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);