Render bracket references as superscript text

This commit improves markdown rendering to convert reference labels
(e.g., `[1]`) to superscripts, improving document readability without
cluttering the text. This improvement applies documentation of all
scripts and categories.

Changes:

- Implement superscript conversion for reference labels within markdown
  content, ensuring a cleaner presentation of textual references.
- Enable HTML content within markdown, necessary for inserting `<sup>`
  elements due to limitations in `markdown-it`, see
  markdown-it/markdown-it#999 for details.
- Refactor markdown rendering process for improved testability and
  adherence to the Single Responsibility Principle.
- Create `_typography.scss` with font size definitions, facilitating
  better control over text presentation.
- Adjust external URL indicator icon sizing for consistency, aligning
  images with the top of the text to maintain a uniform appearence.
- Use normal font-size explicitly for documentation text to ensure
  consistency.
- Remove text size specification in `markdown-styles` mixin, using `1em`
  for spacing to simplify styling.
- Rename font sizing variables for clarity, distinguishing between
  absolute and relative units.
- Change `font-size-relative-smaller` to be `80%`, browser default for
  `font-size: smaller;` CSS style and use it with `<sup>` elements.
- Improve the logic for converting plain URLs to hyperlinks, removing
  trailing whitespace for cleaner link generation.
- Fix plain URL to hyperlink (autolinking) logic removing trailing
  whitespace from the original markdown content. This was revealed by
  tests after separating its logic.
- Increase test coverage with more tests.
- Add types for `markdown-it` through `@types/markdown-it` package for
  better editor support and maintainability.
- Simplify implementation of adding custom anchor attributes in
  `markdown-it` using latest documentation.
This commit is contained in:
undergroundwires
2024-02-09 16:25:05 +01:00
parent 311fcb1813
commit b9c89b701f
42 changed files with 1036 additions and 378 deletions

41
package-lock.json generated
View File

@@ -6,11 +6,12 @@
"packages": { "packages": {
"": { "": {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.12.9", "version": "0.12.10",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@floating-ui/vue": "^1.0.2", "@floating-ui/vue": "^1.0.2",
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
"@types/markdown-it": "^13.0.7",
"ace-builds": "^1.30.0", "ace-builds": "^1.30.0",
"electron-log": "^5.0.1", "electron-log": "^5.0.1",
"electron-progressbar": "^2.1.0", "electron-progressbar": "^2.1.0",
@@ -3395,6 +3396,20 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/linkify-it": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw=="
},
"node_modules/@types/markdown-it": {
"version": "13.0.7",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.7.tgz",
"integrity": "sha512-U/CBi2YUUcTHBt5tjO2r5QV/x0Po6nsYwQU4Y04fBS6vfoImaiZ6f8bi3CjTCxBPQSO1LMyUqkByzi8AidyxfA==",
"dependencies": {
"@types/linkify-it": "*",
"@types/mdurl": "*"
}
},
"node_modules/@types/mdast": { "node_modules/@types/mdast": {
"version": "3.0.12", "version": "3.0.12",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.12.tgz", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.12.tgz",
@@ -3404,6 +3419,11 @@
"@types/unist": "^2" "@types/unist": "^2"
} }
}, },
"node_modules/@types/mdurl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA=="
},
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "0.7.31", "version": "0.7.31",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
@@ -21964,6 +21984,20 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/linkify-it": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw=="
},
"@types/markdown-it": {
"version": "13.0.7",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.7.tgz",
"integrity": "sha512-U/CBi2YUUcTHBt5tjO2r5QV/x0Po6nsYwQU4Y04fBS6vfoImaiZ6f8bi3CjTCxBPQSO1LMyUqkByzi8AidyxfA==",
"requires": {
"@types/linkify-it": "*",
"@types/mdurl": "*"
}
},
"@types/mdast": { "@types/mdast": {
"version": "3.0.12", "version": "3.0.12",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.12.tgz", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.12.tgz",
@@ -21973,6 +22007,11 @@
"@types/unist": "^2" "@types/unist": "^2"
} }
}, },
"@types/mdurl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA=="
},
"@types/ms": { "@types/ms": {
"version": "0.7.31", "version": "0.7.31",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",

View File

@@ -35,6 +35,7 @@
"dependencies": { "dependencies": {
"@floating-ui/vue": "^1.0.2", "@floating-ui/vue": "^1.0.2",
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
"@types/markdown-it": "^13.0.7",
"ace-builds": "^1.30.0", "ace-builds": "^1.30.0",
"electron-log": "^5.0.1", "electron-log": "^5.0.1",
"electron-progressbar": "^2.1.0", "electron-progressbar": "^2.1.0",

View File

@@ -33,12 +33,3 @@
$font-normal : 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; $font-normal : 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
$font-artistic : 'Yesteryear', cursive; $font-artistic : 'Yesteryear', cursive;
$font-main : 'Slabo 27px'; $font-main : 'Slabo 27px';
$font-size-smaller : 14px;
$font-size-small : 16px;
$font-size-normal : 18px;
$font-size-large : 22px;
$font-size-larger : 26px;
$font-size-largest : 40px;
$font-size-relative-smaller: 85%;

View File

@@ -6,6 +6,7 @@
@use "@/presentation/assets/styles/fonts" as *; @use "@/presentation/assets/styles/fonts" as *;
@use "@/presentation/assets/styles/mixins" as *; @use "@/presentation/assets/styles/mixins" as *;
@use "@/presentation/assets/styles/vite-path" as *; @use "@/presentation/assets/styles/vite-path" as *;
@use "@/presentation/assets/styles/typography" as *;
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -20,5 +21,5 @@ a {
body { body {
background: $color-background; background: $color-background;
font-family: $font-main; font-family: $font-main;
font-size: $font-size-normal; font-size: $font-size-absolute-normal;
} }

View File

@@ -1,5 +1,6 @@
@use "@/presentation/assets/styles/colors" as *; @use "@/presentation/assets/styles/colors" as *;
@use "@/presentation/assets/styles/fonts" as *; @use "@/presentation/assets/styles/fonts" as *;
@use "@/presentation/assets/styles/typography" as *;
@mixin hover-or-touch($selector-suffix: '', $selector-prefix: '&') { @mixin hover-or-touch($selector-suffix: '', $selector-prefix: '&') {
@media (hover: hover) { @media (hover: hover) {
@@ -69,6 +70,11 @@
list-style: none; list-style: none;
} }
@mixin reset-sup {
vertical-align: unset;
font-size: unset;
}
@mixin reset-button { @mixin reset-button {
margin: 0; margin: 0;
padding-block: 0; padding-block: 0;
@@ -93,7 +99,7 @@
@mixin flat-button($disabled: false) { @mixin flat-button($disabled: false) {
@include reset-button; @include reset-button;
$font-size: $font-size-normal; $font-size: $font-size-absolute-normal;
@if $disabled { @if $disabled {
color: $color-primary-light; color: $color-primary-light;

View File

@@ -0,0 +1,19 @@
/*
This naming convention for font sizes adheres to CSS standards, distinguishing between absolute and relative sizes.
We prefix each variable with its type (absolute or relative) for clear identification and context.
*/
// Absolute sizes use the <absolute-size> CSS data type, representing specific, fixed sizes unaffected by the parent element's size.
// See: https://archive.today/2024.02.02-005228/https://developer.mozilla.org/en-US/docs/Web/CSS/absolute-size.
$font-size-absolute-x-small : 14px;
$font-size-absolute-small : 16px;
$font-size-absolute-normal : 18px;
$font-size-absolute-large : 22px;
$font-size-absolute-x-large : 26px;
$font-size-absolute-xx-large : 40px;
// Relative sizes employ the <relative-size> CSS data type, allowing font size adjustments based on the parent element's size.
// See: https://archive.today/2024.02.02-010054/https://developer.mozilla.org/en-US/docs/Web/CSS/relative-size.
$font-size-relative-smallest : 80%; // Common browser standard for `font-size: smaller;`
$font-size-relative-smaller : 85%; // Common browser standard for `font-size: smaller;`

View File

@@ -1,6 +1,7 @@
/* This class is not supposed to more than forwarding other styles */ /* This class is not supposed to more than forwarding other styles */
@forward "./fonts"; @forward "./fonts";
@forward "./typography";
@forward "./media"; @forward "./media";
@forward "./colors"; @forward "./colors";
@forward "./globals"; @forward "./globals";

View File

@@ -81,7 +81,7 @@ export default defineComponent({
border-radius: 4px; border-radius: 4px;
.button__icon { .button__icon {
font-size: $font-size-larger; font-size: $font-size-absolute-x-large;
} }
@include clickable; @include clickable;
@@ -99,7 +99,7 @@ export default defineComponent({
.button__text { .button__text {
display: none; display: none;
font-family: $font-artistic; font-family: $font-artistic;
font-size: $font-size-large; font-size: $font-size-absolute-large;
color: $color-primary; color: $color-primary;
font-weight: 500; font-weight: 500;
@include hover-or-touch { @include hover-or-touch {

View File

@@ -63,14 +63,14 @@ export default defineComponent({
padding: 0.2rem; padding: 0.2rem;
.dollar { .dollar {
margin-right: 0.5rem; margin-right: 0.5rem;
font-size: $font-size-smaller; font-size: $font-size-absolute-x-small;
user-select: none; user-select: none;
} }
.copy-action-container { .copy-action-container {
margin-left: 1rem; margin-left: 1rem;
} }
code { code {
font-size: $font-size-small; font-size: $font-size-absolute-small;
} }
} }
</style> </style>

View File

@@ -203,7 +203,7 @@ function getDefaultCode(language: ScriptingLanguage): string {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: auto; overflow: auto;
font-size: $font-size-small; font-size: $font-size-absolute-small;
&__highlight { &__highlight {
background-color: $color-secondary-light; background-color: $color-secondary-light;
position: absolute; position: absolute;

View File

@@ -142,7 +142,7 @@ function isClickable(element: Element) {
.error { .error {
width: 100%; width: 100%;
text-align: center; text-align: center;
font-size: $font-size-largest; font-size: $font-size-absolute-xx-large;
font-family: $font-normal; font-family: $font-normal;
} }

View File

@@ -168,7 +168,7 @@ $card-horizontal-gap : $card-gap;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
justify-content: center; justify-content: center;
font-size: $font-size-large; font-size: $font-size-absolute-large;
} }
.card__inner__selection_indicator { .card__inner__selection_indicator {
height: $card-inner-padding; height: $card-inner-padding;
@@ -181,7 +181,7 @@ $card-horizontal-gap : $card-gap;
width: 100%; width: 100%;
margin-top: .25em; margin-top: .25em;
vertical-align: middle; vertical-align: middle;
font-size: $font-size-normal; font-size: $font-size-absolute-normal;
} }
} }
.card__expander { .card__expander {
@@ -203,7 +203,7 @@ $card-horizontal-gap : $card-gap;
} }
.card__expander__close-button { .card__expander__close-button {
font-size: $font-size-large; font-size: $font-size-absolute-large;
align-self: flex-end; align-self: flex-end;
margin-right: 0.25em; margin-right: 0.25em;
@include clickable; @include clickable;

View File

@@ -57,6 +57,6 @@ export default defineComponent({
<style scoped lang="scss"> <style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *; @use "@/presentation/assets/styles/main" as *;
.icon { .icon {
font-size: $font-size-normal; font-size: $font-size-absolute-normal;
} }
</style> </style>

View File

@@ -151,7 +151,7 @@ $margin-inner: 4px;
margin-top: 1em; margin-top: 1em;
color: $color-primary-light; color: $color-primary-light;
.search__query__close-button { .search__query__close-button {
font-size: $font-size-large; font-size: $font-size-absolute-large;
margin-left: 0.25rem; margin-left: 0.25rem;
} }
} }
@@ -160,7 +160,7 @@ $margin-inner: 4px;
flex-direction: column; flex-direction: column;
word-break:break-word; word-break:break-word;
color: $color-on-primary; color: $color-on-primary;
font-size: $font-size-large; font-size: $font-size-absolute-large;
padding:10px; padding:10px;
text-align:center; text-align:center;
> div { > div {

View File

@@ -59,7 +59,7 @@ function formatAsMarkdownListItem(content: string): string {
flex: 1; // Expands the container to fill available horizontal space, enabling alignment of child items. flex: 1; // Expands the container to fill available horizontal space, enabling alignment of child items.
max-width: 100%; // Prevents horizontal expansion of inner content (e.g., when a code block is shown) max-width: 100%; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
font-size: $font-size-normal; font-size: $font-size-absolute-normal;
font-family: $font-main; font-family: $font-main;
} }
</style> </style>

View File

@@ -49,7 +49,7 @@ export default defineComponent({
.documentation-button { .documentation-button {
vertical-align: middle; vertical-align: middle;
color: $color-primary; color: $color-primary;
font-size: $font-size-large; font-size: $font-size-absolute-large;
:deep() { // This override leads to inconsistent highlight color, it should be re-styled. :deep() { // This override leads to inconsistent highlight color, it should be re-styled.
@include hover-or-touch { @include hover-or-touch {
color: $color-primary-darker; color: $color-primary-darker;

View File

@@ -0,0 +1,28 @@
import { InlineReferenceLabelsToSuperscriptConverter } from './Renderers/InlineReferenceLabelsToSuperscriptConverter';
import { MarkdownItHtmlRenderer } from './Renderers/MarkdownItHtmlRenderer';
import { PlainTextUrlsToHyperlinksConverter } from './Renderers/PlainTextUrlsToHyperlinksConverter';
import type { MarkdownRenderer } from './MarkdownRenderer';
export class CompositeMarkdownRenderer implements MarkdownRenderer {
constructor(
private readonly renderers: readonly MarkdownRenderer[] = StandardMarkdownRenderers,
) {
if (!renderers.length) {
throw new Error('missing renderers');
}
}
public render(markdownContent: string): string {
let renderedContent = markdownContent;
for (const renderer of this.renderers) {
renderedContent = renderer.render(renderedContent);
}
return renderedContent;
}
}
const StandardMarkdownRenderers: readonly MarkdownRenderer[] = [
new PlainTextUrlsToHyperlinksConverter(),
new InlineReferenceLabelsToSuperscriptConverter(),
new MarkdownItHtmlRenderer(),
] as const;

View File

@@ -1,196 +1,3 @@
import MarkdownIt from 'markdown-it'; export interface MarkdownRenderer {
import Renderer from 'markdown-it/lib/renderer';
import Token from 'markdown-it/lib/token';
export function createMarkdownRenderer(): MarkdownRenderer {
const markdownParser = new MarkdownIt({
linkify: false, // Disables auto-linking; handled manually for custom formatting.
breaks: false, // Disables conversion of single newlines (`\n`) to HTML breaks (`<br>`).
});
configureLinksToOpenInNewTab(markdownParser);
return {
render: (markdownContent: string) => {
markdownContent = beautifyAutoLinkedUrls(markdownContent);
return markdownParser.render(markdownContent);
},
};
}
interface MarkdownRenderer {
render(markdownContent: string): string; render(markdownContent: string): string;
} }
const PlainTextUrlInMarkdownRegex = /(?<!\]\(|\[\d+\]:\s+|https?\S+|`)((?:https?):\/\/[^\s\])]*)(?:[\])](?!\()|$|\s)/gm;
function beautifyAutoLinkedUrls(content: string): string {
if (!content) {
return content;
}
return content.replaceAll(PlainTextUrlInMarkdownRegex, (_fullMatch, url) => {
return formatReadableLink(url);
});
}
function formatReadableLink(url: string): string {
const urlParts = new URL(url);
let displayText = formatHostName(urlParts.hostname);
const pageLabel = extractPageLabel(urlParts);
if (pageLabel) {
displayText += ` - ${truncateTextFromEnd(capitalizeEachWord(pageLabel), 50)}`;
}
if (displayText.includes('Msdn.microsoft')) {
console.log(`[${displayText}](${urlParts.href})`);
}
return buildMarkdownLink(displayText, urlParts.href);
}
function formatHostName(hostname: string): string {
const withoutWww = hostname.replace(/^(www\.)/, '');
const truncatedHostName = truncateTextFromStart(withoutWww, 30);
return truncatedHostName;
}
function extractPageLabel(urlParts: URL): string | undefined {
const readablePath = makePathReadable(urlParts.pathname);
if (readablePath) {
return readablePath;
}
return formatQueryParameters(urlParts.searchParams);
}
function buildMarkdownLink(label: string, url: string): string {
return `[${label}](${url})`;
}
function formatQueryParameters(queryParameters: URLSearchParams): string | undefined {
const queryValues = [...queryParameters.values()];
if (queryValues.length === 0) {
return undefined;
}
return findMostDescriptiveName(queryValues);
}
function truncateTextFromStart(text: string, maxLength: number): string {
return text.length > maxLength ? `${text.substring(text.length - maxLength)}` : text;
}
function truncateTextFromEnd(text: string, maxLength: number): string {
return text.length > maxLength ? `${text.substring(0, maxLength)}` : text;
}
function isNumeric(value: string): boolean {
return /^\d+$/.test(value);
}
function makePathReadable(path: string): string | undefined {
const decodedPath = decodeURI(path); // Decode URI components, e.g., '%20' to space
const pathParts = decodedPath.split('/');
const decodedPathParts = pathParts // Split then decode to correctly handle '%2F' as '/'
.map((pathPart) => decodeURIComponent(pathPart));
const descriptivePart = findMostDescriptiveName(decodedPathParts);
if (!descriptivePart) {
return undefined;
}
const withoutExtension = removeFileExtension(descriptivePart);
const tokenizedText = tokenizeTextForReadability(withoutExtension);
return tokenizedText;
}
function tokenizeTextForReadability(text: string): string {
return text
.replaceAll(/[-_+]/g, ' ') // Replace hyphens, underscores, and plus signs with spaces
.replaceAll(/\s+/g, ' '); // Collapse multiple consecutive spaces into a single space
}
function removeFileExtension(value: string): string {
const parts = value.split('.');
if (parts.length === 1) {
return value;
}
const extension = parts[parts.length - 1];
if (extension.length > 9) {
return value; // Heuristically web file extension is no longer than 9 chars (e.g. "html")
}
return parts.slice(0, -1).join('.');
}
function capitalizeEachWord(text: string): string {
return text
.split(' ')
.map((word) => capitalizeFirstLetter(word))
.join(' ');
}
function capitalizeFirstLetter(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}
function findMostDescriptiveName(segments: readonly string[]): string | undefined {
const meaningfulSegments = segments.filter(isMeaningfulPathSegment);
if (meaningfulSegments.length === 0) {
return undefined;
}
const longestGoodSegment = meaningfulSegments.reduce((a, b) => (a.length > b.length ? a : b));
return longestGoodSegment;
}
function isMeaningfulPathSegment(segment: string): boolean {
return segment.length > 2 // This is often non-human categories like T5 etc.
&& !isNumeric(segment) // E.g. article numbers, issue numbers
&& !/^index(?:\.\S{0,10}$|$)/.test(segment) // E.g. index.html
&& !/^[A-Za-z]{2,4}([_-][A-Za-z]{4})?([_-]([A-Za-z]{2}|[0-9]{3}))$/.test(segment) // 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(segment) // GUID
&& !/^[0-9a-f]{40}$/.test(segment); // Git SHA (e.g. GitHub links)
}
const AnchorAttributesForExternalLinks: Record<string, string> = {
target: '_blank',
rel: 'noopener noreferrer',
};
function configureLinksToOpenInNewTab(markdownParser: MarkdownIt): void {
// https://github.com/markdown-it/markdown-it/blob/12.2.0/docs/architecture.md#renderer
const defaultLinkRenderer = getDefaultRenderer(markdownParser, 'link_open');
markdownParser.renderer.rules.link_open = (tokens, index, options, env, self) => {
const currentToken = tokens[index];
Object.entries(AnchorAttributesForExternalLinks).forEach(([attribute, value]) => {
const existingValue = getTokenAttribute(currentToken, attribute);
if (!existingValue) {
addAttributeToToken(currentToken, attribute, value);
} else if (existingValue !== value) {
updateTokenAttribute(currentToken, attribute, value);
}
});
return defaultLinkRenderer(tokens, index, options, env, self);
};
}
function getDefaultRenderer(md: MarkdownIt, ruleName: string): Renderer.RenderRule {
const ruleRenderer = md.renderer.rules[ruleName];
return ruleRenderer || renderTokenAsDefault;
function renderTokenAsDefault(tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options);
}
}
function getTokenAttribute(token: Token, attributeName: string): string | undefined {
const attributeIndex = token.attrIndex(attributeName);
if (attributeIndex < 0) {
return undefined;
}
const value = token.attrs[attributeIndex][1];
return value;
}
function addAttributeToToken(token: Token, attributeName: string, value: string): void {
token.attrPush([attributeName, value]);
}
function updateTokenAttribute(token: Token, attributeName: string, newValue: string): void {
const attributeIndex = token.attrIndex(attributeName);
if (attributeIndex < 0) {
throw new Error(`Attribute "${attributeName}" not found in token.`);
}
token.attrs[attributeIndex][1] = newValue;
}

View File

@@ -8,7 +8,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, computed } from 'vue'; import { defineComponent, computed } from 'vue';
import { createMarkdownRenderer } from './MarkdownRenderer'; import { CompositeMarkdownRenderer } from './CompositeMarkdownRenderer';
export default defineComponent({ export default defineComponent({
props: { props: {
@@ -26,9 +26,8 @@ export default defineComponent({
}, },
}); });
const renderer = createMarkdownRenderer();
function convertMarkdownToHtml(markdownText: string): string { function convertMarkdownToHtml(markdownText: string): string {
const renderer = new CompositeMarkdownRenderer();
return renderer.render(markdownText); return renderer.render(markdownText);
} }
</script> </script>
@@ -38,12 +37,11 @@ function convertMarkdownToHtml(markdownText: string): string {
@import './markdown-styles.scss'; @import './markdown-styles.scss';
$text-color: $color-on-primary; $text-color: $color-on-primary;
$text-size: 0.75em; // Lower looks bad on Firefox
.markdown-text { .markdown-text {
color: $text-color; color: $text-color;
font-size: $text-size; font-size: $font-size-absolute-normal;
font-family: $font-main; font-family: $font-main;
@include markdown-text-styles($text-size: $text-size); @include markdown-text-styles;
} }
</style> </style>

View File

@@ -0,0 +1,36 @@
import type { MarkdownRenderer } from '../MarkdownRenderer';
export class InlineReferenceLabelsToSuperscriptConverter implements MarkdownRenderer {
public render(markdownContent: string): string {
return convertInlineReferenceLabelsToSuperscript(markdownContent);
}
}
function convertInlineReferenceLabelsToSuperscript(content: string): string {
if (!content) {
return content;
}
return content.replaceAll(TextInsideBracketsPattern, (_fullMatch, label, offset) => {
if (!isInlineReferenceLabel(label, content, offset)) {
return `[${label}]`;
}
return `<sup>[${label}]</sup>`;
});
}
function isInlineReferenceLabel(
referenceLabel: string,
markdownText: string,
openingBracketPosition: number,
): boolean {
const referenceLabelDefinitionIndex = markdownText.indexOf(`\n[${referenceLabel}]: `);
if (openingBracketPosition - 1 /* -1 for newline */ === referenceLabelDefinitionIndex) {
return false; // It is a reference definition, not a label.
}
if (referenceLabelDefinitionIndex === -1) {
return false; // The reference definition is missing.
}
return true;
}
const TextInsideBracketsPattern = /\[(.*?)\]/gm;

View File

@@ -0,0 +1,40 @@
import MarkdownIt from 'markdown-it';
import type { MarkdownRenderer } from '../MarkdownRenderer';
import type { RenderRule } from 'markdown-it/lib/renderer';
export class MarkdownItHtmlRenderer implements MarkdownRenderer {
public render(markdownContent: string): string {
const markdownParser = new MarkdownIt({
html: true, // Enable HTML tags in source to allow other custom rendering logic.
linkify: false, // Disables auto-linking; handled manually for custom formatting.
breaks: false, // Disables conversion of single newlines (`\n`) to HTML breaks (`<br>`).
});
configureLinksToOpenInNewTab(markdownParser);
return markdownParser.render(markdownContent);
}
}
function configureLinksToOpenInNewTab(markdownParser: MarkdownIt): void {
// https://github.com/markdown-it/markdown-it/blob/14.0.0/docs/architecture.md#renderer
const defaultLinkRenderer = getDefaultRenderer(markdownParser, 'link_open');
markdownParser.renderer.rules.link_open = (tokens, index, options, env, self) => {
const currentToken = tokens[index];
Object.entries(AnchorAttributesForExternalLinks).forEach(([attribute, value]) => {
currentToken.attrSet(attribute, value);
});
return defaultLinkRenderer(tokens, index, options, env, self);
};
}
function getDefaultRenderer(md: MarkdownIt, ruleName: string): RenderRule {
const ruleRenderer = md.renderer.rules[ruleName];
const renderTokenAsDefault: RenderRule = (tokens, idx, options, _env, self) => {
return self.renderToken(tokens, idx, options);
};
return ruleRenderer || renderTokenAsDefault;
}
const AnchorAttributesForExternalLinks: Record<string, string> = {
target: '_blank',
rel: 'noopener noreferrer',
} as const;

View File

@@ -0,0 +1,127 @@
import type { MarkdownRenderer } from '../MarkdownRenderer';
export class PlainTextUrlsToHyperlinksConverter implements MarkdownRenderer {
public render(markdownContent: string): string {
return autoLinkPlainUrls(markdownContent);
}
}
const PlainTextUrlInMarkdownRegex = /(?<!\]\(|\[\d+\]:\s+|https?\S+|`)((?:https?):\/\/[^\s\])]*)(?:[\])](?!\()|$|\s)/gm;
function autoLinkPlainUrls(content: string): string {
if (!content) {
return content;
}
return content.replaceAll(PlainTextUrlInMarkdownRegex, (fullMatch, url) => {
return fullMatch.replace(url, formatReadableLink(url));
});
}
function formatReadableLink(url: string): string {
const urlParts = new URL(url);
let displayText = formatHostName(urlParts.hostname);
const pageLabel = extractPageLabel(urlParts);
if (pageLabel) {
displayText += ` - ${truncateTextFromEnd(capitalizeEachWord(pageLabel), 50)}`;
}
return buildMarkdownLink(displayText, url);
}
function formatHostName(hostname: string): string {
const withoutWww = hostname.replace(/^(www\.)/, '');
const truncatedHostName = truncateTextFromStart(withoutWww, 30);
return truncatedHostName;
}
function extractPageLabel(urlParts: URL): string | undefined {
const readablePath = makePathReadable(urlParts.pathname);
if (readablePath) {
return readablePath;
}
return formatQueryParameters(urlParts.searchParams);
}
function buildMarkdownLink(label: string, url: string): string {
return `[${label}](${url})`;
}
function formatQueryParameters(queryParameters: URLSearchParams): string | undefined {
const queryValues = [...queryParameters.values()];
if (queryValues.length === 0) {
return undefined;
}
return findMostDescriptiveName(queryValues);
}
function truncateTextFromStart(text: string, maxLength: number): string {
return text.length > maxLength ? `${text.substring(text.length - maxLength)}` : text;
}
function truncateTextFromEnd(text: string, maxLength: number): string {
return text.length > maxLength ? `${text.substring(0, maxLength)}` : text;
}
function isNumeric(value: string): boolean {
return /^\d+$/.test(value);
}
function makePathReadable(path: string): string | undefined {
const decodedPath = decodeURI(path); // Decode URI components, e.g., '%20' to space
const pathParts = decodedPath.split('/');
const decodedPathParts = pathParts // Split then decode to correctly handle '%2F' as '/'
.map((pathPart) => decodeURIComponent(pathPart));
const descriptivePart = findMostDescriptiveName(decodedPathParts);
if (!descriptivePart) {
return undefined;
}
const withoutExtension = removeFileExtension(descriptivePart);
const tokenizedText = tokenizeTextForReadability(withoutExtension);
return tokenizedText;
}
function tokenizeTextForReadability(text: string): string {
return text
.replaceAll(/[-_+]/g, ' ') // Replace hyphens, underscores, and plus signs with spaces
.replaceAll(/\s+/g, ' '); // Collapse multiple consecutive spaces into a single space
}
function removeFileExtension(value: string): string {
const parts = value.split('.');
if (parts.length === 1) {
return value;
}
const extension = parts[parts.length - 1];
if (extension.length > 9) {
return value; // Heuristically web file extension is no longer than 9 chars (e.g. "html")
}
return parts.slice(0, -1).join('.');
}
function capitalizeEachWord(text: string): string {
return text
.split(' ')
.map((word) => capitalizeFirstLetter(word))
.join(' ');
}
function capitalizeFirstLetter(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}
function findMostDescriptiveName(segments: readonly string[]): string | undefined {
const meaningfulSegments = segments.filter(isMeaningfulPathSegment);
if (meaningfulSegments.length === 0) {
return undefined;
}
const longestGoodSegment = meaningfulSegments.reduce((a, b) => (a.length > b.length ? a : b));
return longestGoodSegment;
}
function isMeaningfulPathSegment(segment: string): boolean {
return segment.length > 2 // This is often non-human categories like T5 etc.
&& !isNumeric(segment) // E.g. article numbers, issue numbers
&& !/^index(?:\.\S{0,10}$|$)/.test(segment) // E.g. index.html
&& !/^[A-Za-z]{2,4}([_-][A-Za-z]{4})?([_-]([A-Za-z]{2}|[0-9]{3}))$/.test(segment) // 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(segment) // GUID
&& !/^[0-9a-f]{40}$/.test(segment); // Git SHA (e.g. GitHub links)
}

View File

@@ -55,8 +55,8 @@
@include left-padding('ul, ol', $large-horizontal-spacing); @include left-padding('ul, ol', $large-horizontal-spacing);
} }
@mixin markdown-text-styles($text-size) { @mixin markdown-text-styles {
$base-spacing: $text-size; $base-spacing: 1em;
a { a {
&[href] { &[href] {
@@ -73,11 +73,19 @@
content: ''; content: '';
display: inline-block; display: inline-block;
width: $text-size;
height: $text-size; /*
Use absolute sizing instead of relative. Relative sizing looks bad and inconsistent if there are external elements
inside small text (such as inside `<sup>`) and bigger elements like in bigger text. Making them always have same size
make the text read and flow better.
*/
width: $font-size-absolute-x-small;
height: $font-size-absolute-x-small;
vertical-align: text-top;
background-color: $text-color; background-color: $text-color;
margin-left: math.div($text-size, 4); margin-left: math.div(1em, 4);
} }
/* /*
Match color of global hover behavior. We need to do it manually because global hover sets Match color of global hover behavior. We need to do it manually because global hover sets
@@ -113,4 +121,11 @@
$code-block-padding: $base-spacing, $code-block-padding: $base-spacing,
$color-background: $color-primary-darker, $color-background: $color-primary-darker,
); );
sup {
@include reset-sup;
vertical-align: super;
font-size: $font-size-relative-smallest;
}
} }

View File

@@ -24,6 +24,6 @@ export default defineComponent({
.node-title { .node-title {
font-family: $font-main; font-family: $font-main;
font-size: $font-size-large; font-size: $font-size-absolute-large;
} }
</style> </style>

View File

@@ -75,7 +75,7 @@ export default defineComponent({
@use 'sass:math'; @use 'sass:math';
@use "@/presentation/assets/styles/main" as *; @use "@/presentation/assets/styles/main" as *;
$font-size : $font-size-small; $font-size : $font-size-absolute-small;
$color-toggle-unchecked : $color-primary-darker; $color-toggle-unchecked : $color-primary-darker;
$color-toggle-checked : $color-on-secondary; $color-toggle-checked : $color-on-secondary;

View File

@@ -53,7 +53,7 @@ export default defineComponent({
@use "@/presentation/assets/styles/main" as *; @use "@/presentation/assets/styles/main" as *;
@use "./../tree-colors" as *; @use "./../tree-colors" as *;
$side-size-in-px: $font-size-larger; $side-size-in-px: $font-size-absolute-x-large;
.checkbox { .checkbox {
position: relative; position: relative;

View File

@@ -74,7 +74,7 @@ export default defineComponent({
.dialog__close-button { .dialog__close-button {
color: $color-primary-dark; color: $color-primary-dark;
width: auto; width: auto;
font-size: $font-size-large; font-size: $font-size-absolute-large;
margin-right: 0.25em; margin-right: 0.25em;
align-self: flex-start; align-self: flex-start;
} }

View File

@@ -225,7 +225,7 @@ $color-tooltip-background: $color-primary-darkest;
color: $color-on-primary; color: $color-on-primary;
border-radius: 16px; border-radius: 16px;
padding: 5px 10px 4px; padding: 5px 10px 4px;
font-size: $font-size-normal; font-size: $font-size-absolute-normal;
/* /*
This margin creates a visual buffer between the tooltip and the edges of the document. This margin creates a visual buffer between the tooltip and the edges of the document.

View File

@@ -78,7 +78,7 @@ export default defineComponent({
&__url { &__url {
&:not(:first-child)::before { &:not(:first-child)::before {
content: "|"; content: "|";
font-size: $font-size-smaller; font-size: $font-size-absolute-x-small;
padding: 0 5px; padding: 0 5px;
} }
} }

View File

@@ -65,7 +65,7 @@ function hasDesktopVersion(os: OperatingSystem): boolean {
@use "@/presentation/assets/styles/main" as *; @use "@/presentation/assets/styles/main" as *;
.url { .url {
.inactive { .inactive {
font-size: $font-size-smaller; font-size: $font-size-absolute-x-small;
} }
} }
</style> </style>

View File

@@ -46,11 +46,11 @@ export default defineComponent({
margin: 0; margin: 0;
text-transform: uppercase; text-transform: uppercase;
font-family: $font-main; font-family: $font-main;
font-size: $font-size-largest; font-size: $font-size-absolute-xx-large;
} }
.subtitle { .subtitle {
margin: 0; margin: 0;
font-size: $font-size-larger; font-size: $font-size-absolute-x-large;
color: $color-primary; color: $color-primary;
font-family: $font-artistic; font-family: $font-artistic;
font-weight: 500; font-weight: 500;

View File

@@ -113,7 +113,7 @@ export default defineComponent({
outline: none; outline: none;
color: $color-primary; color: $color-primary;
font-family: $font-normal; font-family: $font-normal;
font-size: $font-size-normal; font-size: $font-size-absolute-normal;
&:focus { &:focus {
color: $color-primary-darker; color: $color-primary-darker;
} }
@@ -127,7 +127,7 @@ export default defineComponent({
text-align: center; text-align: center;
color: $color-on-primary; color: $color-on-primary;
border-radius: 0 5px 5px 0; border-radius: 0 5px 5px 0;
font-size: $font-size-large; font-size: $font-size-absolute-large;
padding:5px; padding:5px;
} }
</style> </style>

View File

@@ -0,0 +1,244 @@
import { describe, it, expect } from 'vitest';
import { parseApplication } from '@/application/Parser/ApplicationParser';
import { CompositeMarkdownRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { parseHtml } from '@tests/shared/HtmlParser';
describe('CompositeMarkdownRenderer', () => {
describe('can render all docs', () => {
// arrange
const renderer = new CompositeMarkdownRenderer();
for (const node of collectAllDocumentedExecutables()) {
it(`${node.executableLabel}`, () => {
// act
const html = renderer.render(node.docs);
const result = analyzeHtmlContentForCorrectFormatting(html);
// assert
expect(result.isCorrectlyFormatted).to.equal(true, formatAssertionMessage([
'HTML validation failed',
`Executable Label: ${node.executableLabel}`,
`Generated HTML: ${result.generatedHtml}`,
]));
});
}
});
it('should convert plain URLs to hyperlinks and apply markdown formatting', () => {
// arrange
const renderer = new CompositeMarkdownRenderer();
const expectedPlainUrl = 'https://privacy.sexy';
const expectedLabel = 'privacy.sexy';
const markdownContent = `Visit ${expectedPlainUrl} for privacy scripts.`;
// act
const renderedOutput = renderer.render(markdownContent);
// assert
const links = extractHyperlinksFromHtmlContent(renderedOutput);
assertExpectedNumberOfHyperlinksInContent({
links, expectedLength: 1, markdownContent, renderedOutput,
});
assertHyperlinkWithExpectedLabelUrlAndAttributes({
link: links[0], expectedHref: expectedPlainUrl, expectedLabel,
});
});
it('should correctly handle inline reference labels converting them to superscript', () => {
// arrange
const renderer = new CompositeMarkdownRenderer();
const expectedInlineReferenceUrlHref = 'https://undergroundwires.dev';
const expectedInlineReferenceUrlLabel = '1';
const markdownContent = [
`See reference [${expectedInlineReferenceUrlLabel}].`,
'\n',
`[${expectedInlineReferenceUrlLabel}]: ${expectedInlineReferenceUrlHref} "Example"`,
].join('\n');
// act
const renderedOutput = renderer.render(markdownContent);
// assert
assertSuperscriptReference({
renderedOutput,
markdownContent,
expectedHref: expectedInlineReferenceUrlHref,
expectedLabel: expectedInlineReferenceUrlLabel,
});
});
it('should process mixed content, converting URLs and references within complex Markdown', () => {
// arrange
const renderer = new CompositeMarkdownRenderer();
const expectedInlineReferenceUrlHref = 'https://undergroundwires.dev';
const expectedInlineReferenceUrlLabel = 'Example Reference';
const expectedPlainUrlHref = 'https://privacy.sexy';
const expectedPlainUrlLabel = 'privacy.sexy';
const markdownContent = [
`This is a test of [inline references][${expectedInlineReferenceUrlLabel}] and plain URLs ${expectedPlainUrlHref}`,
'\n',
`[${expectedInlineReferenceUrlLabel}]: ${expectedInlineReferenceUrlHref} "Example"`,
].join('\n');
// act
const renderedOutput = renderer.render(markdownContent);
// assert
const links = extractHyperlinksFromHtmlContent(renderedOutput);
assertExpectedNumberOfHyperlinksInContent({
links, expectedLength: 2, markdownContent, renderedOutput,
});
assertHyperlinkWithExpectedLabelUrlAndAttributes({
link: links[0],
expectedHref: expectedInlineReferenceUrlHref,
expectedLabel: expectedInlineReferenceUrlLabel,
});
assertHyperlinkWithExpectedLabelUrlAndAttributes({
link: links[1],
expectedHref: expectedPlainUrlHref,
expectedLabel: expectedPlainUrlLabel,
});
assertSuperscriptReference({
renderedOutput,
markdownContent,
expectedHref: expectedInlineReferenceUrlHref,
expectedLabel: expectedInlineReferenceUrlLabel,
});
});
it('ensures no <br> tags are inserted for single line breaks', () => {
// arrange
const renderer = new CompositeMarkdownRenderer();
const markdownContent = 'Line 1\nLine 2';
// act
const renderedOutput = renderer.render(markdownContent);
// assert
expect(renderedOutput).not.to.include('<br>', formatAssertionMessage([
'Expected no <br> tags for single line breaks',
`Rendered content: ${renderedOutput}`,
]));
});
it('applies default anchor attributes for all links including dynamically converted ones', () => {
// arrange
const renderer = new CompositeMarkdownRenderer();
const markdownContent = '[Example](https://example.com) and https://privacy.sexy.';
// act
const renderedOutput = renderer.render(markdownContent);
// assert
const links = extractHyperlinksFromHtmlContent(renderedOutput);
assertExpectedNumberOfHyperlinksInContent({
links, expectedLength: 2, markdownContent, renderedOutput,
});
Array.from(links).forEach((link) => {
assertHyperlinkOpensInNewTabWithSecureRelAttributes({ link });
});
});
});
function assertExpectedNumberOfHyperlinksInContent(context: {
readonly links: HTMLAnchorElement[];
readonly expectedLength: number;
readonly markdownContent: string;
readonly renderedOutput: string;
}): void {
expect(context.links.length).to.equal(context.expectedLength, formatAssertionMessage([
`Expected exactly "${context.expectedLength}" hyperlinks in the rendered output`,
`Found ${context.links.length} hyperlinks instead.`,
`Markdown content: ${context.markdownContent}`,
`Rendered output: ${context.renderedOutput}`,
]));
}
function assertHyperlinkWithExpectedLabelUrlAndAttributes(context: {
readonly link: HTMLAnchorElement;
readonly expectedHref: string;
readonly expectedLabel: string;
}): void {
expect(context.link.href).to.include(context.expectedHref, formatAssertionMessage([
'The hyperlink href does not match the expected URL',
`Expected URL: ${context.expectedHref}`,
`Actual URL: ${context.link.href}`,
]));
expect(context.link.textContent).to.equal(context.expectedLabel, formatAssertionMessage([
`Expected text content of the hyperlink to be ${context.expectedLabel}`,
`Actual text content: ${context.link.textContent}`,
]));
assertHyperlinkOpensInNewTabWithSecureRelAttributes({ link: context.link });
}
function assertHyperlinkOpensInNewTabWithSecureRelAttributes(context: {
readonly link: HTMLAnchorElement;
}): void {
expect(context.link.target).to.equal('_blank', formatAssertionMessage([
'Expected the hyperlink to open in new tabs (target="_blank")',
`Actual target attribute of a link: ${context.link.target}`,
]));
expect(context.link.rel).to.include('noopener noreferrer', formatAssertionMessage([
'Expected the hyperlink to have rel="noopener noreferrer" for security',
`Actual rel attribute of a link: ${context.link.rel}`,
]));
}
function assertSuperscriptReference(context: {
readonly renderedOutput: string;
readonly markdownContent: string;
readonly expectedHref: string;
readonly expectedLabel: string;
}): void {
const html = parseHtml(context.renderedOutput);
const superscript = html.getElementsByTagName('sup')[0];
expectExists(superscript, formatAssertionMessage([
'Expected at least single superscript.',
`Rendered content does not contain any superscript: ${context.renderedOutput}`,
`Markdown content: ${context.markdownContent}`,
]));
const links = extractHyperlinksFromHtmlContent(superscript.innerHTML);
assertExpectedNumberOfHyperlinksInContent({
links,
expectedLength: 1,
markdownContent: context.markdownContent,
renderedOutput: context.renderedOutput,
});
assertHyperlinkWithExpectedLabelUrlAndAttributes({
link: links[0],
expectedHref: context.expectedHref,
expectedLabel: context.expectedLabel,
});
}
function extractHyperlinksFromHtmlContent(htmlText: string): HTMLAnchorElement[] {
const html = parseHtml(htmlText);
const links = html.getElementsByTagName('a');
return Array.from(links);
}
interface DocumentedExecutable {
readonly executableLabel: string
readonly docs: string
}
function collectAllDocumentedExecutables(): DocumentedExecutable[] {
const app = parseApplication();
const allExecutables = app.collections.flatMap((collection) => [
...collection.getAllScripts(),
...collection.getAllCategories(),
]);
const allDocumentedExecutables = allExecutables.filter((e) => e.docs.length > 0);
return allDocumentedExecutables.map((executable): DocumentedExecutable => ({
executableLabel: `${executable.name} (${executable.id})`,
docs: executable.docs.join('\n'),
}));
}
interface HTMLValidationResult {
readonly isCorrectlyFormatted: boolean;
readonly generatedHtml: string;
}
function analyzeHtmlContentForCorrectFormatting(value: string): HTMLValidationResult {
const doc = parseHtml(value);
return {
isCorrectlyFormatted: Array.from(doc.body.childNodes).some((node) => node.nodeType === 1),
generatedHtml: doc.body.innerHTML,
};
}

View File

@@ -1,55 +0,0 @@
import { describe, it, expect } from 'vitest';
import { parseApplication } from '@/application/Parser/ApplicationParser';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { createMarkdownRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer';
describe('MarkdownRenderer', () => {
describe('can render all docs', () => {
// arrange
const renderer = createMarkdownRenderer();
for (const node of collectAllDocumentableNodes()) {
it(`${node.nodeLabel}`, () => {
// act
const html = renderer.render(node.docs);
const result = validateHtml(html);
// assert
expect(result.isValid, result.generatedHtml);
});
}
});
});
interface IDocumentableNode {
readonly nodeLabel: string
readonly 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;
}
}
}
interface IHTMLValidationResult {
readonly isValid: boolean;
readonly generatedHtml: string;
}
function validateHtml(value: string): IHTMLValidationResult {
const doc = new window.DOMParser()
.parseFromString(value, 'text/html');
return {
isValid: Array.from(doc.body.childNodes).some((node) => node.nodeType === 1),
generatedHtml: doc.body.innerHTML,
};
}

View File

@@ -0,0 +1,5 @@
export function parseHtml(htmlString: string): Document {
const parser = new window.DOMParser();
const htmlDoc = parser.parseFromString(htmlString, 'text/html');
return htmlDoc;
}

View File

@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest';
import { MarkdownRendererStub } from '@tests/unit/shared/Stubs/MarkdownRendererStub';
import type { MarkdownRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer';
import { CompositeMarkdownRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer';
describe('CompositeMarkdownRenderer', () => {
describe('constructor', () => {
it('throws error without renderers', () => {
// arrange
const expectedError = 'missing renderers';
const renderers = [];
const context = new MarkdownRendererTestBuilder()
.withMarkdownRenderers(renderers);
// act
const act = () => context.render();
// assert
expect(act).to.throw(expectedError);
});
});
describe('render', () => {
describe('applies modifications', () => {
describe('with single renderer', () => {
it('calls the renderer', () => {
// arrange
const expectedInput = 'initial content';
const renderer = new MarkdownRendererStub();
const context = new MarkdownRendererTestBuilder()
.withMarkdownInput(expectedInput)
.withMarkdownRenderers([renderer]);
// act
context.render();
// assert
renderer.assertRenderWasCalledOnceWith(expectedInput);
});
it('matches single renderer output', () => {
// arrange
const expectedOutput = 'expected output';
const renderer = new MarkdownRendererStub()
.withRenderOutput(expectedOutput);
const context = new MarkdownRendererTestBuilder()
.withMarkdownRenderers([renderer]);
// act
const actualOutput = context.render();
// assert
expect(actualOutput).to.equal(expectedOutput);
});
});
describe('with multiple renderers', () => {
it('calls all renderers in the provided order', () => {
// arrange
const initialInput = 'initial content';
const firstRendererOutput = 'initial content';
const firstRenderer = new MarkdownRendererStub()
.withRenderOutput(firstRendererOutput);
const secondRenderer = new MarkdownRendererStub();
const context = new MarkdownRendererTestBuilder()
.withMarkdownInput(initialInput)
.withMarkdownRenderers([firstRenderer, secondRenderer]);
// act
context.render();
// assert
firstRenderer.assertRenderWasCalledOnceWith(initialInput);
secondRenderer.assertRenderWasCalledOnceWith(firstRendererOutput);
});
it('matches final output from sequence', () => {
// arrange
const expectedOutput = 'final content';
const firstRenderer = new MarkdownRendererStub();
const secondRenderer = new MarkdownRendererStub()
.withRenderOutput(expectedOutput);
const context = new MarkdownRendererTestBuilder()
.withMarkdownRenderers([firstRenderer, secondRenderer]);
// act
const actualOutput = context.render();
// assert
expect(actualOutput).to.equal(expectedOutput);
});
});
});
});
});
class MarkdownRendererTestBuilder {
private markdownInput = `[${MarkdownRendererTestBuilder.name}] Markdown text`;
private markdownRenderers: readonly MarkdownRenderer[] = [
new MarkdownRendererStub(),
];
public withMarkdownInput(markdownInput: string): this {
this.markdownInput = markdownInput;
return this;
}
public withMarkdownRenderers(markdownRenderers: readonly MarkdownRenderer[]): this {
this.markdownRenderers = markdownRenderers;
return this;
}
public render(): ReturnType<MarkdownRenderer['render']> {
const renderer = new CompositeMarkdownRenderer(this.markdownRenderers);
return renderer.render(this.markdownInput);
}
}

View File

@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest';
import { InlineReferenceLabelsToSuperscriptConverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/InlineReferenceLabelsToSuperscriptConverter';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { renderMarkdownUsingRenderer } from './MarkdownRenderingTester';
describe('InlineReferenceLabelsToSuperscriptConverter', () => {
describe('modify', () => {
describe('retains original content where no conversion is required', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly markdownContent: string;
}> = [
{
description: 'text without references',
markdownContent: 'No references here to convert.',
},
{
description: 'numeric references outside brackets',
markdownContent: [
'This is a test 1.',
'Please refer to note 2.',
'1: Reference I',
'1: Reference II',
].join('\n'),
},
{
description: 'references without definitions',
markdownContent: [
'This is a test [1].',
'Please refer to note [2].',
].join('\n'),
},
];
testScenarios.forEach(({ description, markdownContent }) => {
it(description, () => {
// arrange
const expectedOutput = markdownContent; // No change expected
// act
const convertedContent = renderMarkdownUsingRenderer(
InlineReferenceLabelsToSuperscriptConverter,
markdownContent,
);
// assert
expect(convertedContent).to.equal(expectedOutput);
});
});
});
describe('converts references in square brackets to superscript', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly markdownContent: string;
readonly expectedOutput: string;
}> = [
{
description: 'converts a single numeric reference',
markdownContent: [
'See reference [1].',
createMarkdownLinkReferenceDefinition('1'),
].join('\n'),
expectedOutput: [
'See reference <sup>[1]</sup>.',
createMarkdownLinkReferenceDefinition('1'),
].join('\n'),
},
{
description: 'converts a single non-numeric reference',
markdownContent: [
'For more information, check [Reference A].',
createMarkdownLinkReferenceDefinition('Reference A'),
].join('\n'),
expectedOutput: [
'For more information, check <sup>[Reference A]</sup>.',
createMarkdownLinkReferenceDefinition('Reference A'),
].join('\n'),
},
{
description: 'converts multiple numeric references on the same line',
markdownContent: [
'Refer to [1], [2], and [3] for more details.',
createMarkdownLinkReferenceDefinition('1'), createMarkdownLinkReferenceDefinition('2'), createMarkdownLinkReferenceDefinition('3'),
].join('\n'),
expectedOutput: [
'Refer to <sup>[1]</sup>, <sup>[2]</sup>, and <sup>[3]</sup> for more details.',
createMarkdownLinkReferenceDefinition('1'), createMarkdownLinkReferenceDefinition('2'), createMarkdownLinkReferenceDefinition('3'),
].join('\n'),
},
{
description: 'converts multiple numeric references on different lines',
markdownContent: [
'Details can be found in [5].', 'Additional data in [6].',
createMarkdownLinkReferenceDefinition('5'), createMarkdownLinkReferenceDefinition('6'),
].join('\n'),
expectedOutput: [
'Details can be found in <sup>[5]</sup>.', 'Additional data in <sup>[6]</sup>.',
createMarkdownLinkReferenceDefinition('5'), createMarkdownLinkReferenceDefinition('6'),
].join('\n'),
},
{
description: 'handles adjacent references without spaces',
markdownContent: [
'start[first][2][3]end',
createMarkdownLinkReferenceDefinition('first'), createMarkdownLinkReferenceDefinition('2'), createMarkdownLinkReferenceDefinition('3'),
].join('\n'),
expectedOutput: [
'start<sup>[first]</sup><sup>[2]</sup><sup>[3]</sup>end',
createMarkdownLinkReferenceDefinition('first'), createMarkdownLinkReferenceDefinition('2'), createMarkdownLinkReferenceDefinition('3'),
].join('\n'),
},
{
description: 'handles references with special characters',
markdownContent: [
'[reference-name!]',
createMarkdownLinkReferenceDefinition('reference-name!'),
].join('\n'),
expectedOutput: [
'<sup>[reference-name!]</sup>',
createMarkdownLinkReferenceDefinition('reference-name!'),
].join('\n'),
},
{
description: 'handles colon after reference without mistaking for definition',
markdownContent: [
'It said [1]: "No I\'m not AI!"',
createMarkdownLinkReferenceDefinition('1'),
].join('\n'),
expectedOutput: [
'It said <sup>[1]</sup>: "No I\'m not AI!"',
createMarkdownLinkReferenceDefinition('1'),
].join('\n'),
},
];
testScenarios.forEach(({ description, markdownContent, expectedOutput }) => {
it(description, () => {
// act
const convertedContent = renderMarkdownUsingRenderer(
InlineReferenceLabelsToSuperscriptConverter,
markdownContent,
);
// assert
expect(convertedContent).to.equal(expectedOutput, formatAssertionMessage([
`Expected output: ${expectedOutput}`,
`Actual output: ${expectedOutput}`,
]));
});
});
});
});
});
function createMarkdownLinkReferenceDefinition(label: string): string {
return `[${label}]: https://test.url`;
}

View File

@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest';
import { MarkdownItHtmlRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/MarkdownItHtmlRenderer';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { parseHtml } from '@tests/shared/HtmlParser';
import { renderMarkdownUsingRenderer } from './MarkdownRenderingTester';
describe('MarkdownItHtmlRenderer', () => {
describe('modify', () => {
describe('sets default anchor attributes', () => {
const testScenarios: ReadonlyArray<{
readonly attributeName: string;
readonly expectedValue: string;
readonly markdownWithNonCompliantAnchorAttributes: string;
}> = [
{
attributeName: 'target',
expectedValue: '_blank',
markdownWithNonCompliantAnchorAttributes: '[URL](https://undergroundwires.dev){ target="_self" }',
},
{
attributeName: 'rel',
expectedValue: 'noopener noreferrer',
markdownWithNonCompliantAnchorAttributes: '[URL](https://undergroundwires.dev){ rel="nooverride" }',
},
];
testScenarios.forEach(({
attributeName, expectedValue, markdownWithNonCompliantAnchorAttributes,
}) => {
it(`adds "${attributeName}" attribute to anchors`, () => {
// arrange
const markdown = '[undergroundwires.dev](https://undergroundwires.dev)';
// act
const renderedOutput = renderMarkdownUsingRenderer(MarkdownItHtmlRenderer, markdown);
// assert
assertAnchorElementAttribute({
renderedOutput,
attributeName,
expectedValue,
markdownContent: markdown,
});
});
it(`overrides existing "${attributeName}" attribute`, () => {
// arrange & act
const renderedOutput = renderMarkdownUsingRenderer(
MarkdownItHtmlRenderer,
markdownWithNonCompliantAnchorAttributes,
);
// assert
assertAnchorElementAttribute({
renderedOutput,
attributeName,
expectedValue,
markdownContent: markdownWithNonCompliantAnchorAttributes,
});
});
});
});
it('does not convert single line breaks to <br> elements', () => {
// arrange
const markdown = 'Text with\nSingle\nLinebreaks';
// act
const renderedOutput = renderMarkdownUsingRenderer(MarkdownItHtmlRenderer, markdown);
// assert
const html = parseHtml(renderedOutput);
const totalBrElements = html.getElementsByTagName('br').length;
expect(totalBrElements).to.equal(0);
});
});
});
function assertAnchorElementAttribute(context: {
readonly renderedOutput: string;
readonly attributeName: string;
readonly expectedValue: string;
readonly markdownContent: string;
}) {
const html = parseHtml(context.renderedOutput);
const aElement = html.getElementsByTagName('a')[0];
expectExists(aElement, formatAssertionMessage([
'Missing expected `<a>` element',
`Markdown input to render: ${context.markdownContent}`,
`Actual render output: ${context.renderedOutput}`,
]));
const actualValue = aElement.getAttribute(context.attributeName);
expect(context.expectedValue).to.equal(actualValue, formatAssertionMessage([
`Expected attribute value: ${context.expectedValue}`,
`Actual attribute value: ${actualValue}`,
`Attribute name: ${context.attributeName}`,
`Markdown input to render: ${context.markdownContent}`,
`Actual render output:\n${context.renderedOutput}`,
`Actual \`<a>\` element HTML: ${aElement.outerHTML}`,
]));
}

View File

@@ -0,0 +1,11 @@
import type { MarkdownRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer';
type RenderFunction = MarkdownRenderer['render'];
export function renderMarkdownUsingRenderer(
MarkdownRendererClass: { new(): MarkdownRenderer ; },
...renderArgs: Parameters<RenderFunction>
): ReturnType<RenderFunction> {
const rendererInstance = new MarkdownRendererClass();
return rendererInstance.render(...renderArgs);
}

View File

@@ -1,87 +1,47 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createMarkdownRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer'; import { PlainTextUrlsToHyperlinksConverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/PlainTextUrlsToHyperlinksConverter';
import { renderMarkdownUsingRenderer } from './MarkdownRenderingTester';
describe('MarkdownRenderer', () => { describe('PlainTextUrlsToHyperlinksConverter', () => {
describe('createMarkdownRenderer', () => { describe('modify', () => {
it('creates renderer instance', () => { describe('retains original content where no conversion is required', () => {
// arrange & act const testScenarios: ReadonlyArray<{
const renderer = createMarkdownRenderer(); readonly description: string;
// assert readonly markdownContent: string;
expect(renderer !== undefined);
});
describe('sets default anchor attributes', () => {
const attributes: ReadonlyArray<{
readonly attributeName: string,
readonly expectedValue: string,
readonly invalidMarkdown: string
}> = [ }> = [
{ {
attributeName: 'target', description: 'URLs within markdown link syntax',
expectedValue: '_blank', markdownContent: 'URL: [privacy.sexy](https://privacy.sexy).',
invalidMarkdown: '<a href="https://undergroundwires.dev" target="_self">example</a>',
}, },
{ {
attributeName: 'rel', description: 'URLs within inline code blocks',
expectedValue: 'noopener noreferrer', markdownContent: 'URL as code: `https://privacy.sexy`.',
invalidMarkdown: '<a href="https://undergroundwires.dev" rel="nooverride">example</a>', },
{
description: 'reference-style links',
markdownContent: [
'This content has reference-style link [1].',
'[1]: https://privacy.sexy',
].join('\n'),
}, },
]; ];
for (const attribute of attributes) { testScenarios.forEach(({ description, markdownContent }) => {
const { attributeName, expectedValue, invalidMarkdown } = attribute; it(description, () => {
it(`adds "${attributeName}" attribute to anchors`, () => {
// arrange // arrange
const renderer = createMarkdownRenderer(); const expectedOutput = markdownContent; // No change expected
const markdown = '[undergroundwires.dev](https://undergroundwires.dev)';
// act // act
const htmlString = renderer.render(markdown); const convertedContent = renderMarkdownUsingRenderer(
PlainTextUrlsToHyperlinksConverter,
markdownContent,
);
// assert // assert
const html = parseHtml(htmlString); expect(convertedContent).to.equal(expectedOutput);
const aElement = html.getElementsByTagName('a')[0];
expect(aElement.getAttribute(attributeName)).to.equal(expectedValue);
}); });
});
it(`overrides existing "${attributeName}" attribute`, () => {
// arrange
const renderer = createMarkdownRenderer();
// act
const htmlString = renderer.render(invalidMarkdown);
// assert
const html = parseHtml(htmlString);
const aElement = html.getElementsByTagName('a')[0];
expect(aElement.getAttribute(attributeName)).to.equal(expectedValue);
});
}
}); });
it('does not convert single line breaks to <br> elements', () => { describe('converts plain URLs into hyperlinks', () => {
// arrange
const renderer = createMarkdownRenderer();
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('converts plain URLs into hyperlinks', () => {
// arrange
const renderer = createMarkdownRenderer();
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);
});
describe('generates readable labels for automatically linked URLs', () => {
const testScenarios: ReadonlyArray<{ const testScenarios: ReadonlyArray<{
readonly description: string; readonly description: string;
readonly urlText: string; readonly urlText: string;
@@ -158,22 +118,17 @@ describe('MarkdownRenderer', () => {
}) => { }) => {
it(description, () => { it(description, () => {
// arrange // arrange
const renderer = createMarkdownRenderer();
const markdown = `Visit ${urlText} now!`; const markdown = `Visit ${urlText} now!`;
const expectedOutput = `Visit [${expectedLabel}](${urlText}) now!`;
// act // act
const htmlString = renderer.render(markdown); const actualOutput = renderMarkdownUsingRenderer(
PlainTextUrlsToHyperlinksConverter,
markdown,
);
// assert // assert
const html = parseHtml(htmlString); expect(actualOutput).to.equal(expectedOutput);
const aElement = html.getElementsByTagName('a')[0];
expect(aElement.text).to.equal(expectedLabel);
}); });
}); });
}); });
}); });
}); });
function parseHtml(htmlString: string): Document {
const parser = new window.DOMParser();
const htmlDoc = parser.parseFromString(htmlString, 'text/html');
return htmlDoc;
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createIpcConsumerProxy, registerIpcChannel } from '@/presentation/electron/shared/IpcBridging/IpcProxy'; import { createIpcConsumerProxy, registerIpcChannel } from '@/presentation/electron/shared/IpcBridging/IpcProxy';
import { IpcChannel } from '@/presentation/electron/shared/IpcBridging/IpcChannel'; import type { IpcChannel } from '@/presentation/electron/shared/IpcBridging/IpcChannel';
describe('IpcProxy', () => { describe('IpcProxy', () => {
describe('createIpcConsumerProxy', () => { describe('createIpcConsumerProxy', () => {

View File

@@ -0,0 +1,31 @@
import type { MarkdownRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class MarkdownRendererStub
extends StubWithObservableMethodCalls<MarkdownRenderer>
implements MarkdownRenderer {
private renderOutput = `[${MarkdownRendererStub.name}]render output`;
public render(markdownContent: string): string {
this.registerMethodCall({
methodName: 'render',
args: [markdownContent],
});
return this.renderOutput;
}
public withRenderOutput(renderOutput: string): this {
this.renderOutput = renderOutput;
return this;
}
public assertRenderWasCalledOnceWith(expectedInput: string): void {
const calls = this.callHistory.filter((c) => c.methodName === 'render');
expect(calls).to.have.lengthOf(1);
const [call] = calls;
expectExists(call);
const [actualInput] = call.args;
expect(actualInput).to.equal(expectedInput);
}
}