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:
41
package-lock.json
generated
41
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
19
src/presentation/assets/styles/_typography.scss
Normal file
19
src/presentation/assets/styles/_typography.scss
Normal 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;`
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -57,6 +57,6 @@ export default defineComponent({
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
.icon {
|
||||
font-size: $font-size-normal;
|
||||
font-size: $font-size-absolute-normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 (`<br>`).
|
||||
});
|
||||
configureLinksToOpenInNewTab(markdownParser);
|
||||
return {
|
||||
render: (markdownContent: string) => {
|
||||
markdownContent = beautifyAutoLinkedUrls(markdownContent);
|
||||
return markdownParser.render(markdownContent);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface MarkdownRenderer {
|
||||
export interface MarkdownRenderer {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { createMarkdownRenderer } from './MarkdownRenderer';
|
||||
import { CompositeMarkdownRenderer } from './CompositeMarkdownRenderer';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -26,9 +26,8 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
const renderer = createMarkdownRenderer();
|
||||
|
||||
function convertMarkdownToHtml(markdownText: string): string {
|
||||
const renderer = new CompositeMarkdownRenderer();
|
||||
return renderer.render(markdownText);
|
||||
}
|
||||
</script>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 `<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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,6 @@ export default defineComponent({
|
||||
|
||||
.node-title {
|
||||
font-family: $font-main;
|
||||
font-size: $font-size-large;
|
||||
font-size: $font-size-absolute-large;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
5
tests/shared/HtmlParser.ts
Normal file
5
tests/shared/HtmlParser.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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}`,
|
||||
]));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: '<a href="https://undergroundwires.dev" target="_self">example</a>',
|
||||
description: 'URLs within markdown link syntax',
|
||||
markdownContent: 'URL: [privacy.sexy](https://privacy.sexy).',
|
||||
},
|
||||
{
|
||||
attributeName: 'rel',
|
||||
expectedValue: 'noopener noreferrer',
|
||||
invalidMarkdown: '<a href="https://undergroundwires.dev" rel="nooverride">example</a>',
|
||||
description: 'URLs within inline code blocks',
|
||||
markdownContent: 'URL as code: `https://privacy.sexy`.',
|
||||
},
|
||||
{
|
||||
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`, () => {
|
||||
testScenarios.forEach(({ description, markdownContent }) => {
|
||||
it(description, () => {
|
||||
// arrange
|
||||
const renderer = createMarkdownRenderer();
|
||||
const markdown = '[undergroundwires.dev](https://undergroundwires.dev)';
|
||||
const expectedOutput = markdownContent; // No change expected
|
||||
|
||||
// act
|
||||
const htmlString = renderer.render(markdown);
|
||||
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(`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', () => {
|
||||
// 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;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
31
tests/unit/shared/Stubs/MarkdownRendererStub.ts
Normal file
31
tests/unit/shared/Stubs/MarkdownRendererStub.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user