From b62e87df4ef46de22f3bc33e57f8235d24919f57 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:12:28 +0000 Subject: [PATCH] fix(sections): keep a description's block Markdown in the description section The description section replaced every blank line in the description with `
` and squashed runs of spaces. A list, a second paragraph or an indented code block lost its structure, and a fence in a list item fell out of the list. The description is now kept as written, apart from trimming and CRLF normalisation, and prettier formats it as block Markdown. The fence detection from #709 only existed to protect fences from the squash, so it goes too. The contract verifier builds its expected description with the same rule. A README whose action description contains a blank line changes once: paragraphs joined by `
` become separate paragraphs. Fixes #711 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H37LVnd9qB6PmZ5bJi5H2t --- .github/ghadocs/branding.svg | 2 +- README.md | 2 +- __tests__/update-description.test.ts | 100 +++++++++++------------ __tests__/verify-readme-contract.test.ts | 12 +++ scripts/verify-readme-contract.mjs | 51 ++---------- src/sections/update-description.ts | 72 +--------------- 6 files changed, 74 insertions(+), 165 deletions(-) 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(