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:
6
src/presentation/assets/icons/external-link.svg
Normal file
6
src/presentation/assets/icons/external-link.svg
Normal 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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user