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__/markers.test.ts b/__tests__/markers.test.ts
index eb1dbd3e..2be96667 100644
--- a/__tests__/markers.test.ts
+++ b/__tests__/markers.test.ts
@@ -5,7 +5,7 @@
*/
import { describe, expect, it } from 'vite-plus/test';
-import { locateSection } from '../src/markers.js';
+import { diagnoseMarkers, locateSection } from '../src/markers.js';
/** The body `locateSection` finds, or its reason for finding none. */
const body = (source: string, name = 'inputs'): string => {
@@ -146,3 +146,130 @@ describe('locateSection', () => {
expect(body(source.replaceAll(lookalike, name), name)).toBe('\ny');
});
});
+
+describe('diagnoseMarkers', () => {
+ const sections = ['title', 'inputs', 'outputs'];
+
+ it.each([
+ ['a missing letter', 'input', 'inputs'],
+ ['a different case', 'Inputs', 'inputs'],
+ ['a transposition', 'otuputs', 'outputs'],
+ ])('suggests the section for a name with %s', (_label, name, section) => {
+ const source = `\n\n\n\n`;
+
+ expect(diagnoseMarkers(source, sections)).toStrictEqual([
+ `The marker on line 4 names no section. Did you mean '${section}'?`,
+ ]);
+ });
+
+ // README.example.md carries a `[.github/ghadocs/examples/]` marker that
+ // nothing fills, and other tools use the same comment syntax.
+ it('ignores a marker name that is not close to any section', () => {
+ const source = [
+ '',
+ '',
+ '',
+ '',
+ ].join('\n');
+
+ expect(diagnoseMarkers(source, sections)).toStrictEqual([]);
+ });
+
+ // A single pair counts wherever it sits, as in `locateSection`: generated
+ // text can hold an unclosed fence, which would otherwise hide the pair.
+ it('reports a single mistyped pair after an unclosed fence', () => {
+ const source = [
+ '',
+ '```',
+ '',
+ '',
+ '',
+ ].join('\n');
+
+ expect(diagnoseMarkers(source, sections)).toStrictEqual([
+ "The marker on line 4 names no section. Did you mean 'inputs'?",
+ "The marker on line 5 names no section. Did you mean 'inputs'?",
+ ]);
+ });
+
+ // One mistyped side splits the pair across two names; the pair rule has to
+ // see both sides to know the pair counts.
+ it('reports a pair with one mistyped side after an unclosed fence', () => {
+ const source = [
+ '',
+ '```',
+ '',
+ '',
+ '',
+ ].join('\n');
+
+ expect(diagnoseMarkers(source, sections)).toStrictEqual([
+ "The marker on line 4 names no section. Did you mean 'inputs'?",
+ ]);
+ });
+
+ it('ignores a mistyped example inside code next to a real pair', () => {
+ const source = [
+ '```',
+ '',
+ '```',
+ '',
+ '',
+ ].join('\n');
+
+ expect(diagnoseMarkers(source, sections)).toStrictEqual([]);
+ });
+
+ // A real start marker and a mistyped example end marker in closed code are
+ // not a pair.
+ it('ignores a mistyped example in closed code after a real start marker', () => {
+ const source = ['', '```', '', '```'].join('\n');
+
+ expect(diagnoseMarkers(source, sections)).toStrictEqual([]);
+ });
+
+ it.each([
+ ['inline code opened by triple backticks', 'Use ```x ``` here.'],
+ ['an indented code block opening with backticks', 'para\n\n ```\n '],
+ ])('ignores a mistyped marker in %s', (_label, example) => {
+ const source = `\n\n\n${example}\n`;
+
+ expect(diagnoseMarkers(source, sections)).toStrictEqual([]);
+ });
+
+ it('ignores a mistyped marker quoted in inline code', () => {
+ const source =
+ '\n\n\nUse `x ` here.\n';
+
+ expect(diagnoseMarkers(source, sections)).toStrictEqual([]);
+ });
+
+ it('ignores a mistyped marker inside code', () => {
+ const source = '\n\n\n```\n\n```\n';
+
+ expect(diagnoseMarkers(source, sections)).toStrictEqual([]);
+ });
+
+ it('reports a README with no section markers once', () => {
+ const [warning, ...rest] = diagnoseMarkers('# README\n', sections);
+
+ expect(warning).toContain('The README has no markers for the sections being generated');
+ expect(rest).toStrictEqual([]);
+ });
+
+ // `--sections=inputs` against a README with only a title pair generates
+ // nothing, so the warning is about the requested sections.
+ it('reports missing markers for the requested sections only', () => {
+ const source = '\n\n';
+
+ expect(diagnoseMarkers(source, sections, ['inputs'])).toStrictEqual([
+ 'The README has no markers for the sections being generated (inputs), so nothing was generated. Add a pair such as and where each section belongs; README.example.md shows every section.',
+ ]);
+ expect(diagnoseMarkers(source, sections, ['title'])).toStrictEqual([]);
+ expect(diagnoseMarkers(source, sections, [])).toStrictEqual([]);
+ });
+
+ it('does not report a README whose only section marker is unpaired as having none', () => {
+ expect(diagnoseMarkers('\n', sections)).toStrictEqual([]);
+ });
+});
diff --git a/__tests__/readme-generator.test.ts b/__tests__/readme-generator.test.ts
index 9cd84824..89f066f8 100644
--- a/__tests__/readme-generator.test.ts
+++ b/__tests__/readme-generator.test.ts
@@ -33,6 +33,9 @@ describe('ReadmeGenerator', () => {
mockLogTask = new LogTask('mock');
mockInputs = new Inputs({}, mockLogTask);
mockInputs.readmeEditor = new ReadmeEditor('./README.md');
+ vi.mocked(mockInputs.readmeEditor.getReadmeContent).mockReturnValue(
+ '\n\n',
+ );
// The auto-mocked Inputs has no config; the real constructor always builds
// one, and generate() reads the `prettier` flag off it.
mockInputs.config = { get: vi.fn().mockReturnValue(undefined) } as unknown as Inputs['config'];
@@ -128,6 +131,31 @@ describe('ReadmeGenerator', () => {
expect(readmeGenerator.outputSections).toHaveBeenCalledWith(combinedSections);
});
+ // #641 and #644: marker problems are reported before any section is
+ // written, where a successful run would otherwise hide them.
+ it('warns about a README with no section markers', async () => {
+ vi.mocked(mockInputs.readmeEditor.getReadmeContent).mockReturnValue('# README\n');
+ readmeGenerator.updateSections = vi.fn().mockReturnValue([]);
+ readmeGenerator.resolveUpdates = vi.fn().mockResolvedValue({});
+ readmeGenerator.outputSections = vi.fn();
+
+ await readmeGenerator.generate();
+
+ expect(mockLogTask.warn).toHaveBeenCalledWith(
+ expect.stringContaining('The README has no markers for the sections being generated'),
+ );
+ });
+
+ it('warns nothing for a README whose markers name sections', async () => {
+ readmeGenerator.updateSections = vi.fn().mockReturnValue([]);
+ readmeGenerator.resolveUpdates = vi.fn().mockResolvedValue({});
+ readmeGenerator.outputSections = vi.fn();
+
+ await readmeGenerator.generate();
+
+ expect(mockLogTask.warn).not.toHaveBeenCalled();
+ });
+
it.each([
['unset', undefined, true],
['true', true, true],
diff --git a/src/markers.ts b/src/markers.ts
index 6b56f3c2..45545a74 100644
--- a/src/markers.ts
+++ b/src/markers.ts
@@ -68,17 +68,23 @@ function escapeRegExp(text: string): string {
* Parsed with the markdown parser prettier already bundles, so containers,
* HTML blocks and indentation follow Markdown's rules rather than a regex.
* @param {string} source - The document.
- * @returns {Array<[number, number]>} - The code ranges.
+ * @returns {Array<[number, number, boolean]>} - The code ranges, each with
+ * whether it is a fenced code block.
*/
-function codeRanges(source: string): [number, number][] {
+function codeRanges(source: string): [number, number, boolean][] {
// The parser drops a leading byte order mark, which shifts its offsets.
const shift = source.startsWith('') ? 1 : 0;
const parser = markdown.parsers.markdown;
const root = parser.parse(source.slice(shift), {} as never) as MarkdownNode;
- const ranges: [number, number][] = [];
+ const ranges: [number, number, boolean][] = [];
const walk = (node: MarkdownNode): void => {
if ((node.type === 'code' || node.type === 'inlineCode') && node.position) {
- ranges.push([node.position.start.offset + shift, node.position.end.offset + shift]);
+ const from = node.position.start.offset + shift;
+ const to = node.position.end.offset + shift;
+ // A fenced block's node starts at its fence; an indented block's node
+ // starts at its indentation, and inline code is its own kind.
+ const fenced = node.type === 'code' && /^(`{3}|~{3})/.test(source.slice(from, to));
+ ranges.push([from, to, fenced]);
}
for (const child of node.children ?? []) {
walk(child);
@@ -161,3 +167,110 @@ export function locateSection(source: string, name: string): SectionSpan {
const indent = source.slice(from, end.index).match(/\n[\t ]*$/);
return { found: true, start: from, end: indent ? end.index - indent[0].length : end.index };
}
+
+/**
+ * The number of single-character edits between two strings.
+ * @param {string} a - One string.
+ * @param {string} b - The other string.
+ * @returns {number} - The Levenshtein distance.
+ */
+function editDistance(a: string, b: string): number {
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
+ for (let i = 1; i <= a.length; i++) {
+ const current = [i];
+ for (let j = 1; j <= b.length; j++) {
+ const substitution = (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1);
+ current.push(Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, substitution));
+ }
+ previous = current;
+ }
+ return previous[b.length] ?? 0;
+}
+
+/**
+ * The section a mistyped marker name most likely meant: one within two edits,
+ * ignoring case. A name further from every section is some other tool's
+ * marker, or one this tool does not fill, and is not the user's mistake.
+ * @param {string} name - A marker name that is not a section.
+ * @param {readonly string[]} sections - The section names.
+ * @returns {string | undefined} - The closest section, if one is close.
+ */
+function closestSection(name: string, sections: readonly string[]): string | undefined {
+ let best: { section: string; distance: number } | undefined;
+ for (const section of sections) {
+ const distance = editDistance(name.toLowerCase(), section);
+ if (distance <= 2 && (best === undefined || distance < best.distance)) {
+ best = { section, distance };
+ }
+ }
+ return best?.section;
+}
+
+/**
+ * Whether a fenced code block is one that nothing closes. It runs to the end of
+ * the document, so the markers inside it are not examples: generated text can
+ * hold such a fence, and it would otherwise hide every marker after it.
+ * @param {string} code - The source text of a fenced code block.
+ * @returns {boolean} - Whether it is an unclosed fence.
+ */
+function isUnclosedFence(code: string): boolean {
+ const lines = code.split('\n');
+ // A fenced block's node starts at its fence, so the opener is always there.
+ const opener = /^(`{3,}|~{3,})/.exec(lines[0] ?? '')?.[1] ?? '```';
+ const closer = (lines.at(-1) ?? '').replace(/^[\t >]*/, '').trimEnd();
+ const closes =
+ lines.length > 1 &&
+ closer.length >= opener.length &&
+ closer === opener.charAt(0).repeat(closer.length);
+ return !closes;
+}
+
+/**
+ * Warnings about markers that stop a README from being generated as its
+ * author intended, found before any section is written:
+ *
+ * - a marker whose name is a near miss of a section name, which the tool
+ * otherwise skips in silence;
+ * - a README with no marker for any section being generated, which the tool
+ * otherwise leaves unchanged in silence.
+ *
+ * A marker inside closed code is an example and is not reported. A marker
+ * inside a fence that nothing closes is reported, as `locateSection` would
+ * still find it.
+ * @param {string} source - The document.
+ * @param {readonly string[]} sections - Every section name the tool knows.
+ * @param {readonly string[]} requested - The sections being generated.
+ * @returns {string[]} - One message per problem.
+ */
+export function diagnoseMarkers(
+ source: string,
+ sections: readonly string[],
+ requested: readonly string[] = sections,
+): string[] {
+ const examples = codeRanges(source).filter(
+ ([from, to, fenced]) => !fenced || !isUnclosedFence(source.slice(from, to)),
+ );
+ const warnings: string[] = [];
+ for (const match of source.matchAll(/(?/g)) {
+ const name = match[2] ?? '';
+ const section = sections.includes(name) ? undefined : closestSection(name, sections);
+ const example = examples.some(([from, to]) => match.index >= from && match.index < to);
+ if (section !== undefined && !example) {
+ const [line] = linesOf(source, [match.index]);
+ warnings.push(
+ `The marker ${match[0]} on line ${line} names no section. Did you mean '${section}'?`,
+ );
+ }
+ }
+
+ const missing = (name: string): boolean => {
+ const span = locateSection(source, name);
+ return !span.found && span.reason === 'missing';
+ };
+ if (requested.length > 0 && requested.every(missing)) {
+ warnings.push(
+ `The README has no markers for the sections being generated (${requested.join(', ')}), so nothing was generated. Add a pair such as and where each section belongs; README.example.md shows every section.`,
+ );
+ }
+ return warnings;
+}
diff --git a/src/readme-generator.ts b/src/readme-generator.ts
index 11b851a7..80db7750 100644
--- a/src/readme-generator.ts
+++ b/src/readme-generator.ts
@@ -8,10 +8,11 @@
import * as core from '@actions/core';
-import type { ReadmeSection } from './constants.js';
+import { README_SECTIONS, type ReadmeSection } from './constants.js';
import { isPrettierEnabled } from './helpers.js';
import type Inputs from './inputs.js';
import type LogTask from './logtask/index.js';
+import { diagnoseMarkers } from './markers.js';
import updateSection from './sections/index.js';
export type SectionKV = Record;
@@ -94,6 +95,13 @@ export class ReadmeGenerator {
* @returns Promise resolving when done
*/
async generate(providedSections: ReadmeSection[] = this.inputs.sections): Promise {
+ for (const warning of diagnoseMarkers(
+ this.inputs.readmeEditor.getReadmeContent(),
+ README_SECTIONS,
+ providedSections,
+ )) {
+ this.log.warn(warning);
+ }
const sectionPromises = this.updateSections(providedSections);
const sections = await this.resolveUpdates(sectionPromises);