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
169 changes: 72 additions & 97 deletions .github/scripts/gen-action-docs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { err, ok, Result } from "neverthrow";
import {
Expand Down Expand Up @@ -34,13 +33,8 @@ import { SECTIONS } from "../../src/sections/registry.js";
import { agree } from "../../src/text.js";
import type { UndeclaredPolicy } from "../../src/types.js";
import { COUNT_WORD_MAX, countWord } from "./lib/count-word.js";
import {
block,
blockLine,
blockLines,
GeneratedRegion,
regenerateRegions,
} from "./lib/generated-regions.js";
import { block, blockLine, blockLines, GeneratedRegion } from "./lib/generated-regions.js";
import { type GeneratedFiles, regenerateFiles } from "./lib/region-driver.js";

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

Expand Down Expand Up @@ -665,96 +659,77 @@ function yamlDeclarations<T>(
* Each region reads its body back and re-renders it, so a marker moved elsewhere fails instead of regenerating
* in the wrong place or erasing authored text.
*/
export const GENERATED_REGIONS: Readonly<Record<string, readonly GeneratedRegion[]>> = {
"action.yml": [
GeneratedRegion.of<Readonly<Record<string, Pick<InputDecl, "description" | "default">>>>({
name: "action-inputs",
placement: { kind: "under-key", key: "inputs" },
data: () => INPUT_DECLS,
render: block(renderActionInputs),
parse: yamlDeclarations("inputs", INPUT_ENTRY),
}),
GeneratedRegion.of<Readonly<Record<string, { readonly description: string }>>>({
name: "action-outputs",
placement: { kind: "under-key", key: "outputs" },
data: () => OUTPUT_DECLS,
render: block(renderActionOutputs),
parse: yamlDeclarations("outputs", OUTPUT_ENTRY),
}),
],
"docs/reference/undeclared-policy.md": [
GeneratedRegion.of<readonly string[]>({
name: "policy-count-sentence",
placement: { kind: "under-heading", heading: "# The undeclared policy" },
data: () => countSentenceKeys(knobbedSections()),
render: block(countSentence),
parse: parseCountSentence,
}),
GeneratedRegion.of<readonly PolicyRow[]>({
name: "policy-defaults-table",
placement: { kind: "under-heading", heading: "## Defaults per section" },
data: () => policyRows(knobbedSections(), POLICY_ROW_PROSE),
render: block(renderPolicyRows),
parse: tableBody(DEFAULTS_TABLE_HEADER, "keyed", policyRow),
}),
],
"docs/reference/permissions.md": [
GeneratedRegion.of({
name: "permissions-grant-sentence",
placement: { kind: "under-heading", heading: "## What to grant" },
data: () => grantLabels(SECTIONS),
render: block(grantSentence),
parse: parseGrantSentence,
}),
GeneratedRegion.of<readonly GatedRead[]>({
name: "permissions-gated-reads",
placement: { kind: "under-heading", heading: "## How a denial surfaces" },
data: () => gatedReads(SECTIONS),
render: block(gatedBullets),
parse: (body) => blockLines(body).andThen((lines) => parseGatedBullets(lines, 1)),
}),
],
"docs/operate/check-mode.md": [
GeneratedRegion.of<readonly GatedRead[]>({
name: "check-mode-gated-reads",
placement: {
kind: "under-heading",
heading: "## Checking settings changes on pull requests",
},
data: () => gatedReads(SECTIONS),
render: block(checkModeGatedReads),
parse: parseCheckModeGatedReads,
}),
],
export const FILES: GeneratedFiles = {
"action.yml": {
regions: [
GeneratedRegion.of<Readonly<Record<string, Pick<InputDecl, "description" | "default">>>>({
name: "action-inputs",
placement: { kind: "under-key", key: "inputs" },
data: () => INPUT_DECLS,
render: block(renderActionInputs),
parse: yamlDeclarations("inputs", INPUT_ENTRY),
}),
GeneratedRegion.of<Readonly<Record<string, { readonly description: string }>>>({
name: "action-outputs",
placement: { kind: "under-key", key: "outputs" },
data: () => OUTPUT_DECLS,
render: block(renderActionOutputs),
parse: yamlDeclarations("outputs", OUTPUT_ENTRY),
}),
],
},
"docs/reference/undeclared-policy.md": {
regions: [
GeneratedRegion.of<readonly string[]>({
name: "policy-count-sentence",
placement: { kind: "under-heading", heading: "# The undeclared policy" },
data: () => countSentenceKeys(knobbedSections()),
render: block(countSentence),
parse: parseCountSentence,
}),
GeneratedRegion.of<readonly PolicyRow[]>({
name: "policy-defaults-table",
placement: { kind: "under-heading", heading: "## Defaults per section" },
data: () => policyRows(knobbedSections(), POLICY_ROW_PROSE),
render: block(renderPolicyRows),
parse: tableBody(DEFAULTS_TABLE_HEADER, "keyed", policyRow),
}),
],
},
"docs/reference/permissions.md": {
regions: [
GeneratedRegion.of({
name: "permissions-grant-sentence",
placement: { kind: "under-heading", heading: "## What to grant" },
data: () => grantLabels(SECTIONS),
render: block(grantSentence),
parse: parseGrantSentence,
}),
GeneratedRegion.of<readonly GatedRead[]>({
name: "permissions-gated-reads",
placement: { kind: "under-heading", heading: "## How a denial surfaces" },
data: () => gatedReads(SECTIONS),
render: block(gatedBullets),
parse: (body) => blockLines(body).andThen((lines) => parseGatedBullets(lines, 1)),
}),
],
},
"docs/operate/check-mode.md": {
regions: [
GeneratedRegion.of<readonly GatedRead[]>({
name: "check-mode-gated-reads",
placement: {
kind: "under-heading",
heading: "## Checking settings changes on pull requests",
},
data: () => gatedReads(SECTIONS),
render: block(checkModeGatedReads),
parse: parseCheckModeGatedReads,
}),
],
},
};

export function regenerateText(path: string, text: string): string {
const regions = GENERATED_REGIONS[path];
if (regions === undefined) {
throw new Error(`no generated regions are registered for ${path}`);
}
return regenerateRegions(text, regions, path);
}

export function regenerateAll(): string[] {
const changed: string[] = [];
for (const path of Object.keys(GENERATED_REGIONS)) {
const file = join(ROOT, path);
const before = readFileSync(file, "utf8");
const after = regenerateText(path, before);
if (after !== before) {
writeFileSync(file, after);
changed.push(path);
}
}
return changed;
}

if (import.meta.main) {
const changed = regenerateAll();
console.log(
changed.length === 0
? "generated regions already up to date"
: `regenerated ${changed.join(", ")}`,
);
regenerateFiles("gen-action-docs", FILES, ROOT);
}
93 changes: 40 additions & 53 deletions .github/scripts/gen-docs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { err, ok, Result, safeTry } from "neverthrow";
import { RUN_RESULTS } from "../../src/engine/outcome.js";
Expand Down Expand Up @@ -28,12 +27,11 @@ import {
blockLine,
blockLines,
GeneratedRegion,
regenerateRegions,
regionBounds,
} from "./lib/generated-regions.js";
import { type GeneratedFile, type GeneratedFiles, regenerateFiles } from "./lib/region-driver.js";

const ROOT = join(import.meta.dir, "..", "..");
export const COVERAGE_PATH = "docs/reference/coverage.md";

/** The repository these pages document; the token form's name and description derive from it. */
const REPO_SLUG = "Vivswan/github-settings-as-code";
Expand Down Expand Up @@ -816,79 +814,68 @@ function architectureMapRegion(name: string, heading: string): GeneratedRegion {
});
}

export const PAGE_REGIONS: Readonly<Record<string, readonly GeneratedRegion[]>> = {
"README.md": [patUrlRegion("readme-pat-url")],
"docs/start/getting-started.md": [patUrlRegion("pat-url")],
"docs/reference/sections.md": [sectionsTableRegion("sections-table", "# Sections")],
"docs/reference/inputs.md": [outputsListRegion("outputs-list", "## Outputs")],
"docs/reference/architecture.md": [
architectureMapRegion("architecture-map", "## The module map"),
],
};

// The result must reference the token-form label exactly as often as it defines it, at most once: a renamed
// A rendered page must reference the token-form label exactly as often as it defines it, at most once: a renamed
// reference, a second one, or a stale definition ahead of the generated one (it wins) would leave the page wrong
// while regeneration stays a no-op.
export function renderPage(path: string, text: string): string {
const regions = PAGE_REGIONS[path];
if (regions === undefined) {
throw new Error(`gen-docs: no generated regions are registered for ${path}`);
}
const out = regenerateRegions(text, regions, path);
function patFormFault(page: string, path: string): string | undefined {
// CommonMark trims and case-folds labels and lets the first definition win, so every spelling counts: a mention
// opening a line and ending in ":" is a definition, any other bracketed mention is a reference.
const mentions = [...out.matchAll(/^ {0,3}\[([^\]]+)\]:|\[([^\]]+)\]/gm)].filter(
const mentions = [...page.matchAll(/^ {0,3}\[([^\]]+)\]:|\[([^\]]+)\]/gm)].filter(
(match) => (match[1] ?? match[2] ?? "").trim().toLowerCase() === PAT_FORM_LABEL,
);
const definitions = mentions.filter((match) => match[1] !== undefined).length;
const references = mentions.length - definitions;
if (references !== definitions || definitions > 1) {
throw new Error(
`gen-docs: ${path} must reference [${PAT_FORM_LABEL}] exactly as often as it defines it, at most once; found ${references} and ${definitions}`,
return (
`gen-docs: ${path} must reference [${PAT_FORM_LABEL}] exactly as often as it defines it, ` +
`at most once; found ${references} and ${definitions}`
);
}
return out;
return undefined;
}

// The one region closes the file and holds everything below the title (or an empty body between fresh markers), so
// a marker moved over authored prose fails instead of erasing it.
const COVERAGE_REGIONS: readonly GeneratedRegion[] = [
GeneratedRegion.of({
name: "coverage",
placement: { kind: "tail" },
data: () => coveragePage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS),
render: block(renderCoveragePage),
parse: parseCoveragePage,
}),
];
/** A page with regions, held to the token-form rule once rendered. */
function page(...regions: GeneratedRegion[]): GeneratedFile {
return { regions, rendered: patFormFault };
}

/** The page's frontmatter and title; the sidebar reads `order`, and 115 sits the page right after the Sections table (110). */
const COVERAGE_TITLE = "---\norder: 115\n---\n\n# Coverage\n\n";

// Beyond the shared placement checks, the page must be exactly the title, the region, and one final newline, or
// prose left outside could drift from the generator's.
export function renderCoverageFile(coverage: string): string {
function coverageShapeFault(coverage: string, path: string): string | undefined {
const { begin, end } = regionBounds(coverage, "coverage", "html");
if (coverage.slice(0, begin[0]) !== COVERAGE_TITLE || coverage.slice(end[1]) !== "\n") {
throw new Error(
`gen-docs: ${COVERAGE_PATH} must be the frontmatter, the "# Coverage" title, the coverage region, and one final newline`,
);
return `gen-docs: ${path} must be the frontmatter, the "# Coverage" title, the coverage region, and one final newline`;
}
return regenerateRegions(coverage, COVERAGE_REGIONS, COVERAGE_PATH);
return undefined;
}

export const PAGES: GeneratedFiles = {
"docs/reference/coverage.md": {
// The one region closes the file and holds everything below the title (or an empty body between fresh
// markers), so a marker moved over authored prose fails instead of erasing it.
regions: [
GeneratedRegion.of({
name: "coverage",
placement: { kind: "tail" },
data: () => coveragePage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS),
render: block(renderCoveragePage),
parse: parseCoveragePage,
}),
],
shape: coverageShapeFault,
},
"README.md": page(patUrlRegion("readme-pat-url")),
"docs/start/getting-started.md": page(patUrlRegion("pat-url")),
"docs/reference/sections.md": page(sectionsTableRegion("sections-table", "# Sections")),
"docs/reference/inputs.md": page(outputsListRegion("outputs-list", "## Outputs")),
"docs/reference/architecture.md": page(
architectureMapRegion("architecture-map", "## The module map"),
),
};

if (import.meta.main) {
const pages: ReadonlyArray<readonly [string, (text: string) => string]> = [
[COVERAGE_PATH, renderCoverageFile],
...Object.keys(PAGE_REGIONS).map(
(path) => [path, (text: string) => renderPage(path, text)] as const,
),
];
for (const [file, render] of pages) {
const path = join(ROOT, file);
const before = readFileSync(path, "utf8");
const after = render(before);
writeFileSync(path, after);
console.log(`gen-docs: wrote ${path}${after === before ? " (unchanged)" : ""}`);
}
regenerateFiles("gen-docs", PAGES, ROOT);
}
8 changes: 4 additions & 4 deletions .github/scripts/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
*/

import { join } from "node:path";
import { GENERATED_REGIONS } from "./gen-action-docs.js";
import { COVERAGE_PATH, PAGE_REGIONS } from "./gen-docs.js";
import { FILES } from "./gen-action-docs.js";
import { PAGES } from "./gen-docs.js";
import { INDEX_PATH } from "./gen-gaps-index.js";
import { INPUTS_PAGE_PATH } from "./gen-inputs-table.js";

Expand All @@ -32,8 +32,8 @@ function regions(generator: string, paths: readonly string[]): GeneratedOutput[]
*/
export const GENERATED_OUTPUTS: readonly GeneratedOutput[] = [
{ path: INDEX_PATH, generator: "build:gaps-index", kind: "file" },
...regions("build:docs", [COVERAGE_PATH, ...Object.keys(PAGE_REGIONS)]),
...regions("build:action-docs", Object.keys(GENERATED_REGIONS)),
...regions("build:docs", Object.keys(PAGES)),
...regions("build:action-docs", Object.keys(FILES)),
{ path: INPUTS_PAGE_PATH, generator: "build:inputs-table", kind: "regions" },
];

Expand Down
Loading
Loading