Render bracket references as superscript text

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

Changes:

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

View File

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

View File

@@ -1,55 +0,0 @@
import { describe, it, expect } from 'vitest';
import { parseApplication } from '@/application/Parser/ApplicationParser';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { createMarkdownRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Markdown/MarkdownRenderer';
describe('MarkdownRenderer', () => {
describe('can render all docs', () => {
// arrange
const renderer = createMarkdownRenderer();
for (const node of collectAllDocumentableNodes()) {
it(`${node.nodeLabel}`, () => {
// act
const html = renderer.render(node.docs);
const result = validateHtml(html);
// assert
expect(result.isValid, result.generatedHtml);
});
}
});
});
interface IDocumentableNode {
readonly nodeLabel: string
readonly docs: string
}
function* collectAllDocumentableNodes(): Generator<IDocumentableNode> {
const app = parseApplication();
for (const collection of app.collections) {
const documentableNodes = [
...collection.getAllScripts(),
...collection.getAllCategories(),
];
for (const node of documentableNodes) {
const documentable: IDocumentableNode = {
nodeLabel: `${OperatingSystem[collection.os]} | ${node.name} (${node.id})`,
docs: node.docs.join('\n'),
};
yield documentable;
}
}
}
interface IHTMLValidationResult {
readonly isValid: boolean;
readonly generatedHtml: string;
}
function validateHtml(value: string): IHTMLValidationResult {
const doc = new window.DOMParser()
.parseFromString(value, 'text/html');
return {
isValid: Array.from(doc.body.childNodes).some((node) => node.nodeType === 1),
generatedHtml: doc.body.innerHTML,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,87 +1,47 @@
import { describe, it, expect } from 'vitest';
import { 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;
}

View File

@@ -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', () => {

View File

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