From fe87da0fc4a3462525ce302e976ed2fcab928897 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:12:36 +0000 Subject: [PATCH 1/4] fix(sections): keep fenced code blocks in a description intact The description updater turned every blank line into
, including the one before a fenced code block. The opening fence then sat mid-line as text, and the closing fence opened a fence that nothing closed, so GitHub rendered the rest of the README as code. descriptionMarkdown keeps a fenced block verbatim on lines of its own and applies the squashing and
conversion to the prose around it. A description with no fence converts exactly as before. The contract verifier builds its expected description with the same rule. Fixes #705 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 | 56 +++++++++++++++++++++++++ scripts/verify-readme-contract.mjs | 42 +++++++++++++++++-- src/sections/update-description.ts | 63 +++++++++++++++++++++++++--- 5 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 __tests__/update-description.test.ts 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..87b33b23 --- /dev/null +++ b/__tests__/update-description.test.ts @@ -0,0 +1,56 @@ +/** + * 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: flattening the blank line before a fence moved its opening fence + // mid-line, and the closing fence then opened a fence nothing closed. + 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'); + }); + + 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..ab5d8354 100644 --- a/scripts/verify-readme-contract.mjs +++ b/scripts/verify-readme-contract.mjs @@ -799,10 +799,46 @@ 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 is + * kept verbatim, separated from the prose by blank lines. + */ +const descriptionMarkdown = (value) => { + const blocks = []; + let prose = []; + let fence = null; + const flushProse = () => { + const text = prose + .join('\n') + .trim() + .replaceAll(/ +/g, ' ') + .replaceAll(' \n', '\n') + .replaceAll('\n\n', '
'); + if (text) blocks.push(text); + prose = []; + }; + for (const line of String(value).trim().replaceAll('\r\n', '\n').split('\n')) { + const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]; + if (fence === null && marker) { + flushProse(); + fence = { marker, lines: [line] }; + } else if (fence !== null) { + fence.lines.push(line); + if (marker && marker[0] === fence.marker[0] && marker.length >= fence.marker.length && line.trim() === marker) { + blocks.push(fence.lines.join('\n')); + fence = null; + } + } else { + prose.push(line); + } + } + if (fence !== null) blocks.push(fence.lines.join('\n')); + flushProse(); + return blocks.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..8bceb343 100644 --- a/src/sections/update-description.ts +++ b/src/sections/update-description.ts @@ -9,6 +9,62 @@ import type { ReadmeSection } from '../constants.js'; import type Inputs from '../inputs.js'; import LogTask from '../logtask/index.js'; +/** A fence opener or closer: three or more backticks or tildes. */ +const FENCE = /^ {0,3}(`{3,}|~{3,})/; + +/** + * Converts an action.yml description to the Markdown of the description + * section. + * + * Prose is squashed and its blank lines become `
`. A fenced code block + * is kept verbatim on lines of its own: flattening the blank line before it + * would move its opening fence mid-line, leaving the closing fence to open a + * fence that nothing closes. + * @param {string} description - The description from action.yml. + * @returns {string} - The section's Markdown. + */ +export function descriptionMarkdown(description: string): string { + const segments: { code: boolean; lines: string[] }[] = []; + let fence: string | undefined; + for (const line of description.trim().replaceAll('\r\n', '\n').split('\n')) { + const marker = FENCE.exec(line)?.[1]; + if (fence === undefined && marker !== undefined) { + fence = marker; + segments.push({ code: true, lines: [line] }); + continue; + } + const last = segments.at(-1); + if (fence !== undefined && last !== undefined) { + last.lines.push(line); + const closes = + marker?.charAt(0) === fence.charAt(0) && + marker.length >= fence.length && + line.trim() === marker; + if (closes) { + fence = undefined; + } + } else if (last === undefined || last.code) { + segments.push({ code: false, lines: [line] }); + } else { + last.lines.push(line); + } + } + + return segments + .map(({ code, lines }) => { + const text = lines.join('\n'); + return code + ? text + : text + .trim() + .replaceAll(/ +/g, ' ') // Squash consecutive spaces + .replaceAll(' \n', '\n') // Squash space followed by newline + .replaceAll('\n\n', '
'); // Convert double return to a break + }) + .filter((text) => text !== '') + .join('\n\n'); +} + export default function updateDescription( sectionToken: ReadmeSection, inputs: Inputs, @@ -21,12 +77,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); From 13fa01d9a92ef1768aedfede1c364bbdb071e4a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:19:28 +0000 Subject: [PATCH 2/4] fix(sections): read a line opening with inline code as prose A backtick fence's info string cannot hold a backtick, so a description line such as ```code``` is prose opens an inline code span, not a fence. It was read as an unclosed fence, which left the rest of the description unconverted. The description updater and the contract verifier both reject such an opener now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H37LVnd9qB6PmZ5bJi5H2t --- __tests__/update-description.test.ts | 8 ++++++++ scripts/verify-readme-contract.mjs | 2 +- src/sections/update-description.ts | 8 ++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/__tests__/update-description.test.ts b/__tests__/update-description.test.ts index 87b33b23..6213b5cf 100644 --- a/__tests__/update-description.test.ts +++ b/__tests__/update-description.test.ts @@ -50,6 +50,14 @@ describe('descriptionMarkdown', () => { 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('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 ab5d8354..ef200056 100644 --- a/scripts/verify-readme-contract.mjs +++ b/scripts/verify-readme-contract.mjs @@ -819,7 +819,7 @@ const descriptionMarkdown = (value) => { prose = []; }; for (const line of String(value).trim().replaceAll('\r\n', '\n').split('\n')) { - const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]; + const marker = /^ {0,3}(`{3,}(?!.*`)|~{3,})/.exec(line)?.[1]; if (fence === null && marker) { flushProse(); fence = { marker, lines: [line] }; diff --git a/src/sections/update-description.ts b/src/sections/update-description.ts index 8bceb343..3f90670d 100644 --- a/src/sections/update-description.ts +++ b/src/sections/update-description.ts @@ -9,8 +9,12 @@ import type { ReadmeSection } from '../constants.js'; import type Inputs from '../inputs.js'; import LogTask from '../logtask/index.js'; -/** A fence opener or closer: three or more backticks or tildes. */ -const FENCE = /^ {0,3}(`{3,}|~{3,})/; +/** + * A fence opener or closer: three or more backticks or tildes. A backtick + * fence's info string cannot hold a backtick, so such a line is an inline + * code span, not a fence. + */ +const FENCE = /^ {0,3}(`{3,}(?!.*`)|~{3,})/; /** * Converts an action.yml description to the Markdown of the description From 7ca78eb7553348f800cfcbc4f55c56f784fb4f0e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:34:40 +0000 Subject: [PATCH 3/4] fix(sections): find description fences by parsing the description A line-based fence scan only saw fences with up to three spaces of indentation, so a fence inside a list item took the prose path. Fenced code blocks are now found with the markdown parser prettier already bundles, in the description updater and in the contract verifier. Indented code blocks stay on the prose path, as before. The comments now state the rule and link #705 instead of describing the old failure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H37LVnd9qB6PmZ5bJi5H2t --- __tests__/update-description.test.ts | 17 +++++- scripts/verify-readme-contract.mjs | 66 +++++++++++++----------- src/sections/update-description.ts | 77 +++++++++++++++++----------- 3 files changed, 97 insertions(+), 63 deletions(-) diff --git a/__tests__/update-description.test.ts b/__tests__/update-description.test.ts index 6213b5cf..7af7fbd2 100644 --- a/__tests__/update-description.test.ts +++ b/__tests__/update-description.test.ts @@ -16,8 +16,7 @@ describe('descriptionMarkdown', () => { expect(descriptionMarkdown('A\r\n\r\nB')).toBe('A
B'); }); - // #705: flattening the blank line before a fence moved its opening fence - // mid-line, and the closing fence then opened a fence nothing closed. + // #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.', @@ -58,6 +57,20 @@ describe('descriptionMarkdown', () => { ); }); + 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('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 ef200056..0e1b4a21 100644 --- a/scripts/verify-readme-contract.mjs +++ b/scripts/verify-readme-contract.mjs @@ -801,41 +801,45 @@ const expectedTitle = action.name ? `# ${titleImage}${titlePrefix}${action.name} 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 is - * kept verbatim, separated from the prose by blank lines. + * 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 blocks = []; - let prose = []; - let fence = null; - const flushProse = () => { - const text = prose - .join('\n') - .trim() - .replaceAll(/ +/g, ' ') - .replaceAll(' \n', '\n') - .replaceAll('\n\n', '
'); - if (text) blocks.push(text); - prose = []; - }; - for (const line of String(value).trim().replaceAll('\r\n', '\n').split('\n')) { - const marker = /^ {0,3}(`{3,}(?!.*`)|~{3,})/.exec(line)?.[1]; - if (fence === null && marker) { - flushProse(); - fence = { marker, lines: [line] }; - } else if (fence !== null) { - fence.lines.push(line); - if (marker && marker[0] === fence.marker[0] && marker.length >= fence.marker.length && line.trim() === marker) { - blocks.push(fence.lines.join('\n')); - fence = null; + 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).trimStart())) { + fences.push([lineOf(start.offset), lineOf(end.offset)]); } - } else { - prose.push(line); } - } - if (fence !== null) blocks.push(fence.lines.join('\n')); - flushProse(); - return blocks.join('\n\n'); + (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(descriptionMarkdown(action.description)) diff --git a/src/sections/update-description.ts b/src/sections/update-description.ts index 3f90670d..54d07df5 100644 --- a/src/sections/update-description.ts +++ b/src/sections/update-description.ts @@ -7,65 +7,82 @@ */ 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[]; +} + /** - * A fence opener or closer: three or more backticks or tildes. A backtick - * fence's info string cannot hold a backtick, so such a line is an inline - * code span, not a fence. + * 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. */ -const FENCE = /^ {0,3}(`{3,}(?!.*`)|~{3,})/; +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) { + const source = text.slice(position.start.offset, position.end.offset).trimStart(); + 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 - * is kept verbatim on lines of its own: flattening the blank line before it - * would move its opening fence mid-line, leaving the closing fence to open a - * fence that nothing closes. + * 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[] }[] = []; - let fence: string | undefined; - for (const line of description.trim().replaceAll('\r\n', '\n').split('\n')) { - const marker = FENCE.exec(line)?.[1]; - if (fence === undefined && marker !== undefined) { - fence = marker; - segments.push({ code: true, lines: [line] }); - continue; - } + 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 (fence !== undefined && last !== undefined) { + if (last?.code === code) { last.lines.push(line); - const closes = - marker?.charAt(0) === fence.charAt(0) && - marker.length >= fence.length && - line.trim() === marker; - if (closes) { - fence = undefined; - } - } else if (last === undefined || last.code) { - segments.push({ code: false, lines: [line] }); } else { - last.lines.push(line); + segments.push({ code, lines: [line] }); } } return segments .map(({ code, lines }) => { - const text = lines.join('\n'); + const block = lines.join('\n'); return code - ? text - : text + ? 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((text) => text !== '') + .filter((block) => block !== '') .join('\n\n'); } From b2a61c6a62b004f29e9536c7eb50ab0f048d6fe5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:43:13 +0000 Subject: [PATCH 4/4] fix(sections): tell indented code from fenced blocks in descriptions The fence check trimmed a code node's leading whitespace, so an indented code block whose first line starts with backticks was kept verbatim as a fence. A fenced block's node starts at its fence and an indented block's node starts at its indentation, so the check now reads the untrimmed start, in the description updater and in the contract verifier. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H37LVnd9qB6PmZ5bJi5H2t --- __tests__/update-description.test.ts | 6 ++++++ scripts/verify-readme-contract.mjs | 2 +- src/sections/update-description.ts | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/__tests__/update-description.test.ts b/__tests__/update-description.test.ts index 7af7fbd2..0f6cadd3 100644 --- a/__tests__/update-description.test.ts +++ b/__tests__/update-description.test.ts @@ -71,6 +71,12 @@ describe('descriptionMarkdown', () => { 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 0e1b4a21..1b8bb6f6 100644 --- a/scripts/verify-readme-contract.mjs +++ b/scripts/verify-readme-contract.mjs @@ -812,7 +812,7 @@ const descriptionMarkdown = (value) => { const walk = (node) => { if (node.type === 'code' && node.position) { const { start, end } = node.position; - if (/^(```|~~~)/.test(text.slice(start.offset, end.offset).trimStart())) { + if (/^(```|~~~)/.test(text.slice(start.offset, end.offset))) { fences.push([lineOf(start.offset), lineOf(end.offset)]); } } diff --git a/src/sections/update-description.ts b/src/sections/update-description.ts index 54d07df5..c77db10b 100644 --- a/src/sections/update-description.ts +++ b/src/sections/update-description.ts @@ -34,7 +34,9 @@ function fencedLines(text: string): [number, number][] { const walk = (node: MarkdownNode): void => { const { position } = node; if (node.type === 'code' && position) { - const source = text.slice(position.start.offset, position.end.offset).trimStart(); + // 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)]); }