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:
@@ -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