Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ghadocs/branding.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ value replaced by `***REDACTED***`. Keys whose names look sensitive (`auth`,
<!-- start usage -->

```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.
Expand Down
100 changes: 49 additions & 51 deletions __tests__/update-description.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<br />New paragraph',
'One line \nnext\n\nNew paragraph',
);
});

it('reads CRLF line endings as LF', () => {
expect(descriptionMarkdown('A\r\n\r\nB')).toBe('A<br />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<br />next para',
);
});
describe('the formatted description section', () => {
const section = async (description: string): Promise<string> =>
(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<br /> 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<br /> ```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);
});
});
12 changes: 12 additions & 0 deletions __tests__/verify-readme-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br />- one\n- two'), action)).toThrow();
});

it('validates generated usage descriptions and removed defaults exactly', () => {
expect(() =>
verify(README.replace(' # Description: A \\| B', ' # Description: stale')),
Expand Down
51 changes: 7 additions & 44 deletions scripts/verify-readme-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<br />`, 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 `<img>` tag with no free parts: `updateBranding` fixes
* the width at `15%` and the alignment at `center`, and only the `src` comes
Expand All @@ -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 `<br />`, 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', '<br />'),
)
.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;
Expand Down
72 changes: 4 additions & 68 deletions src/sections/update-description.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<br />`. 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', '<br />'); // Convert double return to a break
})
.filter((block) => block !== '')
.join('\n\n');
return description.trim().replaceAll('\r\n', '\n');
}

export default function updateDescription(
Expand Down
Loading