diff --git a/.github/ghadocs/branding.svg b/.github/ghadocs/branding.svg index b7809eea..ee6d5285 100644 --- a/.github/ghadocs/branding.svg +++ b/.github/ghadocs/branding.svg @@ -1,3 +1,3 @@ - + diff --git a/README.md b/README.md index 263f7edd..1822a402 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.1 +- uses: bitflight-devops/github-action-readme-generator@v2.0.2 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 index 0f6cadd3..eff4b0c1 100644 --- a/__tests__/update-description.test.ts +++ b/__tests__/update-description.test.ts @@ -3,81 +3,79 @@ */ import { describe, expect, it } from 'vite-plus/test'; +import { formatMarkdown } from '../src/prettier.js'; import { descriptionMarkdown } from '../src/sections/update-description.js'; describe('descriptionMarkdown', () => { - it('squashes prose and turns blank lines into breaks', () => { + it('trims the description and keeps its lines as written', () => { expect(descriptionMarkdown(' One line \nnext\n\nNew paragraph ')).toBe( - 'One line\nnext
New paragraph', + 'One line \nnext\n\nNew paragraph', ); }); it('reads CRLF line endings as LF', () => { - expect(descriptionMarkdown('A\r\n\r\nB')).toBe('A
B'); + expect(descriptionMarkdown('A\r\n\r\nB')).toBe('A\n\nB'); }); - // #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', () => { + // #711: blank lines and indentation carry the structure of block Markdown. + it('keeps a list whose item holds a fenced code block', () => { const description = [ - 'Does things.', - '', - '```yaml', - 'uses: x', + 'Does things.', '', - ' with: y', - '```', + '- Run:', '', - 'After it.', + ' ```yaml', + ' uses: x', + ' ```', ].join('\n'); - expect(descriptionMarkdown(description)).toBe( - ['Does things.', '', '```yaml', 'uses: x', '', ' with: y', '```', '', 'After it.'].join( - '\n', - ), - ); + expect(descriptionMarkdown(description)).toBe(description); }); - 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'); + it('keeps an indented code block', () => { + expect(descriptionMarkdown('Intro\n\n indented line')).toBe('Intro\n\n indented line'); }); +}); - // 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', - ); - }); +describe('the formatted description section', () => { + const section = async (description: string): Promise => + (await formatMarkdown(`\n${descriptionMarkdown(description)}`)).trim(); - it('keeps a fenced code block inside a list item verbatim', () => { - const description = ['- Run:', '', ' ```yaml', ' uses: x', '', ' ```', '- Done'].join( - '\n', - ); + it('keeps paragraphs, lists and fences as block Markdown', async () => { + const description = [ + 'Does things.', + '', + 'Second paragraph.', + '', + '- Run:', + '', + ' ```yaml', + ' uses: x', + ' ```', + '', + '- Done', + ].join('\n'); - expect(descriptionMarkdown(description)).toBe( - ['- Run:', '', ' ```yaml', ' uses: x', '', ' ```', '', '- Done'].join('\n'), + expect(await section(description)).toBe( + [ + 'Does things.', + '', + 'Second paragraph.', + '', + '- 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('is stable when formatted again', async () => { + const once = await section('Intro\n\n- a\n\n more of a\n- b\n\n code'); - it('leaves backticks that do not start a line as prose', () => { - expect(descriptionMarkdown('Use ``` inline')).toBe('Use ``` inline'); + expect(await section(once)).toBe(once); }); }); diff --git a/__tests__/verify-readme-contract.test.ts b/__tests__/verify-readme-contract.test.ts index a7b7004f..04ec3fb1 100644 --- a/__tests__/verify-readme-contract.test.ts +++ b/__tests__/verify-readme-contract.test.ts @@ -158,6 +158,18 @@ describe('README contract verifier regressions', () => { expect(verify(readme, action)).toContain('All contract checks passed'); }); + // #711: the description section keeps a description's block Markdown. + it('expects a block Markdown description as written', () => { + const action = ACTION.replace( + 'description: __bold__', + 'description: |\n Intro.\n\n - one\n - two', + ); + expect(verify(README.replace('**bold**', 'Intro.\n\n- one\n- two'), action)).toContain( + 'All contract checks passed', + ); + expect(() => verify(README.replace('**bold**', 'Intro.
- one\n- two'), action)).toThrow(); + }); + it('validates generated usage descriptions and removed defaults exactly', () => { expect(() => verify(README.replace(' # Description: A \\| B', ' # Description: stale')), diff --git a/scripts/verify-readme-contract.mjs b/scripts/verify-readme-contract.mjs index 5d31a2ae..9b3016f8 100644 --- a/scripts/verify-readme-contract.mjs +++ b/scripts/verify-readme-contract.mjs @@ -785,10 +785,10 @@ const DEFAULT_BRAND_COLOR = 'blue'; * name, so the name is what the body must END with. Containment accepted a * stale `# Release Action Legacy` for an action since renamed `Release * Action`, which no templating can produce. - * - `description` is the whole description with paragraph breaks turned into - * `
`, so it is compared entire. Comparing the first paragraph passed a - * section holding only that paragraph — for a multi-paragraph description, - * precisely the stale state this gate is for. (`firstParagraph` is still + * - `description` is the whole description, kept as written, so it is compared + * entire. Comparing the first paragraph passed a section holding only that + * paragraph — for a multi-paragraph description, precisely the stale state + * this gate is for. (`firstParagraph` is still * right for the tables, where `update-inputs.ts` really does truncate.) * - `branding` is one `` tag with no free parts: `updateBranding` fixes * the width at `15%` and the alignment at `center`, and only the `src` comes @@ -811,47 +811,10 @@ 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. + * Mirrors `descriptionMarkdown` in `src/sections/update-description.ts`: the + * description is block Markdown, kept as written. */ -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 descriptionMarkdown = (value) => String(value).trim().replaceAll('\r\n', '\n'); const expectedDescription = action.description ? await generatedMarkdown(descriptionMarkdown(action.description)) : null; diff --git a/src/sections/update-description.ts b/src/sections/update-description.ts index c77db10b..7028ba3c 100644 --- a/src/sections/update-description.ts +++ b/src/sections/update-description.ts @@ -7,85 +7,21 @@ */ 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. + * The description is block Markdown, so it is kept as written: blank lines, + * indentation and fenced code blocks all carry structure — see #711. + * Formatting it is prettier's job. * @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'); + return description.trim().replaceAll('\r\n', '\n'); } export default function updateDescription(