Improve documentation support with markdown

Rework documentation URLs as inline markdown.

Redesign documentations with markdown text.

Redesign way to document scripts/categories and present the
documentation.

Documentation is showed in an expandable box instead of tooltip. This is
to allow writing longer documentation (tooltips are meant to be used for
short text) and have better experience on mobile.

If a node (script/category) has documentation it's now shown with single
information icon (ℹ) aligned to right.

Add support for rendering documentation as markdown. It automatically
converts plain URLs to URLs with display names (e.g.
https://docs.microsoft.com/..) will be rendered automatically like
"docs.microsoft.com - Windows 11 Privacy...".
This commit is contained in:
undergroundwires
2022-09-25 23:25:43 +02:00
parent 924b326244
commit 6067bdb24e
41 changed files with 973 additions and 265 deletions

View File

@@ -3,7 +3,7 @@ import { expect } from 'chai';
import type { CategoryData, CategoryOrScriptData } from '@/application/collections/';
import { CategoryFactoryType, parseCategory } from '@/application/Parser/CategoryParser';
import { parseScript } from '@/application/Parser/Script/ScriptParser';
import { parseDocUrls } from '@/application/Parser/DocumentationParser';
import { parseDocs } from '@/application/Parser/DocumentationParser';
import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub';
import { ScriptDataStub } from '@tests/unit/shared/Stubs/ScriptDataStub';
import { CategoryCollectionParseContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionParseContextStub';
@@ -157,14 +157,14 @@ describe('CategoryParser', () => {
it('returns expected docs', () => {
// arrange
const url = 'https://privacy.sexy';
const expected = parseDocUrls({ docs: url });
const expected = parseDocs({ docs: url });
const category = new CategoryDataStub()
.withDocs(url);
// act
const actual = new TestBuilder()
.withData(category)
.parseCategory()
.documentationUrls;
.docs;
// assert
expect(actual).to.deep.equal(expected);
});

View File

@@ -1,26 +1,60 @@
import 'mocha';
import { expect } from 'chai';
import type { DocumentableData } from '@/application/collections/';
import { parseDocUrls } from '@/application/Parser/DocumentationParser';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { parseDocs } from '@/application/Parser/DocumentationParser';
import { itEachAbsentObjectValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
describe('DocumentationParser', () => {
describe('parseDocUrls', () => {
describe('throws when absent', () => {
describe('parseDocs', () => {
describe('throws when node is missing', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing documentable';
// act
const act = () => parseDocUrls(absentValue);
const act = () => parseDocs(absentValue);
// assert
expect(act).to.throw(expectedError);
});
});
describe('throws when single documentation is missing', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const expectedError = 'missing documentation';
const node: DocumentableData = { docs: ['non empty doc 1', absentValue] };
// act
const act = () => parseDocs(node);
// assert
expect(act).to.throw(expectedError);
});
});
describe('throws when type is unexpected', () => {
// arrange
const expectedTypeError = 'docs field (documentation) must be an array of strings';
const wrongTypedValue = 22 as never;
const testCases: ReadonlyArray<{ name: string, node: DocumentableData }> = [
{
name: 'given docs',
node: { docs: wrongTypedValue },
},
{
name: 'single doc',
node: { docs: ['non empty doc 1', wrongTypedValue] },
},
];
for (const testCase of testCases) {
it(testCase.name, () => {
// act
const act = () => parseDocs(testCase.node);
// assert
expect(act).to.throw(expectedTypeError);
});
}
});
it('returns empty when empty', () => {
// arrange
const empty: DocumentableData = { };
// act
const actual = parseDocUrls(empty);
const actual = parseDocs(empty);
// assert
expect(actual).to.have.lengthOf(0);
});
@@ -30,7 +64,7 @@ describe('DocumentationParser', () => {
const expected = [url];
const sut: DocumentableData = { docs: url };
// act
const actual = parseDocUrls(sut);
const actual = parseDocs(sut);
// assert
expect(actual).to.deep.equal(expected);
});
@@ -39,7 +73,7 @@ describe('DocumentationParser', () => {
const expected = ['https://privacy.sexy', 'https://github.com/undergroundwires/privacy.sexy'];
const sut: DocumentableData = { docs: expected };
// act
const actual = parseDocUrls(sut);
const actual = parseDocs(sut);
// assert
expect(actual).to.deep.equal(expected);
});

View File

@@ -2,7 +2,7 @@ import 'mocha';
import { expect } from 'chai';
import type { ScriptData } from '@/application/collections/';
import { parseScript, ScriptFactoryType } from '@/application/Parser/Script/ScriptParser';
import { parseDocUrls } from '@/application/Parser/DocumentationParser';
import { parseDocs } from '@/application/Parser/DocumentationParser';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext';
import { itEachAbsentObjectValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
@@ -36,13 +36,13 @@ describe('ScriptParser', () => {
const docs = ['https://expected-doc1.com', 'https://expected-doc2.com'];
const script = ScriptDataStub.createWithCode()
.withDocs(docs);
const expected = parseDocUrls(script);
const expected = parseDocs(script);
// act
const actual = new TestBuilder()
.withData(script)
.parseScript();
// assert
expect(actual.documentationUrls).to.deep.equal(expected);
expect(actual.docs).to.deep.equal(expected);
});
describe('level', () => {
describe('accepts absent level', () => {

View File

@@ -92,15 +92,15 @@ describe('Script', () => {
}
});
});
describe('documentationUrls', () => {
describe('docs', () => {
it('sets as expected', () => {
// arrange
const expected = ['doc1', 'doc2'];
// act
const sut = new ScriptBuilder()
.withDocumentationUrls(expected)
.withDocs(expected)
.build();
const actual = sut.documentationUrls;
const actual = sut.docs;
// assert
expect(actual).to.equal(expected);
});
@@ -115,7 +115,7 @@ class ScriptBuilder {
private level = RecommendationLevel.Standard;
private documentationUrls: readonly string[];
private docs: readonly string[] = undefined;
public withCodes(code: string, revertCode = ''): ScriptBuilder {
this.code = new ScriptCodeStub()
@@ -139,8 +139,8 @@ class ScriptBuilder {
return this;
}
public withDocumentationUrls(urls: readonly string[]): ScriptBuilder {
this.documentationUrls = urls;
public withDocs(urls: readonly string[]): ScriptBuilder {
this.docs = urls;
return this;
}
@@ -148,7 +148,7 @@ class ScriptBuilder {
return new Script(
this.name,
this.code,
this.documentationUrls,
this.docs,
this.level,
);
}

View File

@@ -90,7 +90,7 @@ function isReversible(category: ICategory): boolean {
function expectSameCategory(node: INode, category: ICategory): void {
expect(node.type).to.equal(NodeType.Category, getErrorMessage('type'));
expect(node.id).to.equal(getCategoryNodeId(category), getErrorMessage('id'));
expect(node.documentationUrls).to.equal(category.documentationUrls, getErrorMessage('documentationUrls'));
expect(node.docs).to.equal(category.docs, getErrorMessage('docs'));
expect(node.text).to.equal(category.name, getErrorMessage('name'));
expect(node.isReversible).to.equal(isReversible(category), getErrorMessage('isReversible'));
expect(node.children).to.have.lengthOf(category.scripts.length || category.subCategories.length, getErrorMessage('name'));
@@ -110,7 +110,7 @@ function expectSameCategory(node: INode, category: ICategory): void {
function expectSameScript(node: INode, script: IScript): void {
expect(node.type).to.equal(NodeType.Script, getErrorMessage('type'));
expect(node.id).to.equal(getScriptNodeId(script), getErrorMessage('id'));
expect(node.documentationUrls).to.equal(script.documentationUrls, getErrorMessage('documentationUrls'));
expect(node.docs).to.equal(script.docs, getErrorMessage('docs'));
expect(node.text).to.equal(script.name, getErrorMessage('name'));
expect(node.isReversible).to.equal(script.canRevert(), getErrorMessage('canRevert'));
expect(node.children).to.equal(undefined);

View File

@@ -12,7 +12,7 @@ describe('NodePredicateFilter', () => {
data: {
text: 'script-text',
type: NodeType.Script,
documentationUrls: [],
docs: [],
isReversible: false,
},
states: undefined,
@@ -22,7 +22,7 @@ describe('NodePredicateFilter', () => {
id: 'script',
text: 'script-text',
isReversible: false,
documentationUrls: [],
docs: [],
children: [],
type: NodeType.Script,
};
@@ -54,7 +54,7 @@ function getExistingNode(): ILiquorTreeExistingNode {
data: {
text: 'script-text',
type: NodeType.Script,
documentationUrls: [],
docs: [],
isReversible: false,
},
states: undefined,

View File

@@ -32,16 +32,16 @@ describe('NodeStateUpdater', () => {
// arrange
const node = {
id: '1',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [
{
id: '2',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [getScriptNode('a'), getScriptNode('b')],
},
{
id: '3',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [getScriptNode('c')],
},
],
@@ -56,16 +56,16 @@ describe('NodeStateUpdater', () => {
// arrange
const node = {
id: '1',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [
{
id: '2',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [getScriptNode('a'), getScriptNode('b')],
},
{
id: '3',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [getScriptNode('c')],
},
],
@@ -80,16 +80,16 @@ describe('NodeStateUpdater', () => {
// arrange
const node = {
id: '1',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [
{
id: '2',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [getScriptNode('a'), getScriptNode('b')],
},
{
id: '3',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [getScriptNode('c')],
},
],
@@ -128,16 +128,16 @@ describe('NodeStateUpdater', () => {
// arrange
const node = {
id: '1',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [
{
id: '2',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [getScriptNode('a'), getScriptNode('b')],
},
{
id: '3',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [getScriptNode('c')],
},
],
@@ -152,16 +152,16 @@ describe('NodeStateUpdater', () => {
// arrange
const node = {
id: '1',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [
{
id: '2',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [getScriptNode('a'), getScriptNode('b')],
},
{
id: '3',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [getScriptNode('c')],
},
],
@@ -176,16 +176,16 @@ describe('NodeStateUpdater', () => {
// arrange
const node = {
id: '1',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [
{
id: '2',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [getScriptNode('a'), getScriptNode('b')],
},
{
id: '3',
data: { type: NodeType.Category, documentationUrls: [], isReversible: false },
data: { type: NodeType.Category, docs: [], isReversible: false },
children: [getScriptNode('c')],
},
],
@@ -204,7 +204,7 @@ describe('NodeStateUpdater', () => {
id: scriptNodeId,
data: {
type: NodeType.Script,
documentationUrls: [],
docs: [],
isReversible: false,
},
children: [],

View File

@@ -33,20 +33,20 @@ function getNode(): INode {
text: 'parentcategory',
isReversible: true,
type: NodeType.Category,
documentationUrls: ['parentcategory-url1', 'parentcategory-url2'],
docs: ['parentcategory-doc1', 'parentcategory-doc2'],
children: [
{
id: '2',
text: 'subcategory',
isReversible: true,
documentationUrls: ['subcategory-url1', 'subcategory-url2'],
docs: ['subcategory-doc1', 'subcategory-doc2'],
type: NodeType.Category,
children: [
{
id: 'script1',
text: 'cool script 1',
isReversible: true,
documentationUrls: ['script1url1', 'script1url2'],
docs: ['script1-doc1', 'script1-doc2'],
children: [],
type: NodeType.Script,
},
@@ -54,7 +54,7 @@ function getNode(): INode {
id: 'script2',
text: 'cool script 2',
isReversible: true,
documentationUrls: ['script2url1', 'script2url2'],
docs: ['script2-doc1', 'script2-doc2'],
children: [],
type: NodeType.Script,
}],
@@ -66,7 +66,7 @@ function getExpectedExistingNodeData(node: INode): ILiquorTreeNodeData {
return {
text: node.text,
type: node.type,
documentationUrls: node.documentationUrls,
docs: node.docs,
isReversible: node.isReversible,
};
}
@@ -74,7 +74,7 @@ function getExpectedExistingNodeData(node: INode): ILiquorTreeNodeData {
function getExpectedNewNodeData(node: INode): ICustomLiquorTreeData {
return {
type: node.type,
documentationUrls: node.documentationUrls,
docs: node.docs,
isReversible: node.isReversible,
};
}

View File

@@ -0,0 +1,69 @@
import 'mocha';
import { expect } from 'chai';
import { createRenderer } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/MarkdownRenderer';
describe('MarkdownRenderer', () => {
describe('createRenderer', () => {
it('can create', () => {
// arrange & act
const renderer = createRenderer();
// assert
expect(renderer !== undefined);
});
it('opens URLs in new tab', () => {
// arrange
const renderer = createRenderer();
const markdown = '[undergroundwires.dev](https://undergroundwires.dev)';
// act
const htmlString = renderer.render(markdown);
// assert
const html = parseHtml(htmlString);
const aElement = html.getElementsByTagName('a')[0];
const href = aElement.getAttribute('target');
expect(href).to.equal('_blank');
});
it('does not convert single linebreak to <br>', () => {
// arrange
const renderer = createRenderer();
const markdown = 'Text with\nSingle\nLinebreaks';
// act
const htmlString = renderer.render(markdown);
// assert
const html = parseHtml(htmlString);
const totalBrElements = html.getElementsByTagName('br').length;
expect(totalBrElements).to.equal(0);
});
it('creates links for plain URL', () => {
// arrange
const renderer = createRenderer();
const expectedUrl = 'https://privacy.sexy/';
const markdown = `Visit ${expectedUrl} now!`;
// act
const htmlString = renderer.render(markdown);
// assert
const html = parseHtml(htmlString);
const aElement = html.getElementsByTagName('a')[0];
const href = aElement.getAttribute('href');
expect(href).to.equal(expectedUrl);
});
it('it generates beautiful labels for auto-linkified URL', () => {
// arrange
const renderer = createRenderer();
const url = 'https://privacy.sexy';
const expectedText = 'privacy.sexy';
const markdown = `Visit ${url} now!`;
// act
const htmlString = renderer.render(markdown);
// assert
const html = parseHtml(htmlString);
const aElement = html.getElementsByTagName('a')[0];
expect(aElement.text).to.equal(expectedText);
});
});
});
function parseHtml(htmlString: string): Document {
const parser = new window.DOMParser();
const htmlDoc = parser.parseFromString(htmlString, 'text/html');
return htmlDoc;
}

View File

@@ -39,7 +39,7 @@ describe('ReverterFactory', () => {
id: nodeId,
text: 'text',
isReversible: false,
documentationUrls: [],
docs: [],
children: [],
type,
};

View File

@@ -1,4 +1,4 @@
import type { CategoryData, CategoryOrScriptData, DocumentationUrlsData } from '@/application/collections/';
import type { CategoryData, CategoryOrScriptData, DocumentationData } from '@/application/collections/';
import { ScriptDataStub } from './ScriptDataStub';
export class CategoryDataStub implements CategoryData {
@@ -6,7 +6,7 @@ export class CategoryDataStub implements CategoryData {
public category = 'category name';
public docs?: DocumentationUrlsData;
public docs?: DocumentationData;
public withChildren(children: readonly CategoryOrScriptData[]) {
this.children = children;
@@ -18,7 +18,7 @@ export class CategoryDataStub implements CategoryData {
return this;
}
public withDocs(docs: DocumentationUrlsData) {
public withDocs(docs: DocumentationData) {
this.docs = docs;
return this;
}

View File

@@ -9,7 +9,7 @@ export class CategoryStub extends BaseEntity<number> implements ICategory {
public readonly scripts = new Array<IScript>();
public readonly documentationUrls = new Array<string>();
public readonly docs = new Array<string>();
public constructor(id: number) {
super(id);

View File

@@ -33,7 +33,7 @@ export class ScriptDataStub implements ScriptData {
public recommend = RecommendationLevel[RecommendationLevel.Standard].toLowerCase();
public docs = ['hello.com'];
public docs?: readonly string[] = ['hello.com'];
private constructor() { /* use static methods for constructing */ }
@@ -42,7 +42,7 @@ export class ScriptDataStub implements ScriptData {
return this;
}
public withDocs(docs: string[]): ScriptDataStub {
public withDocs(docs: readonly string[]): ScriptDataStub {
this.docs = docs;
return this;
}

View File

@@ -11,7 +11,7 @@ export class ScriptStub extends BaseEntity<string> implements IScript {
revert: `REM revert-code (${this.id})`,
};
public readonly documentationUrls = new Array<string>();
public readonly docs = new Array<string>();
public level? = RecommendationLevel.Standard;