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
105 changes: 55 additions & 50 deletions docs/reference/file-formats.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"scripts": {
"build": "tsdown",
"check": "pnpm run fmt:check && pnpm run oxlint && pnpm run typecheck",
"check:docs-content": "pnpm run generate:docs-content && git diff --exit-code src/generated/docs-content.ts",
"check:docs-content": "pnpm run generate:docs-content && git diff --exit-code src/generated/docs-content.ts docs/reference/file-formats.md",
"check:gitignore": "pnpm run dev gitignore && git diff --exit-code .gitignore .gitattributes",
"check:supported-tools": "tsx scripts/generate-supported-tools-tables.ts && git diff --exit-code README.md docs/reference/supported-tools.md",
"check:sync-skill-docs": "tsx scripts/check-skill-docs-sync.ts",
Expand Down
30 changes: 23 additions & 7 deletions scripts/generate-docs-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { join, relative, sep } from "node:path";

import { globbySync } from "globby";

import { renderHookEventsMatrix } from "./hook-events-table.js";

/**
* Embed the canonical `docs/**\/*.md` hierarchy into a generated TypeScript
* module so the `rulesync docs` command can serve it from every distribution
Expand All @@ -14,6 +16,26 @@ const repoRoot = join(import.meta.dirname, "..");
const docsRoot = join(repoRoot, "docs");
const outputPath = join(repoRoot, "src", "generated", "docs-content.ts");

// Normalize to the repo's formatter so the drift check compares stable output.
// npx is npx.cmd on Windows; a shell resolves it. stderr is inherited so a
// formatter failure stays diagnosable.
const runOxfmt = (path: string): void => {
execFileSync("npx", ["oxfmt", relative(repoRoot, path)], {
cwd: repoRoot,
stdio: ["ignore", "ignore", "inherit"],
shell: process.platform === "win32",
});
};

// Regenerate the derived hook-event matrix inside file-formats.md before
// embedding, so a stale committed table fails the docs-content drift check
// (`check:docs-content` diffs this file as well as the embed). Written
// unconditionally: the renderer emits cells without column padding and oxfmt
// owns the final column layout, mirroring generate-supported-tools-tables.ts.
const hookMatrixPath = join(docsRoot, "reference", "file-formats.md");
writeFileSync(hookMatrixPath, renderHookEventsMatrix(readFileSync(hookMatrixPath, "utf8")), "utf8");
runOxfmt(hookMatrixPath);

const filePaths = globbySync("**/*.md", {
cwd: docsRoot,
// The VitePress internals and the landing page (theme-config frontmatter,
Expand Down Expand Up @@ -46,12 +68,6 @@ const lines: string[] = [
];

writeFileSync(outputPath, lines.join("\n"), "utf8");
// Normalize to the repo's formatter so the drift check compares stable output.
execFileSync("npx", ["oxfmt", relative(repoRoot, outputPath)], {
cwd: repoRoot,
stdio: "ignore",
// npx is npx.cmd on Windows; a shell resolves it.
shell: process.platform === "win32",
});
runOxfmt(outputPath);
// oxlint-disable-next-line no-console
console.log(`Embedded ${entries.length} docs into ${relative(repoRoot, outputPath)}`);
20 changes: 7 additions & 13 deletions scripts/generate-supported-tools-tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getProcessorRegistryEntry, PROCESSOR_REGISTRY } from "../src/types/proc
import { TOOL_DISPLAY, type ToolDisplayEntry } from "../src/types/tool-display.js";
import { ALL_TOOL_TARGETS, type ToolTarget } from "../src/types/tool-targets.js";
import { formatError } from "../src/utils/error.js";
import { replaceBetweenMarkers } from "./markdown-markers.js";

const FEATURES = [
"rules",
Expand Down Expand Up @@ -104,25 +105,18 @@ const README_AI_MARK = "SUPPORTED_TOOLS_AI";
const README_STD_MARK = "SUPPORTED_TOOLS_STANDARD";
const DOCS_MARK = "SUPPORTED_TOOLS_DOCS";

const replaceBetween = (content: string, marker: string, body: string): string => {
const begin = `<!-- ${marker}:BEGIN -->`;
const end = `<!-- ${marker}:END -->`;
const startIdx = content.indexOf(begin);
const endIdx = content.indexOf(end);
if (startIdx === -1 || endIdx === -1) {
throw new Error(`Markers ${marker} not found; add ${begin} / ${end} around the table.`);
}
return `${content.slice(0, startIdx + begin.length)}\n${body}\n${content.slice(endIdx)}`;
};

const renderReadme = (content: string): string => {
const ai = buildReadmeTable(TOOL_DISPLAY.filter((e) => e.group === "ai"));
const std = buildReadmeTable(TOOL_DISPLAY.filter((e) => e.group === "standard"));
return replaceBetween(replaceBetween(content, README_AI_MARK, ai), README_STD_MARK, std);
return replaceBetweenMarkers(
replaceBetweenMarkers(content, README_AI_MARK, ai),
README_STD_MARK,
std,
);
};

const renderDocs = (content: string): string =>
replaceBetween(content, DOCS_MARK, buildDocsTable());
replaceBetweenMarkers(content, DOCS_MARK, buildDocsTable());

const main = (): void => {
// Display list must cover exactly the non-legacy targets.
Expand Down
80 changes: 80 additions & 0 deletions scripts/hook-events-table.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";

import { toolHooksFactories } from "../src/features/hooks/hooks-processor.js";
import { HOOK_EVENTS } from "../src/types/hooks.js";
import { TOOL_DISPLAY } from "../src/types/tool-display.js";
import { renderHookEventsMatrix } from "./hook-events-table.js";
import { replaceBetweenMarkers } from "./markdown-markers.js";

const wrap = (inner: string): string =>
`before\n<!-- HOOK_EVENTS_MATRIX:BEGIN -->\n${inner}\n<!-- HOOK_EVENTS_MATRIX:END -->\nafter`;

describe("renderHookEventsMatrix", () => {
it("replaces the content between the markers with the generated table", () => {
const result = renderHookEventsMatrix(wrap("| stale |"));

expect(result).not.toContain("| stale |");
expect(result.startsWith("before\n<!-- HOOK_EVENTS_MATRIX:BEGIN -->\n| Event |")).toBe(true);
expect(result.endsWith("<!-- HOOK_EVENTS_MATRIX:END -->\nafter")).toBe(true);
});

it("renders one column per hooks target and one row per supported event", () => {
const result = renderHookEventsMatrix(wrap(""));
const tableLines = result
.split("\n")
.filter((line) => line.startsWith("|"))
.filter((line) => !line.startsWith("| ---"));

const header = tableLines[0]!;
const headerLabels = header
.split("|")
.map((cell) => cell.trim())
.filter(Boolean)
.slice(1);
const hooksLabels = TOOL_DISPLAY.filter((entry) =>
toolHooksFactories.has(entry.key as never),
).map((entry) => entry.label);
expect(headerLabels).toEqual(hooksLabels);

const supportedEventCount = HOOK_EVENTS.filter((event) =>
[...toolHooksFactories.values()].some((factory) => factory.supportedEvents.includes(event)),
).length;
expect(tableLines.length - 1).toBe(supportedEventCount);
});

it("marks each cell from the factory's supportedEvents", () => {
const result = renderHookEventsMatrix(wrap(""));
const columns = TOOL_DISPLAY.filter((entry) => toolHooksFactories.has(entry.key as never));
const rows = result
.split("\n")
.filter((line) => line.startsWith("| `"))
.map((line) => {
const cells = line
.split("|")
.map((cell) => cell.trim())
.filter(Boolean);
return { event: cells[0]!.replaceAll("`", ""), cells: cells.slice(1) };
});

for (const { event, cells } of rows) {
cells.forEach((cell, index) => {
const factory = toolHooksFactories.get(columns[index]!.key as never)!;
const expected = factory.supportedEvents.includes(event as never) ? "✅" : "—";
expect(cell, `${event} × ${columns[index]!.label}`).toBe(expected);
});
}
});

it("throws when the markers are missing", () => {
expect(() => renderHookEventsMatrix("no markers here")).toThrow(
"Markers HOOK_EVENTS_MATRIX not found",
);
});
});

describe("replaceBetweenMarkers", () => {
it("throws when END precedes BEGIN instead of duplicating content", () => {
const content = "<!-- M:END -->\nmiddle\n<!-- M:BEGIN -->";
expect(() => replaceBetweenMarkers(content, "M", "body")).toThrow("Markers M not found");
});
});
50 changes: 50 additions & 0 deletions scripts/hook-events-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { toolHooksFactories } from "../src/features/hooks/hooks-processor.js";
import { HOOK_EVENTS, type HookEvent } from "../src/types/hooks.js";
import { TOOL_DISPLAY } from "../src/types/tool-display.js";
import { replaceBetweenMarkers } from "./markdown-markers.js";

const MARKER = "HOOK_EVENTS_MATRIX";

/**
* Render the `Hook event × tool matrix` table in `docs/reference/file-formats.md`
* from each hooks factory's `supportedEvents`, so the table cannot drift from
* `src/types/hooks.ts` (it used to be hand-maintained and was missing columns
* for several hook-capable targets — and still listed removed ones).
*
* Freshness is enforced indirectly: `scripts/generate-docs-content.ts` calls
* this before embedding the docs, so a stale committed table makes
* `pnpm run check:docs-content` fail on the regenerated embed.
*/
export const renderHookEventsMatrix = (content: string): string => {
const factories: ReadonlyMap<string, { supportedEvents: readonly HookEvent[] }> =
toolHooksFactories;

// Column order follows TOOL_DISPLAY (the order the other generated tables
// use), restricted to targets that have a hooks factory.
const columns = TOOL_DISPLAY.filter((entry) => factories.has(entry.key));
const displayedKeys = new Set<string>(columns.map((entry) => entry.key));
const missing = [...factories.keys()].filter((key) => !displayedKeys.has(key));
if (missing.length > 0) {
throw new Error(`Hooks targets missing from TOOL_DISPLAY: ${missing.join(", ")}`);
}

const supportedSets = new Map<string, ReadonlySet<HookEvent>>(
columns.map((entry) => [entry.key, new Set(factories.get(entry.key)!.supportedEvents)]),
);

// Only events at least one hooks target supports get a row, in the canonical
// HOOK_EVENTS order.
const rows = HOOK_EVENTS.filter((event) =>
columns.some((entry) => supportedSets.get(entry.key)!.has(event)),
);

const header = `| Event | ${columns.map((entry) => entry.label).join(" | ")} |`;
const separator = `| --- | ${columns.map(() => ":-:").join(" | ")} |`;
const body = rows.map((event) => {
const cells = columns.map((entry) => (supportedSets.get(entry.key)!.has(event) ? "✅" : "—"));
return `| \`${event}\` | ${cells.join(" | ")} |`;
});
const table = [header, separator, ...body].join("\n");

return replaceBetweenMarkers(content, MARKER, table);
};
15 changes: 15 additions & 0 deletions scripts/markdown-markers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Replace the content between `<!-- MARKER:BEGIN -->` / `<!-- MARKER:END -->`
* comments with a generated body. Shared by the marker-based table generators
* (supported-tools tables, hook-event matrix).
*/
export const replaceBetweenMarkers = (content: string, marker: string, body: string): string => {
const begin = `<!-- ${marker}:BEGIN -->`;
const end = `<!-- ${marker}:END -->`;
const startIdx = content.indexOf(begin);
const endIdx = content.indexOf(end);
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
throw new Error(`Markers ${marker} not found; add ${begin} / ${end} around the table.`);
}
return `${content.slice(0, startIdx + begin.length)}\n${body}\n${content.slice(endIdx)}`;
};
2 changes: 1 addition & 1 deletion src/generated/docs-content.ts

Large diffs are not rendered by default.

Loading