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(