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,
};
}