From b9c89b701fc77d20dcc706419a8659ad156c4fc2 Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Fri, 9 Feb 2024 16:25:05 +0100 Subject: [PATCH] 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 `` 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 `` 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. --- package-lock.json | 41 ++- package.json | 1 + src/presentation/assets/styles/_fonts.scss | 9 - src/presentation/assets/styles/_globals.scss | 3 +- src/presentation/assets/styles/_mixins.scss | 8 +- .../assets/styles/_typography.scss | 19 ++ src/presentation/assets/styles/main.scss | 1 + .../Code/CodeButtons/IconButton.vue | 4 +- .../RunInstructions/Steps/CopyableCommand.vue | 4 +- .../components/Code/TheCodeArea.vue | 2 +- .../Scripts/View/Cards/CardList.vue | 2 +- .../Scripts/View/Cards/CardListItem.vue | 6 +- .../View/Cards/CardSelectionIndicator.vue | 2 +- .../Scripts/View/TheScriptsView.vue | 4 +- .../Documentation/DocumentationText.vue | 2 +- .../ToggleDocumentationButton.vue | 2 +- .../Markdown/CompositeMarkdownRenderer.ts | 28 ++ .../NodeContent/Markdown/MarkdownRenderer.ts | 195 +------------- .../NodeContent/Markdown/MarkdownText.vue | 10 +- ...neReferenceLabelsToSuperscriptConverter.ts | 36 +++ .../Renderers/MarkdownItHtmlRenderer.ts | 40 +++ .../PlainTextUrlsToHyperlinksConverter.ts | 127 +++++++++ .../NodeContent/Markdown/markdown-styles.scss | 25 +- .../View/Tree/NodeContent/NodeTitle.vue | 2 +- .../View/Tree/NodeContent/ToggleSwitch.vue | 2 +- .../View/Tree/TreeView/Node/NodeCheckbox.vue | 2 +- .../components/Shared/Modal/ModalDialog.vue | 2 +- .../components/Shared/TooltipWrapper.vue | 2 +- .../components/TheFooter/DownloadUrlList.vue | 2 +- .../TheFooter/DownloadUrlListItem.vue | 2 +- src/presentation/components/TheHeader.vue | 4 +- src/presentation/components/TheSearchBar.vue | 4 +- .../CompositeMarkdownRenderer.spec.ts | 244 ++++++++++++++++++ .../Markdown/MarkdownRenderer.spec.ts | 55 ---- tests/shared/HtmlParser.ts | 5 + .../CompositeMarkdownRenderer.spec.ts | 104 ++++++++ ...erenceLabelsToSuperscriptConverter.spec.ts | 156 +++++++++++ .../Modifiers/MarkdownItHtmlRenderer.spec.ts | 98 +++++++ .../Modifiers/MarkdownRenderingTester.ts | 11 + ...lainTextUrlsToHyperlinksConverter.spec.ts} | 115 +++------ .../electron/shared/IpcProxy.spec.ts | 2 +- .../unit/shared/Stubs/MarkdownRendererStub.ts | 31 +++ 42 files changed, 1036 insertions(+), 378 deletions(-) create mode 100644 src/presentation/assets/styles/_typography.scss create mode 100644 src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer.ts create mode 100644 src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/InlineReferenceLabelsToSuperscriptConverter.ts create mode 100644 src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/MarkdownItHtmlRenderer.ts create mode 100644 src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/PlainTextUrlsToHyperlinksConverter.ts create mode 100644 tests/integration/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer.spec.ts delete mode 100644 tests/integration/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer.spec.ts create mode 100644 tests/shared/HtmlParser.ts create mode 100644 tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer.spec.ts create mode 100644 tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/InlineReferenceLabelsToSuperscriptConverter.spec.ts create mode 100644 tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/MarkdownItHtmlRenderer.spec.ts create mode 100644 tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/MarkdownRenderingTester.ts rename tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/{MarkdownRenderer.spec.ts => Modifiers/PlainTextUrlsToHyperlinksConverter.spec.ts} (50%) create mode 100644 tests/unit/shared/Stubs/MarkdownRendererStub.ts diff --git a/package-lock.json b/package-lock.json index ece54659..8a3ce9b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,11 +6,12 @@ "packages": { "": { "name": "privacy.sexy", - "version": "0.12.9", + "version": "0.12.10", "hasInstallScript": true, "dependencies": { "@floating-ui/vue": "^1.0.2", "@juggle/resize-observer": "^3.4.0", + "@types/markdown-it": "^13.0.7", "ace-builds": "^1.30.0", "electron-log": "^5.0.1", "electron-progressbar": "^2.1.0", @@ -3395,6 +3396,20 @@ "@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": { "version": "3.0.12", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.12.tgz", @@ -3404,6 +3419,11 @@ "@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": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -21964,6 +21984,20 @@ "@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": { "version": "3.0.12", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.12.tgz", @@ -21973,6 +22007,11 @@ "@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": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", diff --git a/package.json b/package.json index f4b067fd..582f6191 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dependencies": { "@floating-ui/vue": "^1.0.2", "@juggle/resize-observer": "^3.4.0", + "@types/markdown-it": "^13.0.7", "ace-builds": "^1.30.0", "electron-log": "^5.0.1", "electron-progressbar": "^2.1.0", diff --git a/src/presentation/assets/styles/_fonts.scss b/src/presentation/assets/styles/_fonts.scss index 0667056e..671b8aa8 100644 --- a/src/presentation/assets/styles/_fonts.scss +++ b/src/presentation/assets/styles/_fonts.scss @@ -33,12 +33,3 @@ $font-normal : 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; $font-artistic : 'Yesteryear', cursive; $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%; diff --git a/src/presentation/assets/styles/_globals.scss b/src/presentation/assets/styles/_globals.scss index fdc10d52..9ab97ee4 100644 --- a/src/presentation/assets/styles/_globals.scss +++ b/src/presentation/assets/styles/_globals.scss @@ -6,6 +6,7 @@ @use "@/presentation/assets/styles/fonts" as *; @use "@/presentation/assets/styles/mixins" as *; @use "@/presentation/assets/styles/vite-path" as *; +@use "@/presentation/assets/styles/typography" as *; * { box-sizing: border-box; @@ -20,5 +21,5 @@ a { body { background: $color-background; font-family: $font-main; - font-size: $font-size-normal; + font-size: $font-size-absolute-normal; } diff --git a/src/presentation/assets/styles/_mixins.scss b/src/presentation/assets/styles/_mixins.scss index eb201285..b2c8dc34 100644 --- a/src/presentation/assets/styles/_mixins.scss +++ b/src/presentation/assets/styles/_mixins.scss @@ -1,5 +1,6 @@ @use "@/presentation/assets/styles/colors" as *; @use "@/presentation/assets/styles/fonts" as *; +@use "@/presentation/assets/styles/typography" as *; @mixin hover-or-touch($selector-suffix: '', $selector-prefix: '&') { @media (hover: hover) { @@ -69,6 +70,11 @@ list-style: none; } +@mixin reset-sup { + vertical-align: unset; + font-size: unset; +} + @mixin reset-button { margin: 0; padding-block: 0; @@ -93,7 +99,7 @@ @mixin flat-button($disabled: false) { @include reset-button; - $font-size: $font-size-normal; + $font-size: $font-size-absolute-normal; @if $disabled { color: $color-primary-light; diff --git a/src/presentation/assets/styles/_typography.scss b/src/presentation/assets/styles/_typography.scss new file mode 100644 index 00000000..7c516561 --- /dev/null +++ b/src/presentation/assets/styles/_typography.scss @@ -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 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 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;` diff --git a/src/presentation/assets/styles/main.scss b/src/presentation/assets/styles/main.scss index d06041f9..88d7f7b7 100644 --- a/src/presentation/assets/styles/main.scss +++ b/src/presentation/assets/styles/main.scss @@ -1,6 +1,7 @@ /* This class is not supposed to more than forwarding other styles */ @forward "./fonts"; +@forward "./typography"; @forward "./media"; @forward "./colors"; @forward "./globals"; diff --git a/src/presentation/components/Code/CodeButtons/IconButton.vue b/src/presentation/components/Code/CodeButtons/IconButton.vue index d1e28b8e..5257efb3 100644 --- a/src/presentation/components/Code/CodeButtons/IconButton.vue +++ b/src/presentation/components/Code/CodeButtons/IconButton.vue @@ -81,7 +81,7 @@ export default defineComponent({ border-radius: 4px; .button__icon { - font-size: $font-size-larger; + font-size: $font-size-absolute-x-large; } @include clickable; @@ -99,7 +99,7 @@ export default defineComponent({ .button__text { display: none; font-family: $font-artistic; - font-size: $font-size-large; + font-size: $font-size-absolute-large; color: $color-primary; font-weight: 500; @include hover-or-touch { diff --git a/src/presentation/components/Code/CodeButtons/Save/RunInstructions/Steps/CopyableCommand.vue b/src/presentation/components/Code/CodeButtons/Save/RunInstructions/Steps/CopyableCommand.vue index 03ff0fad..ec541ac7 100644 --- a/src/presentation/components/Code/CodeButtons/Save/RunInstructions/Steps/CopyableCommand.vue +++ b/src/presentation/components/Code/CodeButtons/Save/RunInstructions/Steps/CopyableCommand.vue @@ -63,14 +63,14 @@ export default defineComponent({ padding: 0.2rem; .dollar { margin-right: 0.5rem; - font-size: $font-size-smaller; + font-size: $font-size-absolute-x-small; user-select: none; } .copy-action-container { margin-left: 1rem; } code { - font-size: $font-size-small; + font-size: $font-size-absolute-small; } } diff --git a/src/presentation/components/Code/TheCodeArea.vue b/src/presentation/components/Code/TheCodeArea.vue index 5968231f..50d3a588 100644 --- a/src/presentation/components/Code/TheCodeArea.vue +++ b/src/presentation/components/Code/TheCodeArea.vue @@ -203,7 +203,7 @@ function getDefaultCode(language: ScriptingLanguage): string { width: 100%; height: 100%; overflow: auto; - font-size: $font-size-small; + font-size: $font-size-absolute-small; &__highlight { background-color: $color-secondary-light; position: absolute; diff --git a/src/presentation/components/Scripts/View/Cards/CardList.vue b/src/presentation/components/Scripts/View/Cards/CardList.vue index e112ba56..51af12bc 100644 --- a/src/presentation/components/Scripts/View/Cards/CardList.vue +++ b/src/presentation/components/Scripts/View/Cards/CardList.vue @@ -142,7 +142,7 @@ function isClickable(element: Element) { .error { width: 100%; text-align: center; - font-size: $font-size-largest; + font-size: $font-size-absolute-xx-large; font-family: $font-normal; } diff --git a/src/presentation/components/Scripts/View/Cards/CardListItem.vue b/src/presentation/components/Scripts/View/Cards/CardListItem.vue index 616c7d6e..f6a99c68 100644 --- a/src/presentation/components/Scripts/View/Cards/CardListItem.vue +++ b/src/presentation/components/Scripts/View/Cards/CardListItem.vue @@ -168,7 +168,7 @@ $card-horizontal-gap : $card-gap; flex-direction: column; flex: 1; justify-content: center; - font-size: $font-size-large; + font-size: $font-size-absolute-large; } .card__inner__selection_indicator { height: $card-inner-padding; @@ -181,7 +181,7 @@ $card-horizontal-gap : $card-gap; width: 100%; margin-top: .25em; vertical-align: middle; - font-size: $font-size-normal; + font-size: $font-size-absolute-normal; } } .card__expander { @@ -203,7 +203,7 @@ $card-horizontal-gap : $card-gap; } .card__expander__close-button { - font-size: $font-size-large; + font-size: $font-size-absolute-large; align-self: flex-end; margin-right: 0.25em; @include clickable; diff --git a/src/presentation/components/Scripts/View/Cards/CardSelectionIndicator.vue b/src/presentation/components/Scripts/View/Cards/CardSelectionIndicator.vue index 80837b88..dc4668c6 100644 --- a/src/presentation/components/Scripts/View/Cards/CardSelectionIndicator.vue +++ b/src/presentation/components/Scripts/View/Cards/CardSelectionIndicator.vue @@ -57,6 +57,6 @@ export default defineComponent({ diff --git a/src/presentation/components/Scripts/View/TheScriptsView.vue b/src/presentation/components/Scripts/View/TheScriptsView.vue index 59d34852..cbd0ae44 100644 --- a/src/presentation/components/Scripts/View/TheScriptsView.vue +++ b/src/presentation/components/Scripts/View/TheScriptsView.vue @@ -151,7 +151,7 @@ $margin-inner: 4px; margin-top: 1em; color: $color-primary-light; .search__query__close-button { - font-size: $font-size-large; + font-size: $font-size-absolute-large; margin-left: 0.25rem; } } @@ -160,7 +160,7 @@ $margin-inner: 4px; flex-direction: column; word-break:break-word; color: $color-on-primary; - font-size: $font-size-large; + font-size: $font-size-absolute-large; padding:10px; text-align:center; > div { diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentationText.vue b/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentationText.vue index 06580d37..e8e7f80b 100644 --- a/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentationText.vue +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentationText.vue @@ -59,7 +59,7 @@ function formatAsMarkdownListItem(content: string): string { 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) - font-size: $font-size-normal; + font-size: $font-size-absolute-normal; font-family: $font-main; } diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/ToggleDocumentationButton.vue b/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/ToggleDocumentationButton.vue index ebb0860a..bfcc1723 100644 --- a/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/ToggleDocumentationButton.vue +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/ToggleDocumentationButton.vue @@ -49,7 +49,7 @@ export default defineComponent({ .documentation-button { vertical-align: middle; 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. @include hover-or-touch { color: $color-primary-darker; diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer.ts b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer.ts new file mode 100644 index 00000000..1d55a325 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer.ts @@ -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; diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer.ts b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer.ts index a3832c40..6a4b62c3 100644 --- a/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer.ts +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer.ts @@ -1,196 +1,3 @@ -import MarkdownIt from 'markdown-it'; -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 (`
`). - }); - configureLinksToOpenInNewTab(markdownParser); - return { - render: (markdownContent: string) => { - markdownContent = beautifyAutoLinkedUrls(markdownContent); - return markdownParser.render(markdownContent); - }, - }; -} - -interface MarkdownRenderer { +export interface MarkdownRenderer { render(markdownContent: string): string; } - -const PlainTextUrlInMarkdownRegex = /(? { - 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 = { - 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; -} diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownText.vue b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownText.vue index b7ebaf48..7c1f64f9 100644 --- a/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownText.vue +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownText.vue @@ -8,7 +8,7 @@ @@ -38,12 +37,11 @@ function convertMarkdownToHtml(markdownText: string): string { @import './markdown-styles.scss'; $text-color: $color-on-primary; -$text-size: 0.75em; // Lower looks bad on Firefox .markdown-text { color: $text-color; - font-size: $text-size; + font-size: $font-size-absolute-normal; font-family: $font-main; - @include markdown-text-styles($text-size: $text-size); + @include markdown-text-styles; } diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/InlineReferenceLabelsToSuperscriptConverter.ts b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/InlineReferenceLabelsToSuperscriptConverter.ts new file mode 100644 index 00000000..c05fa979 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/InlineReferenceLabelsToSuperscriptConverter.ts @@ -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 `[${label}]`; + }); +} + +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; diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/MarkdownItHtmlRenderer.ts b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/MarkdownItHtmlRenderer.ts new file mode 100644 index 00000000..5587dada --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/MarkdownItHtmlRenderer.ts @@ -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 (`
`). + }); + 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 = { + target: '_blank', + rel: 'noopener noreferrer', +} as const; diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/PlainTextUrlsToHyperlinksConverter.ts b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/PlainTextUrlsToHyperlinksConverter.ts new file mode 100644 index 00000000..3d30cff9 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Renderers/PlainTextUrlsToHyperlinksConverter.ts @@ -0,0 +1,127 @@ +import type { MarkdownRenderer } from '../MarkdownRenderer'; + +export class PlainTextUrlsToHyperlinksConverter implements MarkdownRenderer { + public render(markdownContent: string): string { + return autoLinkPlainUrls(markdownContent); + } +} + +const PlainTextUrlInMarkdownRegex = /(? { + 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) +} diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/markdown-styles.scss b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/markdown-styles.scss index e3bb7e0a..73c1caa7 100644 --- a/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/markdown-styles.scss +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/Markdown/markdown-styles.scss @@ -55,8 +55,8 @@ @include left-padding('ul, ol', $large-horizontal-spacing); } -@mixin markdown-text-styles($text-size) { - $base-spacing: $text-size; +@mixin markdown-text-styles { + $base-spacing: 1em; a { &[href] { @@ -73,11 +73,19 @@ content: ''; 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 ``) 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; - 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 @@ -113,4 +121,11 @@ $code-block-padding: $base-spacing, $color-background: $color-primary-darker, ); + + sup { + @include reset-sup; + + vertical-align: super; + font-size: $font-size-relative-smallest; + } } diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/NodeTitle.vue b/src/presentation/components/Scripts/View/Tree/NodeContent/NodeTitle.vue index 2af18961..111d7a8a 100644 --- a/src/presentation/components/Scripts/View/Tree/NodeContent/NodeTitle.vue +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/NodeTitle.vue @@ -24,6 +24,6 @@ export default defineComponent({ .node-title { font-family: $font-main; - font-size: $font-size-large; + font-size: $font-size-absolute-large; } diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/ToggleSwitch.vue b/src/presentation/components/Scripts/View/Tree/NodeContent/ToggleSwitch.vue index 9622fd4a..17ae86eb 100644 --- a/src/presentation/components/Scripts/View/Tree/NodeContent/ToggleSwitch.vue +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/ToggleSwitch.vue @@ -75,7 +75,7 @@ export default defineComponent({ @use 'sass:math'; @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-checked : $color-on-secondary; diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/NodeCheckbox.vue b/src/presentation/components/Scripts/View/Tree/TreeView/Node/NodeCheckbox.vue index 5a883c68..0922a242 100644 --- a/src/presentation/components/Scripts/View/Tree/TreeView/Node/NodeCheckbox.vue +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/NodeCheckbox.vue @@ -53,7 +53,7 @@ export default defineComponent({ @use "@/presentation/assets/styles/main" as *; @use "./../tree-colors" as *; -$side-size-in-px: $font-size-larger; +$side-size-in-px: $font-size-absolute-x-large; .checkbox { position: relative; diff --git a/src/presentation/components/Shared/Modal/ModalDialog.vue b/src/presentation/components/Shared/Modal/ModalDialog.vue index e94c6bb7..b5e5d34d 100644 --- a/src/presentation/components/Shared/Modal/ModalDialog.vue +++ b/src/presentation/components/Shared/Modal/ModalDialog.vue @@ -74,7 +74,7 @@ export default defineComponent({ .dialog__close-button { color: $color-primary-dark; width: auto; - font-size: $font-size-large; + font-size: $font-size-absolute-large; margin-right: 0.25em; align-self: flex-start; } diff --git a/src/presentation/components/Shared/TooltipWrapper.vue b/src/presentation/components/Shared/TooltipWrapper.vue index e9bf09f6..6f0c4d02 100644 --- a/src/presentation/components/Shared/TooltipWrapper.vue +++ b/src/presentation/components/Shared/TooltipWrapper.vue @@ -225,7 +225,7 @@ $color-tooltip-background: $color-primary-darkest; color: $color-on-primary; border-radius: 16px; 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. diff --git a/src/presentation/components/TheFooter/DownloadUrlList.vue b/src/presentation/components/TheFooter/DownloadUrlList.vue index aec0b90d..14b3a725 100644 --- a/src/presentation/components/TheFooter/DownloadUrlList.vue +++ b/src/presentation/components/TheFooter/DownloadUrlList.vue @@ -78,7 +78,7 @@ export default defineComponent({ &__url { &:not(:first-child)::before { content: "|"; - font-size: $font-size-smaller; + font-size: $font-size-absolute-x-small; padding: 0 5px; } } diff --git a/src/presentation/components/TheFooter/DownloadUrlListItem.vue b/src/presentation/components/TheFooter/DownloadUrlListItem.vue index 46d76049..0bbaa4e0 100644 --- a/src/presentation/components/TheFooter/DownloadUrlListItem.vue +++ b/src/presentation/components/TheFooter/DownloadUrlListItem.vue @@ -65,7 +65,7 @@ function hasDesktopVersion(os: OperatingSystem): boolean { @use "@/presentation/assets/styles/main" as *; .url { .inactive { - font-size: $font-size-smaller; + font-size: $font-size-absolute-x-small; } } diff --git a/src/presentation/components/TheHeader.vue b/src/presentation/components/TheHeader.vue index 8e26802d..6a2ce26b 100644 --- a/src/presentation/components/TheHeader.vue +++ b/src/presentation/components/TheHeader.vue @@ -46,11 +46,11 @@ export default defineComponent({ margin: 0; text-transform: uppercase; font-family: $font-main; - font-size: $font-size-largest; + font-size: $font-size-absolute-xx-large; } .subtitle { margin: 0; - font-size: $font-size-larger; + font-size: $font-size-absolute-x-large; color: $color-primary; font-family: $font-artistic; font-weight: 500; diff --git a/src/presentation/components/TheSearchBar.vue b/src/presentation/components/TheSearchBar.vue index f8516229..422c1e84 100644 --- a/src/presentation/components/TheSearchBar.vue +++ b/src/presentation/components/TheSearchBar.vue @@ -113,7 +113,7 @@ export default defineComponent({ outline: none; color: $color-primary; font-family: $font-normal; - font-size: $font-size-normal; + font-size: $font-size-absolute-normal; &:focus { color: $color-primary-darker; } @@ -127,7 +127,7 @@ export default defineComponent({ text-align: center; color: $color-on-primary; border-radius: 0 5px 5px 0; - font-size: $font-size-large; + font-size: $font-size-absolute-large; padding:5px; } diff --git a/tests/integration/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer.spec.ts b/tests/integration/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer.spec.ts new file mode 100644 index 00000000..decc2495 --- /dev/null +++ b/tests/integration/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer.spec.ts @@ -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
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('
', formatAssertionMessage([ + 'Expected no
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, + }; +} diff --git a/tests/integration/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer.spec.ts b/tests/integration/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer.spec.ts deleted file mode 100644 index 5747068a..00000000 --- a/tests/integration/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer.spec.ts +++ /dev/null @@ -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 { - 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, - }; -} diff --git a/tests/shared/HtmlParser.ts b/tests/shared/HtmlParser.ts new file mode 100644 index 00000000..eb28fba8 --- /dev/null +++ b/tests/shared/HtmlParser.ts @@ -0,0 +1,5 @@ +export function parseHtml(htmlString: string): Document { + const parser = new window.DOMParser(); + const htmlDoc = parser.parseFromString(htmlString, 'text/html'); + return htmlDoc; +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer.spec.ts new file mode 100644 index 00000000..16227d58 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/CompositeMarkdownRenderer.spec.ts @@ -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 { + const renderer = new CompositeMarkdownRenderer(this.markdownRenderers); + return renderer.render(this.markdownInput); + } +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/InlineReferenceLabelsToSuperscriptConverter.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/InlineReferenceLabelsToSuperscriptConverter.spec.ts new file mode 100644 index 00000000..95b8c3e4 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/InlineReferenceLabelsToSuperscriptConverter.spec.ts @@ -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 [1].', + 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 [Reference A].', + 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 [1], [2], and [3] 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 [5].', 'Additional data in [6].', + 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[first][2][3]end', + createMarkdownLinkReferenceDefinition('first'), createMarkdownLinkReferenceDefinition('2'), createMarkdownLinkReferenceDefinition('3'), + ].join('\n'), + }, + { + description: 'handles references with special characters', + markdownContent: [ + '[reference-name!]', + createMarkdownLinkReferenceDefinition('reference-name!'), + ].join('\n'), + expectedOutput: [ + '[reference-name!]', + 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 [1]: "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`; +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/MarkdownItHtmlRenderer.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/MarkdownItHtmlRenderer.spec.ts new file mode 100644 index 00000000..da4deae6 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/MarkdownItHtmlRenderer.spec.ts @@ -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
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 `` 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 \`\` element HTML: ${aElement.outerHTML}`, + ])); +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/MarkdownRenderingTester.ts b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/MarkdownRenderingTester.ts new file mode 100644 index 00000000..c48fbde0 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/MarkdownRenderingTester.ts @@ -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 +): ReturnType { + const rendererInstance = new MarkdownRendererClass(); + return rendererInstance.render(...renderArgs); +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/PlainTextUrlsToHyperlinksConverter.spec.ts similarity index 50% rename from tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer.spec.ts rename to tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/PlainTextUrlsToHyperlinksConverter.spec.ts index f530e465..d69d1ea4 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Markdown/Modifiers/PlainTextUrlsToHyperlinksConverter.spec.ts @@ -1,87 +1,47 @@ 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('createMarkdownRenderer', () => { - it('creates renderer instance', () => { - // arrange & act - const renderer = createMarkdownRenderer(); - // assert - expect(renderer !== undefined); - }); - describe('sets default anchor attributes', () => { - const attributes: ReadonlyArray<{ - readonly attributeName: string, - readonly expectedValue: string, - readonly invalidMarkdown: string +describe('PlainTextUrlsToHyperlinksConverter', () => { + describe('modify', () => { + describe('retains original content where no conversion is required', () => { + const testScenarios: ReadonlyArray<{ + readonly description: string; + readonly markdownContent: string; }> = [ { - attributeName: 'target', - expectedValue: '_blank', - invalidMarkdown: 'example', + description: 'URLs within markdown link syntax', + markdownContent: 'URL: [privacy.sexy](https://privacy.sexy).', + }, + { + description: 'URLs within inline code blocks', + markdownContent: 'URL as code: `https://privacy.sexy`.', }, { - attributeName: 'rel', - expectedValue: 'noopener noreferrer', - invalidMarkdown: 'example', + description: 'reference-style links', + markdownContent: [ + 'This content has reference-style link [1].', + '[1]: https://privacy.sexy', + ].join('\n'), }, ]; - for (const attribute of attributes) { - const { attributeName, expectedValue, invalidMarkdown } = attribute; - - it(`adds "${attributeName}" attribute to anchors`, () => { - // arrange - const renderer = createMarkdownRenderer(); - const markdown = '[undergroundwires.dev](https://undergroundwires.dev)'; - - // act - const htmlString = renderer.render(markdown); - - // assert - const html = parseHtml(htmlString); - const aElement = html.getElementsByTagName('a')[0]; - expect(aElement.getAttribute(attributeName)).to.equal(expectedValue); - }); - - it(`overrides existing "${attributeName}" attribute`, () => { + testScenarios.forEach(({ description, markdownContent }) => { + it(description, () => { // arrange - const renderer = createMarkdownRenderer(); + const expectedOutput = markdownContent; // No change expected // act - const htmlString = renderer.render(invalidMarkdown); + const convertedContent = renderMarkdownUsingRenderer( + PlainTextUrlsToHyperlinksConverter, + markdownContent, + ); // assert - const html = parseHtml(htmlString); - const aElement = html.getElementsByTagName('a')[0]; - expect(aElement.getAttribute(attributeName)).to.equal(expectedValue); + expect(convertedContent).to.equal(expectedOutput); }); - } - }); - it('does not convert single line breaks to
elements', () => { - // 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', () => { + describe('converts plain URLs into hyperlinks', () => { const testScenarios: ReadonlyArray<{ readonly description: string; readonly urlText: string; @@ -158,22 +118,17 @@ describe('MarkdownRenderer', () => { }) => { it(description, () => { // arrange - const renderer = createMarkdownRenderer(); const markdown = `Visit ${urlText} now!`; + const expectedOutput = `Visit [${expectedLabel}](${urlText}) now!`; // act - const htmlString = renderer.render(markdown); + const actualOutput = renderMarkdownUsingRenderer( + PlainTextUrlsToHyperlinksConverter, + markdown, + ); // assert - const html = parseHtml(htmlString); - const aElement = html.getElementsByTagName('a')[0]; - expect(aElement.text).to.equal(expectedLabel); + expect(actualOutput).to.equal(expectedOutput); }); }); }); }); }); - -function parseHtml(htmlString: string): Document { - const parser = new window.DOMParser(); - const htmlDoc = parser.parseFromString(htmlString, 'text/html'); - return htmlDoc; -} diff --git a/tests/unit/presentation/electron/shared/IpcProxy.spec.ts b/tests/unit/presentation/electron/shared/IpcProxy.spec.ts index 299b11f9..03397c4e 100644 --- a/tests/unit/presentation/electron/shared/IpcProxy.spec.ts +++ b/tests/unit/presentation/electron/shared/IpcProxy.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; 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('createIpcConsumerProxy', () => { diff --git a/tests/unit/shared/Stubs/MarkdownRendererStub.ts b/tests/unit/shared/Stubs/MarkdownRendererStub.ts new file mode 100644 index 00000000..2510be35 --- /dev/null +++ b/tests/unit/shared/Stubs/MarkdownRendererStub.ts @@ -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 + 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); + } +}