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

@@ -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);
if (docs.length > 0) {
container.addParts(docs);
}
} else if (typeof docs === 'string') {
urls.addUrl(docs);
container.addPart(docs);
} else {
throw new Error('Docs field (documentation url) must a string or array of strings');
throwInvalidType();
}
return urls;
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,10 +19,13 @@ $color-node-checkbox-tick-checked : $color-on-secondary;
&-node {
white-space: normal !important;
> .tree-content {
> .tree-anchor > span {
color: $color-node-fg;
text-transform: uppercase;
font-size: 1.5em;
> .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,31 +1,29 @@
<template>
<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 :docs="this.data.docs">
<div id="node">
<div class="item text">{{ this.data.text }}</div>
<RevertToggle
class="item"
v-if="data.isReversible"
:node="data" />
</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 {
@Prop() public data: INode;
@Prop() public data: INode;
}
</script>
@@ -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 {