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:
1
.eslintignore
Normal file
1
.eslintignore
Normal file
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -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
376
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
) {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export interface IDocumentable {
|
||||
readonly documentationUrls: ReadonlyArray<string>;
|
||||
readonly docs: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
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,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;
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -39,7 +39,7 @@ describe('ReverterFactory', () => {
|
||||
id: nodeId,
|
||||
text: 'text',
|
||||
isReversible: false,
|
||||
documentationUrls: [],
|
||||
docs: [],
|
||||
children: [],
|
||||
type,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user