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.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.
Expand Down
83 changes: 83 additions & 0 deletions __tests__/update-description.test.ts
Original file line number Diff line number Diff line change
@@ -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<br />New paragraph',
);
});

it('reads CRLF line endings as LF', () => {
expect(descriptionMarkdown('A\r\n\r\nB')).toBe('A<br />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<br />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<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('leaves backticks that do not start a line as prose', () => {
expect(descriptionMarkdown('Use ``` inline')).toBe('Use ``` inline');
});
});
46 changes: 43 additions & 3 deletions scripts/verify-readme-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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.
*/
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 expectedDescription = action.description
? await generatedMarkdown(
String(action.description).trim().replaceAll('\r\n', '\n').replaceAll(/ +/g, ' ').replaceAll(' \n', '\n').replaceAll('\n\n', '<br />'),
)
? await generatedMarkdown(descriptionMarkdown(action.description))
: null;

for (const name of ['title', 'description', 'branding']) {
Expand Down
86 changes: 80 additions & 6 deletions src/sections/update-description.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('~~~')) {
Comment thread
Jamie-BitFlight marked this conversation as resolved.
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);
Comment thread
Jamie-BitFlight marked this conversation as resolved.
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.
* @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');
}

export default function updateDescription(
sectionToken: ReadmeSection,
inputs: Inputs,
Expand All @@ -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', '<br />'); // 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);
Expand Down
Loading