|
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 | + */ |
4 | 6 |
|
5 | | -import { readFileSync } from "node:fs"; |
6 | 7 | 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"; |
8 | 13 |
|
9 | 14 | const ROOT = join(import.meta.dir, "..", ".."); |
10 | 15 |
|
11 | | -/** The page action-docs renders the inputs table into, from the action.yml build:action-docs writes just before. */ |
12 | 16 | export const INPUTS_PAGE_PATH = "docs/reference/inputs.md"; |
13 | 17 |
|
14 | | -export const MARKER = '<!-- action-docs-inputs source="action.yml" -->'; |
| 18 | +const INPUTS_TABLE_HEADER = |
| 19 | + "| name | description | required | default |\n| --- | --- | --- | --- |"; |
15 | 20 |
|
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">>>; |
18 | 23 |
|
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 | +} |
30 | 28 |
|
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 | +} |
41 | 33 |
|
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 | +} |
44 | 43 |
|
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 | +} |
53 | 53 |
|
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; |
63 | 58 |
|
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"); |
69 | 66 | } |
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"); |
77 | 69 | } |
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`); |
80 | 73 | } |
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"); |
94 | 76 | } |
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 }]); |
96 | 81 | } |
97 | 82 |
|
| 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 | + |
98 | 99 | 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); |
112 | 101 | } |
0 commit comments