Skip to content

Commit ef74db0

Browse files
committed
refactor(scripts): render the inputs table as a generated region
The inputs table on docs/reference/inputs.md was rendered by the action-docs package from action.yml between action-docs's own markers, after a hand-written guard checked that the region held only a rendering. It is now one more GeneratedRegion: the data is INPUT_DECLS, the same source action.yml's inputs region is generated from; the render is a four-cell table through renderTable; the parse reads each row back through tableBody; and the round-trip seal refuses a moved marker, an authored row, or a description holding "<" naming the line. Every table line is byte-identical to the previous rendering, the paragraph wrapper and the empty-default spelling included. Only the marker lines changed, and the "## Inputs" heading moved above the BEGIN marker so the region sits under it like every other table region. action-docs leaves the devDependencies: the region needs a synchronous render and a parse, which the async, file-writing package cannot give.
1 parent a65fcda commit ef74db0

8 files changed

Lines changed: 146 additions & 220 deletions

File tree

Lines changed: 79 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,101 @@
1-
/** Renders the inputs table on docs/reference/inputs.md with action-docs, after refusing a marker region holding anything
2-
* but the lines of a rendering in the renderer's order: action-docs rewrites the region blind, and auto-fix.yml pushes
3-
* regenerations unreviewed, so an authored line that merely looks like a table row would be erased and committed. */
1+
/**
2+
* The inputs table of docs/reference/inputs.md: one row per input declaration, rendered as a generated region so a
3+
* marker moved over authored text is refused instead of regenerated over. The page's outputs list is gen-docs.ts's
4+
* region, so two generators write into the page.
5+
*/
46

5-
import { readFileSync } from "node:fs";
67
import { join } from "node:path";
7-
import { generateActionMarkdownDocs } from "action-docs";
8+
import { err, ok, type Result } from "neverthrow";
9+
import { INPUT_DECLS, type InputDecl } from "../../src/flows/inputs.js";
10+
import { renderTable, tableBody, tableFault } from "../../src/report/markdown.js";
11+
import { block, GeneratedRegion } from "./lib/generated-regions.js";
12+
import { type GeneratedFiles, regenerateFiles } from "./lib/region-driver.js";
813

914
const ROOT = join(import.meta.dir, "..", "..");
1015

11-
/** The page action-docs renders the inputs table into, from the action.yml build:action-docs writes just before. */
1216
export const INPUTS_PAGE_PATH = "docs/reference/inputs.md";
1317

14-
export const MARKER = '<!-- action-docs-inputs source="action.yml" -->';
18+
const INPUTS_TABLE_HEADER =
19+
"| name | description | required | default |\n| --- | --- | --- | --- |";
1520

16-
/** The header row action-docs emits; test/docs/inputs.test.ts pins the same cells on the committed page. */
17-
const HEADER_CELLS = ["name", "description", "required", "default"];
21+
/** The declarations the table renders from, by input name. */
22+
type Inputs = Readonly<Record<string, Pick<InputDecl, "description" | "default">>>;
1823

19-
/** The trimmed cells of a table line `| a | b |`, or null for any other line. One split per line and no regex: the
20-
* earlier four-cell regex backtracked exponentially on a long row of repeated cells. */
21-
const cells = (line: string): string[] | null =>
22-
line.startsWith("| ") && line.endsWith(" |")
23-
? line
24-
.slice(1, -1)
25-
.split("|")
26-
.map((cell) => cell.trim())
27-
: null;
28-
29-
type CellCheck = (cell: string) => boolean;
24+
/** Why a description cannot sit in its cell: the cell is HTML, so a tag would be swallowed by the page's renderer. */
25+
function descriptionFault(text: string): string | undefined {
26+
return text.includes("<") ? 'holds a "<", which the cell would read as an HTML tag' : undefined;
27+
}
3028

31-
const lineOf =
32-
(checks: CellCheck[]) =>
33-
(line: string): boolean => {
34-
const found = cells(line);
35-
return (
36-
found !== null &&
37-
found.length === checks.length &&
38-
checks.every((ok, i) => ok(found[i] ?? ""))
39-
);
40-
};
29+
/** The default as the table shows it: in a code span, the empty string as `""`. */
30+
function defaultSpan(value: string): string {
31+
return `\`${value === "" ? '""' : value}\``;
32+
}
4133

42-
const backticked: CellCheck = (cell) =>
43-
cell.length > 2 && cell.startsWith("`") && cell.endsWith("`");
34+
function inputsCells(inputs: Inputs): ReadonlyArray<readonly string[]> {
35+
return Object.entries(inputs).map(([name, decl]) => {
36+
const fault = descriptionFault(decl.description);
37+
if (fault !== undefined) {
38+
throw new Error(`gen-inputs-table: the "${name}" description ${fault}`);
39+
}
40+
return [`\`${name}\``, `<p>${decl.description}</p>`, "`false`", defaultSpan(decl.default)];
41+
});
42+
}
4443

45-
/** A row action-docs renders: | `name` | <p>description</p> | `false` | `default` |. An authored four-cell row
46-
* with bare cells is refused on these, since a cell count alone let one through. */
47-
const row = lineOf([
48-
backticked,
49-
(cell) => cell.startsWith("<p>") && cell.endsWith("</p>"),
50-
(cell) => cell === "`true`" || cell === "`false`",
51-
backticked,
52-
]);
44+
/** The table rule is the author's to meet, so a description that would break its row stops the build naming it. */
45+
export function renderInputsTable(inputs: Inputs): string {
46+
const cells = inputsCells(inputs);
47+
const fault = tableFault(INPUTS_TABLE_HEADER, cells, "cells");
48+
if (fault !== undefined) {
49+
throw new Error(`gen-inputs-table: ${fault}`);
50+
}
51+
return renderTable(INPUTS_TABLE_HEADER, cells);
52+
}
5353

54-
/** The first entry is the rest of the opening marker's own line. A "|" in a description would split its rendered row
55-
* past `row`'s four cells and refuse it as authored, so test/docs/inputs.test.ts pins every description free of one. */
56-
const LEADING: ((line: string) => boolean)[] = [
57-
(line) => line === "",
58-
(line) => line === "## Inputs",
59-
(line) => line === "",
60-
lineOf(HEADER_CELLS.map((name) => (cell: string) => cell === name)),
61-
lineOf(HEADER_CELLS.map(() => (cell: string) => cell === "---")),
62-
];
54+
const NAME_SPAN = /^`([a-z][a-z0-9-]*)`$/;
55+
// The dotAll flag is for the two Unicode line separators cellFault() admits inside a cell.
56+
const DESCRIPTION_CELL = /^<p>(.*)<\/p>$/s;
57+
const DEFAULT_SPAN = /^`(.*)`$/s;
6358

64-
/** Why `page` cannot be handed to action-docs, or null when its region holds only a rendering's lines in order. */
65-
export function regionProblem(page: string): string | null {
66-
const parts = page.split(MARKER);
67-
if (parts.length !== 3) {
68-
return `${INPUTS_PAGE_PATH} must carry exactly two "${MARKER}" markers, found ${parts.length - 1}`;
59+
/** A row read back to the declaration it renders from; every input is optional, so the required cell is fixed. */
60+
function inputRow(cells: readonly string[]): Result<readonly [string, Inputs[string]], string> {
61+
const name = NAME_SPAN.exec(cells[0] ?? "")?.[1];
62+
const description = DESCRIPTION_CELL.exec(cells[1] ?? "")?.[1];
63+
const value = DEFAULT_SPAN.exec(cells[3] ?? "")?.[1];
64+
if (name === undefined) {
65+
return err("does not open with an input name in a code span");
6966
}
70-
const [intro, region] = parts as [string, string, string];
71-
const openingLine = intro.split("\n").length;
72-
const lines = region.split("\n");
73-
const last = lines.length - 1;
74-
if (last === 0) {
75-
// action-docs then replaces the opening marker alone and leaves three on the page.
76-
return `the closing "${MARKER}" marker on line ${openingLine} of ${INPUTS_PAGE_PATH} must start its own line`;
67+
if (description === undefined) {
68+
return err("has no paragraph-wrapped description in its second cell");
7769
}
78-
if (lines.every((line) => line === "")) {
79-
return null;
70+
const fault = descriptionFault(description);
71+
if (fault !== undefined) {
72+
return err(`${fault}, which the renderer refuses`);
8073
}
81-
const authored = lines.findIndex((line, index) => {
82-
const leading = LEADING[index];
83-
if (leading !== undefined) {
84-
return !leading(line);
85-
}
86-
return index === last ? line !== "" : !row(line);
87-
});
88-
if (authored !== -1) {
89-
return (
90-
`line ${openingLine + authored} of ${INPUTS_PAGE_PATH} sits between the two "${MARKER}" markers ` +
91-
"but is not a line of a rendered inputs table; move the markers back around the table before " +
92-
"regenerating, or action-docs erases what sits between them"
93-
);
74+
if (cells[2] !== "`false`") {
75+
return err("does not show `false` in its required cell");
9476
}
95-
return null;
77+
if (value === undefined) {
78+
return err("has no default in a code span in its fourth cell");
79+
}
80+
return ok([name, { description, default: value === '""' ? "" : value }]);
9681
}
9782

83+
const parseInputsTable = tableBody(INPUTS_TABLE_HEADER, "cells", inputRow);
84+
85+
export const INPUTS_PAGES: GeneratedFiles = {
86+
[INPUTS_PAGE_PATH]: {
87+
regions: [
88+
GeneratedRegion.of<Inputs>({
89+
name: "inputs-table",
90+
placement: { kind: "under-heading", heading: "## Inputs" },
91+
data: () => INPUT_DECLS,
92+
render: block(renderInputsTable),
93+
parse: (body) => parseInputsTable(body).map((rows) => Object.fromEntries(rows)),
94+
}),
95+
],
96+
},
97+
};
98+
9899
if (import.meta.main) {
99-
// action-docs resolves both files from the working directory and matches the marker on the literal source name.
100-
process.chdir(ROOT);
101-
const problem = regionProblem(readFileSync(INPUTS_PAGE_PATH, "utf8"));
102-
if (problem !== null) {
103-
console.error(`gen-inputs-table: ${problem}`);
104-
process.exit(1);
105-
}
106-
await generateActionMarkdownDocs({
107-
sourceFile: "action.yml",
108-
updateReadme: true,
109-
readmeFile: INPUTS_PAGE_PATH,
110-
});
111-
console.log(`gen-inputs-table: rendered ${INPUTS_PAGE_PATH} from action.yml`);
100+
regenerateFiles("gen-inputs-table", INPUTS_PAGES, ROOT);
112101
}

‎.github/scripts/generated.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export interface GeneratedOutput {
1616
readonly path: string;
1717
/** The package.json script that writes it; `bun run <generator>` regenerates it in place. */
1818
readonly generator: string;
19-
/** Marker-delimited regions inside an authored file (lib/generated-regions.ts, or action-docs's own markers), or the whole file. */
19+
/** Marker-delimited regions inside an authored file (lib/generated-regions.ts), or the whole file. */
2020
readonly kind: "regions" | "file";
2121
}
2222

@@ -26,9 +26,8 @@ function regions(generator: string, paths: readonly string[]): GeneratedOutput[]
2626

2727
/**
2828
* A page two generators write into (docs/reference/inputs.md) has one row per generator. Rows stay in dependency
29-
* order, each artifact before its readers, or a new gap file or a bump would leave a run stale.
29+
* order, each artifact before its readers, or a new gap file would leave a run stale.
3030
* the gaps index -> imported through src/ by the docs and action.yml generators
31-
* action.yml -> feeds the inputs table
3231
*/
3332
export const GENERATED_OUTPUTS: readonly GeneratedOutput[] = [
3433
{ path: INDEX_PATH, generator: "build:gaps-index", kind: "file" },

0 commit comments

Comments
 (0)