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

1
.eslintignore Normal file
View File

@@ -0,0 +1 @@
dist/

View File

@@ -41,6 +41,9 @@
- `children: [` ***[`Category`](#Category)*** `|` [***`Script`***](#Script) `, ... ]` (**required**)
- ❗ Category must consist of at least one subcategory or script.
- Children can be combination of scripts and subcategories.
- `docs`: *`string`* | `[`*`string`*`, ... ]`
- Documentation pieces related to the category.
- Rendered as markdown.
### `Script`
@@ -71,8 +74,8 @@
- A shared function or sequence of functions to call (called in order)
- ❗ If not defined `code` must be defined
- `docs`: *`string`* | `[`*`string`*`, ... ]`
- Single documentation URL or list of URLs for those who wants to learn more about the script
- E.g. `https://docs.microsoft.com/en-us/windows-server/`
- Documentation pieces related to the script.
- Rendered as markdown.
- `recommend`: `"standard"` | `"strict"` | `undefined` (default)
- If not defined then the script will not be recommended
- If defined it can be either

376
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,7 @@
"file-saver": "^2.0.5",
"install": "^0.13.0",
"liquor-tree": "^0.2.70",
"markdown-it": "^13.0.1",
"npm": "^8.5.3",
"v-tooltip": "2.1.3",
"vue": "^2.6.14",

View File

@@ -5,7 +5,7 @@ import { Script } from '@/domain/Script';
import { Category } from '@/domain/Category';
import { NodeValidator } from '@/application/Parser/NodeValidation/NodeValidator';
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { parseDocUrls } from './DocumentationParser';
import { parseDocs } from './DocumentationParser';
import { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext';
import { parseScript } from './Script/ScriptParser';
@@ -50,7 +50,7 @@ function parseCategoryRecursively(context: ICategoryParseContext): Category {
return context.factory(
/* id: */ categoryIdCounter++,
/* name: */ context.categoryData.category,
/* docs: */ parseDocUrls(context.categoryData),
/* docs: */ parseDocs(context.categoryData),
/* categories: */ children.subCategories,
/* scripts: */ children.subScripts,
);

View File

@@ -1,64 +1,58 @@
import type { DocumentableData, DocumentationUrlsData } from '@/application/collections/';
import type { DocumentableData, DocumentationData } from '@/application/collections/';
export function parseDocUrls(documentable: DocumentableData): ReadonlyArray<string> {
export function parseDocs(documentable: DocumentableData): readonly string[] {
if (!documentable) {
throw new Error('missing documentable');
}
const { docs } = documentable;
if (!docs || !docs.length) {
if (!docs) {
return [];
}
let result = new DocumentationUrlContainer();
let result = new DocumentationContainer();
result = addDocs(docs, result);
return result.getAll();
}
function addDocs(
docs: DocumentationUrlsData,
urls: DocumentationUrlContainer,
): DocumentationUrlContainer {
docs: DocumentationData,
container: DocumentationContainer,
): DocumentationContainer {
if (docs instanceof Array) {
urls.addUrls(docs);
} else if (typeof docs === 'string') {
urls.addUrl(docs);
} else {
throw new Error('Docs field (documentation url) must a string or array of strings');
if (docs.length > 0) {
container.addParts(docs);
}
return urls;
} else if (typeof docs === 'string') {
container.addPart(docs);
} else {
throwInvalidType();
}
return container;
}
class DocumentationUrlContainer {
private readonly urls = new Array<string>();
class DocumentationContainer {
private readonly parts = new Array<string>();
public addUrl(url: string) {
validateUrl(url);
this.urls.push(url);
public addPart(documentation: string) {
if (!documentation) {
throw Error('missing documentation');
}
if (typeof documentation !== 'string') {
throwInvalidType();
}
this.parts.push(documentation);
}
public addUrls(urls: readonly string[]) {
for (const url of urls) {
if (typeof url !== 'string') {
throw new Error('Docs field (documentation url) must be an array of strings');
}
this.addUrl(url);
public addParts(parts: readonly string[]) {
for (const part of parts) {
this.addPart(part);
}
}
public getAll(): ReadonlyArray<string> {
return this.urls;
return this.parts;
}
}
function validateUrl(docUrl: string): void {
if (!docUrl) {
throw new Error('Documentation url is null or empty');
}
if (docUrl.includes('\n')) {
throw new Error('Documentation url cannot be multi-lined.');
}
const validUrlRegex = /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g;
const res = docUrl.match(validUrlRegex);
if (res == null) {
throw new Error(`Invalid documentation url: ${docUrl}`);
}
function throwInvalidType() {
throw new Error('docs field (documentation) must be an array of strings');
}

View File

@@ -3,7 +3,7 @@ import { Script } from '@/domain/Script';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
import { parseDocUrls } from '../DocumentationParser';
import { parseDocs } from '../DocumentationParser';
import { createEnumParser, IEnumParser } from '../../Common/Enum';
import { NodeType } from '../NodeValidation/NodeType';
import { NodeValidator } from '../NodeValidation/NodeValidator';
@@ -23,7 +23,7 @@ export function parseScript(
const script = scriptFactory(
/* name: */ data.name,
/* code: */ parseCode(data, context),
/* docs: */ parseDocUrls(data),
/* docs: */ parseDocs(data),
/* level: */ parseLevel(data.recommend, levelParser),
);
return script;

View File

@@ -12,10 +12,10 @@ declare module '@/application/collections/*' {
}
export type CategoryOrScriptData = CategoryData | ScriptData;
export type DocumentationUrlsData = ReadonlyArray<string> | string;
export type DocumentationData = ReadonlyArray<string> | string;
export interface DocumentableData {
readonly docs?: DocumentationUrlsData;
readonly docs?: DocumentationData;
}
export interface InstructionHolder {

View File

@@ -8,7 +8,7 @@ export class Category extends BaseEntity<number> implements ICategory {
constructor(
id: number,
public readonly name: string,
public readonly documentationUrls: ReadonlyArray<string>,
public readonly docs: ReadonlyArray<string>,
public readonly subCategories?: ReadonlyArray<ICategory>,
public readonly scripts?: ReadonlyArray<IScript>,
) {

View File

@@ -1,3 +1,3 @@
export interface IDocumentable {
readonly documentationUrls: ReadonlyArray<string>;
readonly docs: ReadonlyArray<string>;
}

View File

@@ -6,7 +6,7 @@ import { IScriptCode } from './IScriptCode';
export interface IScript extends IEntity<string>, IDocumentable {
readonly name: string;
readonly level?: RecommendationLevel;
readonly documentationUrls: ReadonlyArray<string>;
readonly docs: ReadonlyArray<string>;
readonly code: IScriptCode;
canRevert(): boolean;
}

View File

@@ -7,7 +7,7 @@ export class Script extends BaseEntity<string> implements IScript {
constructor(
public readonly name: string,
public readonly code: IScriptCode,
public readonly documentationUrls: ReadonlyArray<string>,
public readonly docs: ReadonlyArray<string>,
public readonly level?: RecommendationLevel,
) {
super(name);

View File

@@ -0,0 +1,6 @@
<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M10 6v2H5v11h11v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6zm11-3v9l-3.794-3.793-5.999 6-1.414-1.414 5.999-6L12 3h9z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 302 B

View File

@@ -10,12 +10,13 @@
box-sizing: border-box;
}
$globals-color-hover: $color-primary;
a {
color:inherit;
text-decoration: underline;
cursor: pointer;
@include hover-or-touch {
color: $color-primary;
color: $globals-color-hover;
}
}

View File

@@ -19,11 +19,14 @@ $color-node-checkbox-tick-checked : $color-on-secondary;
&-node {
white-space: normal !important;
> .tree-content {
> .tree-anchor > span {
> .tree-anchor {
> span {
color: $color-node-fg;
text-transform: uppercase;
font-size: 1.5em;
}
display: block; // so it takes full width to allow aligning items inside
}
@include hover-or-touch {
background: $color-node-hover-bg !important;
}

View File

@@ -64,7 +64,7 @@ function convertCategoryToNode(
type: NodeType.Category,
text: category.name,
children,
documentationUrls: category.documentationUrls,
docs: category.docs,
isReversible: children && children.every((child) => child.isReversible),
};
}
@@ -75,7 +75,7 @@ function convertScriptToNode(script: IScript): INode {
type: NodeType.Script,
text: script.name,
children: undefined,
documentationUrls: script.documentationUrls,
docs: script.docs,
isReversible: script.canRevert(),
};
}

View File

@@ -13,7 +13,7 @@ declare module 'liquor-tree' {
}
export interface ICustomLiquorTreeData {
type: number;
documentationUrls: ReadonlyArray<string>;
docs: ReadonlyArray<string>;
isReversible: boolean;
}
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js

View File

@@ -11,7 +11,7 @@ export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode):
text: liquorTreeNode.data.text,
// selected: liquorTreeNode.states && liquorTreeNode.states.checked,
children: convertChildren(liquorTreeNode.children, convertExistingToNode),
documentationUrls: liquorTreeNode.data.documentationUrls,
docs: liquorTreeNode.data.docs,
isReversible: liquorTreeNode.data.isReversible,
};
}
@@ -27,7 +27,7 @@ export function toNewLiquorTreeNode(node: INode): ILiquorTreeNewNode {
},
children: convertChildren(node.children, toNewLiquorTreeNode),
data: {
documentationUrls: node.documentationUrls,
docs: node.docs,
isReversible: node.isReversible,
type: node.type,
},

View File

@@ -0,0 +1,71 @@
<template>
<div class="container">
<div class="header">
<div class="content">
<slot />
</div>
<ToggleDocumentationButton
v-if="docs && docs.length > 0"
v-on:show="isExpanded = true"
v-on:hide="isExpanded = false"
/>
</div>
<div
v-if="docs && docs.length > 0 && isExpanded"
class="docs"
v-bind:class="{ 'docs-expanded': isExpanded, 'docs-collapsed': !isExpanded }" >
<DocumentationText
:docs="docs" class="text"
v-bind:class="{ 'expanded': isExpanded, 'collapsed': !isExpanded }" />
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import DocumentationText from './DocumentationText.vue';
import ToggleDocumentationButton from './ToggleDocumentationButton.vue';
@Component({
components: {
DocumentationText,
ToggleDocumentationButton,
},
})
export default class Documentation extends Vue {
@Prop() public docs!: readonly string[];
public isExpanded = false;
}
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.container {
display: flex;
flex-direction: column;
*:not(:first-child) {
margin-left: 5px;
}
.header {
display: flex;
flex-direction: row;
.content {
flex: 1;
}
}
.docs {
background: $color-primary-darkest;
margin-top: 0.25em;
color: $color-on-primary;
text-transform: none;
padding: 0.5em;
&-collapsed {
display: none;
}
cursor: auto;
user-select: text;
}
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div
class="documentation-text"
v-html="renderedText"
v-on:click.stop
>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { createRenderer } from './MarkdownRenderer';
@Component
export default class DocumentationText extends Vue {
@Prop() public docs: readonly string[];
private readonly renderer = createRenderer();
get renderedText(): string {
if (!this.docs || this.docs.length === 0) {
return '';
}
if (this.docs.length === 1) {
return this.renderer.render(this.docs[0]);
}
const bulletpoints = this.docs
.map((doc) => renderAsMarkdownListItem(doc))
.join('\n');
return this.renderer.render(bulletpoints);
}
}
function renderAsMarkdownListItem(content: string): string {
if (!content || content.length === 0) {
throw new Error('missing content');
}
const lines = content.split(/\r\n|\r|\n/);
return `- ${lines[0]}${lines.slice(1)
.map((line) => `\n ${line}`)
.join()}`;
}
</script>
<style lang="scss"> /* Not scoped due to element styling such as "a". */
@use "@/presentation/assets/styles/main" as *;
$text-color: $color-on-primary;
$text-size: 0.75em; // Lower looks bad on Firefox
.documentation-text {
color: $text-color;
font-size: $text-size;
font-family: $font-main;
code {
font-family: $font-normal;
font-weight: 600;
}
a {
&[href] {
word-break: break-word; // So URLs don't overflow
}
&[href^="http"]{
&:after {
/*
Use mask element instead of content/background-image etc.
This way we can apply current font color to it to match the theme
*/
mask: url(~@/presentation/assets/icons/external-link.svg) no-repeat 50% 50%;
mask-size: cover;
content: '';
display: inline-block;
width: $text-size;
height: $text-size;
background-color: $text-color;
margin-left: calc($text-size / 4);
}
/*
Match color of global hover behavior. We need to do it manually because global hover sets
`color` property but here we need to modify `background-color` property because color only
works if SVG is embedded as HTML element (as `<svg/>`) not as `url(..)` as we do. Then the
only option is to use `mask` and `background-color` properties.
*/
@include hover-or-touch {
&::after{
background-color: $globals-color-hover;
}
}
}
}
/*
Different browsers have different <p>, we should even this out.
See CSS 2.1 specification https://www.w3.org/TR/CSS21/sample.html.
*/
p {
/*
Remove surrounding padding so a markdown text that is a list (e.g. <ul>)
has same outer padding as a paragraph (</p>).
*/
margin: 0;
+ p {
margin-top: 1em;
}
}
ul {
// CSS default is 40px, if the text is a bulletpoint, it leads to unexpected padding.
padding-inline-start: 1em;
/*
Set list style explicitly, because otherwise it changes based on parent <ul>s in tree view.
We reset the style from here.
*/
list-style: square;
}
}
</style>

View File

@@ -0,0 +1,156 @@
import MarkdownIt from 'markdown-it';
import Renderer from 'markdown-it/lib/renderer';
import Token from 'markdown-it/lib/token';
export function createRenderer(): IRenderer {
const md = new MarkdownIt({
linkify: true, // Auto-convert URL-like text to links.
breaks: false, // Do not convert single '\n's into <br>.
});
openUrlsInNewTab(md);
return {
render: (markdown: string) => {
markdown = beatifyAutoLinks(markdown);
return md.render(markdown);
},
};
}
export interface IRenderer {
render(markdown: string): string;
}
function beatifyAutoLinks(content: string): string {
if (!content) {
return content;
}
return content.replaceAll(/(?<!\]\(|\[\d+\]:\s+|https?\S+)((?:https?):\/\/[^\s\])]*)(?:[\])](?!\()|$|\s)/gm, (_$, urlMatch) => {
return toReadableLink(urlMatch);
});
}
function toReadableLink(url: string): string {
const parts = new URL(url);
let displayName = toReadableHostName(parts.hostname);
const pageName = extractPageName(parts);
if (pageName) {
displayName += ` - ${truncateRight(capitalizeEachLetter(pageName), 50)}`;
}
return `[${displayName}](${parts.href})`;
}
function toReadableHostName(hostname: string): string {
const wwwStripped = hostname.replace(/^(www\.)/, '');
const truncated = truncateLeft(wwwStripped, 30);
return truncated;
}
function extractPageName(parts: URL): string | undefined {
const path = toReadablePath(parts.pathname);
if (path) {
return path;
}
return toReadableQuery(parts);
}
function toReadableQuery(parts: URL): string | undefined {
const queryValues = [...parts.searchParams.values()];
if (queryValues.length === 0) {
return undefined;
}
return selectMostDescriptiveName(queryValues);
}
function truncateLeft(phrase: string, threshold: number): string {
return phrase.length > threshold ? `${phrase.substring(phrase.length - threshold, phrase.length)}` : phrase;
}
function isDigit(value: string): boolean {
return /^\d+$/.test(value);
}
function toReadablePath(path: string): string | undefined {
const decodedPath = decodeURI(path); // Fixes e.g. %20 to whitespaces
const pathPart = selectMostDescriptiveName(decodedPath.split('/'));
if (!pathPart) {
return undefined;
}
const extensionStripped = removeTrailingExtension(pathPart);
const humanlyTokenized = extensionStripped.replaceAll(/[-_]/g, ' ');
return humanlyTokenized;
}
function removeTrailingExtension(value: string): string {
const parts = value.split('.');
if (parts.length === 1) {
return value;
}
if (parts.at(-1).length > 9) {
return value; // Heuristically web file extension is no longer than 9 chars (e.g. "html")
}
return parts.slice(0, -1).join('.');
}
function capitalizeEachLetter(phrase: string): string {
return phrase
.split(' ')
.map((word) => capitalizeFirstLetter(word))
.join(' ');
function capitalizeFirstLetter(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
}
function truncateRight(phrase: string, threshold: number): string {
return phrase.length > threshold ? `${phrase.substring(0, threshold)}` : phrase;
}
function selectMostDescriptiveName(parts: readonly string[]): string | undefined {
const goodParts = parts.filter(isGoodPathPart);
if (goodParts.length === 0) {
return undefined;
}
const longestGoodPart = goodParts.reduce((a, b) => (a.length > b.length ? a : b));
return longestGoodPart;
}
function isGoodPathPart(part: string): boolean {
return part
&& !isDigit(part) // E.g. article numbers, issue numbers
&& part.length > 2 // This is often non-human categories like T5 etc.
&& !/^index(?:\.\S{0,10}$|$)/.test(part) // E.g. index.html
&& !/^[A-Za-z]{2,4}([_-][A-Za-z]{4})?([_-]([A-Za-z]{2}|[0-9]{3}))$/.test(part) // Locale string e.g. fr-FR
&& !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(part) // GUID
&& !/^[0-9a-f]{40}$/.test(part); // Git SHA (e.g. GitHub links)
}
function openUrlsInNewTab(md: MarkdownIt) {
// https://github.com/markdown-it/markdown-it/blob/12.2.0/docs/architecture.md#renderer
const defaultRender = getDefaultRenderer(md, 'link_open');
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx];
if (!getTokenAttributeValue(token, 'target')) {
token.attrPush(['target', '_blank']);
}
return defaultRender(tokens, idx, options, env, self);
};
}
function getDefaultRenderer(md: MarkdownIt, ruleName: string): Renderer.RenderRule {
const renderer = md.renderer.rules[ruleName];
if (renderer) {
return renderer;
}
return (tokens, idx, options, _env, self) => {
return self.renderToken(tokens, idx, options);
};
}
function getTokenAttributeValue(token: Token, attributeName: string): string | undefined {
const attributeIndex = token.attrIndex(attributeName);
if (attributeIndex < 0) {
return undefined;
}
const value = token.attrs[attributeIndex][1];
return value;
}

View File

@@ -0,0 +1,47 @@
<template>
<a
class="button"
target="_blank"
v-bind:class="{ 'button-on': this.isOn }"
v-on:click.stop
v-on:click="toggle()"
>
<font-awesome-icon :icon="['fas', 'info-circle']" />
</a>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class ToggleDocumentationButton extends Vue {
public isOn = false;
public toggle() {
this.isOn = !this.isOn;
if (this.isOn) {
this.$emit('show');
} else {
this.$emit('hide');
}
}
}
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.button {
@include clickable;
vertical-align: middle;
color: $color-primary;
@include hover-or-touch {
color: $color-primary-darker;
}
&-on {
color: $color-primary-light;
}
}
</style>

View File

@@ -1,45 +0,0 @@
<template>
<div class="documentationUrls">
<a v-for="url of this.documentationUrls"
v-bind:key="url"
:href="url"
:alt="url"
target="_blank" class="documentationUrl"
v-tooltip.top-center="url"
v-on:click.stop>
<font-awesome-icon :icon="['fas', 'info-circle']" />
</a>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component
export default class DocumentationUrls extends Vue {
@Prop() public documentationUrls: string[];
}
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.documentationUrls {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.documentationUrl {
display: flex;
color: $color-primary;
vertical-align: middle;
@include clickable;
@include hover-or-touch {
color: $color-primary-darker;
}
&:not(:first-child) {
margin-left: 0.1em;
}
}
</style>

View File

@@ -7,7 +7,7 @@ export interface INode {
readonly id: string;
readonly text: string;
readonly isReversible: boolean;
readonly documentationUrls: ReadonlyArray<string>;
readonly docs: ReadonlyArray<string>;
readonly children?: ReadonlyArray<INode>;
readonly type: NodeType;
}

View File

@@ -1,27 +1,25 @@
<template>
<Documentable :docs="this.data.docs">
<div id="node">
<div class="item text">{{ this.data.text }}</div>
<RevertToggle
class="item"
v-if="data.isReversible"
:node="data" />
<DocumentationUrls
class="item"
v-if="data.documentationUrls && data.documentationUrls.length > 0"
:documentationUrls="this.data.documentationUrls" />
</div>
</Documentable>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { INode } from './INode';
import RevertToggle from './RevertToggle.vue';
import DocumentationUrls from './DocumentationUrls.vue';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
import Documentable from './Documentation/Documentable.vue';
@Component({
components: {
RevertToggle,
DocumentationUrls,
Documentable,
},
})
export default class Node extends Vue {
@@ -33,7 +31,7 @@ export default class Node extends Vue {
@use "@/presentation/assets/styles/main" as *;
#node {
display:flex;
display: flex;
flex-direction: row;
flex-wrap: wrap;
.text {

View File

@@ -36,10 +36,14 @@ function collectUniqueUrls(app: IApplication): string[] {
return app
.collections
.flatMap((a) => a.getAllScripts())
.flatMap((script) => script.documentationUrls)
.flatMap((script) => script.docs?.flatMap((doc) => parseUrls(doc)))
.filter((url, index, array) => array.indexOf(url) === index);
}
function parseUrls(text: string): string[] {
return text?.match(/\bhttps?:\/\/\S+/gi) ?? [];
}
function printUrls(statuses: IUrlStatus[]): string {
/* eslint-disable prefer-template */
return '\n'

View File

@@ -0,0 +1,46 @@
import 'mocha';
import { expect } from 'chai';
import { parseApplication } from '@/application/Parser/ApplicationParser';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { createRenderer } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/MarkdownRenderer';
describe('MarkdownRenderer', () => {
describe('can render all docs', () => {
// arrange
const renderer = createRenderer();
for (const node of collectAllDocumentableNodes()) {
it(`${node.nodeLabel}`, () => {
// act
const html = renderer.render(node.docs);
// assert
expect(isValidHtml(html));
});
}
});
});
interface IDocumentableNode {
nodeLabel: string
docs: string
}
function* collectAllDocumentableNodes(): Generator<IDocumentableNode> {
const app = parseApplication();
for (const collection of app.collections) {
const documentableNodes = [
...collection.getAllScripts(),
...collection.getAllCategories(),
];
for (const node of documentableNodes) {
const documentable: IDocumentableNode = {
nodeLabel: `${OperatingSystem[collection.os]} | ${node.name} (${node.id})`,
docs: node.docs.join('\n'),
};
yield documentable;
}
}
}
function isValidHtml(value: string): boolean {
const doc = new window.DOMParser().parseFromString(value, 'text/html');
return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1);
}

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;