diff --git a/.github/ghadocs/branding.svg b/.github/ghadocs/branding.svg index ee6d5285..b7809eea 100644 --- a/.github/ghadocs/branding.svg +++ b/.github/ghadocs/branding.svg @@ -1,3 +1,3 @@ - + diff --git a/README.md b/README.md index 8dc6ead6..263f7edd 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ value replaced by `***REDACTED***`. Keys whose names look sensitive (`auth`, ```yaml -- uses: bitflight-devops/github-action-readme-generator@v2.0.0 +- uses: bitflight-devops/github-action-readme-generator@v2.0.1 with: # Description: The absolute or relative path to the `action.yml` file to read in # from. diff --git a/__tests__/update-description.test.ts b/__tests__/update-description.test.ts new file mode 100644 index 00000000..0f6cadd3 --- /dev/null +++ b/__tests__/update-description.test.ts @@ -0,0 +1,83 @@ +/** + * Covers how an action.yml description becomes the description section. + */ +import { describe, expect, it } from 'vite-plus/test'; + +import { descriptionMarkdown } from '../src/sections/update-description.js'; + +describe('descriptionMarkdown', () => { + it('squashes prose and turns blank lines into breaks', () => { + expect(descriptionMarkdown(' One line \nnext\n\nNew paragraph ')).toBe( + 'One line\nnext
New paragraph', + ); + }); + + it('reads CRLF line endings as LF', () => { + expect(descriptionMarkdown('A\r\n\r\nB')).toBe('A
B'); + }); + + // #705: a fence keeps its own lines, set apart from the prose by blank lines. + it('keeps a fenced code block verbatim on lines of its own', () => { + const description = [ + 'Does things.', + '', + '```yaml', + 'uses: x', + '', + ' with: y', + '```', + '', + 'After it.', + ].join('\n'); + + expect(descriptionMarkdown(description)).toBe( + ['Does things.', '', '```yaml', 'uses: x', '', ' with: y', '```', '', 'After it.'].join( + '\n', + ), + ); + }); + + it('closes a fence only on a fence of the same character and at least its length', () => { + const description = ['````', '```', '~~~~', '````', 'after'].join('\n'); + + expect(descriptionMarkdown(description)).toBe( + ['````', '```', '~~~~', '````', '', 'after'].join('\n'), + ); + }); + + it('keeps an unclosed fence to the end, as it renders', () => { + expect(descriptionMarkdown('Intro\n\n~~~\nopen only')).toBe('Intro\n\n~~~\nopen only'); + }); + + // A backtick fence's info string cannot hold a backtick, so this line is an + // inline code span. + it('reads a line opening with an inline code span as prose', () => { + expect(descriptionMarkdown('```code``` is prose\n\nnext para')).toBe( + '```code``` is prose
next para', + ); + }); + + it('keeps a fenced code block inside a list item verbatim', () => { + const description = ['- Run:', '', ' ```yaml', ' uses: x', '', ' ```', '- Done'].join( + '\n', + ); + + expect(descriptionMarkdown(description)).toBe( + ['- Run:', '', ' ```yaml', ' uses: x', '', ' ```', '', '- Done'].join('\n'), + ); + }); + + it('squashes an indented code block as prose, as before', () => { + expect(descriptionMarkdown('Intro\n\n indented line')).toBe('Intro
indented line'); + }); + + it('squashes an indented code block whose first line is backticks as prose', () => { + expect(descriptionMarkdown('Intro\n\n ```not a fence\n x')).toBe( + 'Intro
```not a fence\n x', + ); + }); + + it('leaves backticks that do not start a line as prose', () => { + expect(descriptionMarkdown('Use ``` inline')).toBe('Use ``` inline'); + }); +}); diff --git a/scripts/verify-readme-contract.mjs b/scripts/verify-readme-contract.mjs index 88337068..1b8bb6f6 100644 --- a/scripts/verify-readme-contract.mjs +++ b/scripts/verify-readme-contract.mjs @@ -799,10 +799,50 @@ const titleImage = titleBranding ? `${brandingImage('60px')} ` : ''; const expectedBranding = await generatedMarkdown(brandingImage('15%')); const expectedTitle = action.name ? `# ${titleImage}${titlePrefix}${action.name}` : null; const normalisedExpectedTitle = expectedTitle ? await generatedMarkdown(expectedTitle) : null; +/** + * Mirrors `descriptionMarkdown` in `src/sections/update-description.ts`: + * prose is squashed with blank lines as `
`, and a fenced code block — + * found by parsing, so one inside a list counts — keeps its own lines, + * verbatim, set apart from the prose by blank lines. + */ +const descriptionMarkdown = (value) => { + const text = String(value).trim().replaceAll('\r\n', '\n'); + const lineOf = (offset) => text.slice(0, offset).split('\n').length - 1; + const fences = []; + const walk = (node) => { + if (node.type === 'code' && node.position) { + const { start, end } = node.position; + if (/^(```|~~~)/.test(text.slice(start.offset, end.offset))) { + fences.push([lineOf(start.offset), lineOf(end.offset)]); + } + } + (node.children ?? []).forEach(walk); + }; + walk(markdown.parsers.markdown.parse(text, {})); + + const blocks = []; + let current = null; + text.split('\n').forEach((line, index) => { + const code = fences.some(([first, last]) => index >= first && index <= last); + if (current?.code === code) current.lines.push(line); + else blocks.push((current = { code, lines: [line] })); + }); + return blocks + .map(({ code, lines }) => + code + ? lines.join('\n') + : lines + .join('\n') + .trim() + .replaceAll(/ +/g, ' ') + .replaceAll(' \n', '\n') + .replaceAll('\n\n', '
'), + ) + .filter(Boolean) + .join('\n\n'); +}; const expectedDescription = action.description - ? await generatedMarkdown( - String(action.description).trim().replaceAll('\r\n', '\n').replaceAll(/ +/g, ' ').replaceAll(' \n', '\n').replaceAll('\n\n', '
'), - ) + ? await generatedMarkdown(descriptionMarkdown(action.description)) : null; for (const name of ['title', 'description', 'branding']) { diff --git a/src/sections/update-description.ts b/src/sections/update-description.ts index 6c52e272..c77db10b 100644 --- a/src/sections/update-description.ts +++ b/src/sections/update-description.ts @@ -7,8 +7,87 @@ */ import type { ReadmeSection } from '../constants.js'; import type Inputs from '../inputs.js'; +import * as markdown from 'prettier/plugins/markdown'; + import LogTask from '../logtask/index.js'; +/** The parts of a Markdown AST node this module reads. */ +interface MarkdownNode { + type: string; + position?: { start: { offset: number }; end: { offset: number } }; + children?: MarkdownNode[]; +} + +/** + * The 0-based first and last line of each fenced code block in `text`. + * + * Found with the markdown parser prettier already bundles, so a fence inside + * a list or a blockquote, or a line that only opens an inline code span, + * follows Markdown's rules. Indented code blocks are not included: their + * lines are treated as prose. + * @param {string} text - The description, with LF line endings. + * @returns {Array<[number, number]>} - The line ranges, inclusive. + */ +function fencedLines(text: string): [number, number][] { + const lineOf = (offset: number): number => text.slice(0, offset).split('\n').length - 1; + const ranges: [number, number][] = []; + const walk = (node: MarkdownNode): void => { + const { position } = node; + if (node.type === 'code' && position) { + // A fenced block's node starts at its fence; an indented block's node + // starts at its indentation, even when its first line is backticks. + const source = text.slice(position.start.offset, position.end.offset); + if (source.startsWith('```') || source.startsWith('~~~')) { + ranges.push([lineOf(position.start.offset), lineOf(position.end.offset)]); + } + } + for (const child of node.children ?? []) { + walk(child); + } + }; + walk(markdown.parsers.markdown.parse(text, {} as never) as MarkdownNode); + return ranges; +} + +/** + * Converts an action.yml description to the Markdown of the description + * section. + * + * Prose is squashed and its blank lines become `
`. A fenced code block + * keeps its own lines, verbatim, with a blank line between it and the prose + * around it — see #705. + * @param {string} description - The description from action.yml. + * @returns {string} - The section's Markdown. + */ +export function descriptionMarkdown(description: string): string { + const text = description.trim().replaceAll('\r\n', '\n'); + const fences = fencedLines(text); + const segments: { code: boolean; lines: string[] }[] = []; + for (const [index, line] of text.split('\n').entries()) { + const code = fences.some(([first, last]) => index >= first && index <= last); + const last = segments.at(-1); + if (last?.code === code) { + last.lines.push(line); + } else { + segments.push({ code, lines: [line] }); + } + } + + return segments + .map(({ code, lines }) => { + const block = lines.join('\n'); + return code + ? block + : block + .trim() + .replaceAll(/ +/g, ' ') // Squash consecutive spaces + .replaceAll(' \n', '\n') // Squash space followed by newline + .replaceAll('\n\n', '
'); // Convert double return to a break + }) + .filter((block) => block !== '') + .join('\n\n'); +} + export default function updateDescription( sectionToken: ReadmeSection, inputs: Inputs, @@ -21,12 +100,7 @@ export default function updateDescription( // Build the new description section if (inputs?.action?.description) { log.start(); - const desc: string = inputs.action.description - .trim() - .replaceAll('\r\n', '\n') // Convert CR to LF - .replaceAll(/ +/g, ' ') // Squash consecutive spaces - .replaceAll(' \n', '\n') // Squash space followed by newline - .replaceAll('\n\n', '
'); // Convert double return to a break + const desc = descriptionMarkdown(inputs.action.description); log.info(`Writing ${desc.length} characters to the description section`); content.push(desc);