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

@@ -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 {