diff --git a/.claude/skills/curate-toolkit-docs/SKILL.md b/.claude/skills/curate-toolkit-docs/SKILL.md new file mode 100644 index 000000000..cc82a3f98 --- /dev/null +++ b/.claude/skills/curate-toolkit-docs/SKILL.md @@ -0,0 +1,136 @@ +--- +name: curate-toolkit-docs +description: Add or edit hand-authored prose on an Arcade toolkit reference page by writing curation files under toolkit-docs-generator/curation/. Use when asked to document a toolkit's auth setup, explain an enum or parameter, add a warning to a tool, or fix wording on a toolkit page — anything where the text belongs to a specific toolkit or tool rather than a standalone docs page. +--- + +# Curating toolkit docs + +Toolkit reference pages are generated. Editing the JSON under +`toolkit-docs-generator/data/toolkits/` does nothing durable — the next +generation run overwrites it. Hand-written prose lives in +`toolkit-docs-generator/curation//` and is folded into the JSON on +every run. + +`toolkit-docs-generator/CURATION.md` is the format reference. This is the +procedure. + +## Step 1: confirm curation is the right home + +Curation is for prose bound to one toolkit or one tool: auth setup, enum value +tables, parameter caveats, per-tool warnings. If the content is a standalone +guide or concept explanation — anything a reader would reach from the sidebar +on its own — it belongs in `app/en/` as a normal MDX page instead. Stop and +write that page. + +## Step 2: read the neighbors + +```bash +ls toolkit-docs-generator/curation/ +cat toolkit-docs-generator/curation/googleflights/chunks/*.mdx +``` + +Directory names are lowercase and stripped of punctuation: `GoogleFlights` → +`googleflights`. Create one if the toolkit has none. Toolkits in the same family +usually share a pattern, and most existing auth prose is close to what you need. + +## Step 3: pick the file kind + +Use `chunks/*.mdx` — a block injected into the toolkit page or one tool's +section. That's the right answer in almost every case. The other two kinds, +`imports/*.mdx` and `pages/**/*.mdx`, are validated and carried into the JSON +but nothing in the app reads them, so don't reach for either expecting it to +render. + +Name the file with a numeric prefix and a slug matching the neighbors, for +example `003-auth-after-markdown.mdx`. The number is for humans and does not +control display order. + +## Step 4: choose `location` and `position` + +Toolkit-level (no `tool:` key): + +| You want it | Use | +| --- | --- | +| Right under the title, above the generated summary | `location: header`, `position: before` | +| Auth setup, after the summary | `location: auth`, `position: after` | +| A reference section between the summary and the tools table | `location: custom_section`, `position: after` | +| Just above the tools table | `location: before_available_tools`, `position: after` | +| Below the tools table, above the per-tool sections | `location: after_available_tools`, `position: after` | + +`position` is a slot name, not a spatial relationship — `description` + `after` +still renders above the generated summary. Check the ordering table in +`CURATION.md` before assuming a combination does what its name suggests. Some +render nowhere at all, and the compiler accepts them silently. + +Tool-level: add `tool: Toolkit.ToolName`, fully qualified, same toolkit as the +directory. Then `location` is one of `description`, `parameters`, `secrets`, +`auth`, or `output`, and `position: replace` suppresses that default block. One +pitfall: a tool-level `auth` chunk only renders for tools that have OAuth +scopes, and only after the reader expands the scope details. If the prose +matters to every reader, make it a toolkit-level `auth` chunk. + +## Step 5: write the file + +```mdx +--- +type: markdown +location: custom_section +position: after +header: "## GoogleFlightsTravelClass" +--- + +## GoogleFlightsTravelClass + +Cabin class for the search. + +- **`ECONOMY`**: Economy cabin. +- **`BUSINESS`**: Business cabin. +``` + +Rules that bite: + +- `type: markdown` renders flat. `callout`, `warning`, `info`, and `tip` wrap + the body in a callout box, and so does `section` despite its name — use + `markdown` for flat prose. +- `header` sets the anchor and section-nav entry but prints nothing. Repeat the + heading in the body, as in the example. +- Order within a slot comes from `priority` (lower first, default `100`), not + from filenames. +- `Callout`, `Steps`, `Tabs`, `TabbedCodeBlock`, `TableOfContents`, + `ToolFooter`, `SignupLink`, and `DataTable` work without importing. Any other + component fails to render. +- Unknown frontmatter keys fail the run. There is no `language`, `slug`, or + `order` key. +- Follow `STYLEGUIDE.md`: sentence case headings, active voice, "Arcade + Engine", "MCP server", "tool". + +To delete prose, delete the file — the curation directory is authoritative, so +removing the last file for a toolkit clears its prose on the next run. + +## Step 6: verify before committing + +Always run this. No credentials needed: + +```bash +cd toolkit-docs-generator +../node_modules/.bin/tsx src/cli/index.ts validate-curation --toolkit +``` + +Errors name the exact file and reason. Then `pnpm vale:check` from the repo +root. + +If you used `tool:`, confirm the value against the generated JSON, where +`qualifiedName` is exactly the format the frontmatter wants. A wrong value +fails the generation workflow, not your local check. + +```bash +grep -o '"qualifiedName": "[^"]*"' toolkit-docs-generator/data/toolkits/googleflights.json +``` + +## Step 7: set expectations in the PR + +The rendered page will not change in the PR's preview deploy — curation only +reaches the site when the generation workflow next runs and opens its automated +docs PR. Say so in the description so a reviewer doesn't hunt for a visual +diff. A local preview needs generated JSON, which needs Engine credentials — +see the last section of `CURATION.md`. diff --git a/toolkit-docs-generator/ARCHITECTURE.md b/toolkit-docs-generator/ARCHITECTURE.md index 968cd6f6c..75daa725d 100644 --- a/toolkit-docs-generator/ARCHITECTURE.md +++ b/toolkit-docs-generator/ARCHITECTURE.md @@ -27,7 +27,8 @@ The generator does **not** render HTML. It produces structured JSON and optional - `MarkdownCurationSource` compiles documentation chunks, import declarations, and subpages from the configured curation directory. When configured, that directory is globally authoritative: a missing toolkit directory means the - toolkit has no authored curation. + toolkit has no authored curation. [CURATION.md](CURATION.md) documents the + file format it accepts. - `CombinedToolkitDataSource` merges tools and metadata into one interface. ### Merger @@ -94,6 +95,7 @@ public, read-only values configured through these Vercel environment variables: - `src/sources/engine-api.ts` — tool metadata from Engine API - `src/sources/markdown-curation.ts` — Markdown and MDX curation compiler + ([format reference](CURATION.md)) - `src/sources/toolkit-data-source.ts` — unified data source - `src/merger/data-merger.ts` — merge pipeline - `src/generator/json-generator.ts` — output writer diff --git a/toolkit-docs-generator/CURATION.md b/toolkit-docs-generator/CURATION.md new file mode 100644 index 000000000..3fa6b0319 --- /dev/null +++ b/toolkit-docs-generator/CURATION.md @@ -0,0 +1,260 @@ +# Toolkit curation format + +Everything under `toolkit-docs-generator/curation/` is hand-authored prose the +generator folds into toolkit JSON. This is the format reference: file kinds, +frontmatter keys, where each one puts content on the page, and how the pipeline +fails. For a walkthrough of adding curation, see the `curate-toolkit-docs` +skill in `.claude/skills/`. + +Behavior below comes from `src/sources/markdown-curation.ts` (compiles the +directory), `src/shared/toolkit-schemas.ts` (schemas), +`src/merger/data-merger.ts` (attaches curation to toolkits and tools), and, in +the app, `toolkit-page.tsx`, `tool-section.tsx`, and +`documentation-chunk-renderer.tsx` under `app/_components/toolkit-docs/`. + +## Directory layout + +One directory per toolkit, named for the toolkit ID, matched case- and +punctuation-insensitively: `curation/googleflights/` serves `GoogleFlights`. + +```text +curation/ + / + chunks/*.mdx injectable blocks on the toolkit or a tool page + imports/*.mdx one ESM import declaration each + pages/**/*.mdx standalone subpages +``` + +Only `.md` and `.mdx` files in those three directories are read. Other files +are ignored. A `.json` file anywhere under `curation/` fails the run. + +## File kinds + +Every file needs YAML frontmatter and a non-empty body. Bodies compile through +`@mdx-js/mdx` at generation time, so a syntax error fails the run instead of +reaching the site. + +| Kind | Frontmatter | Body | Lands in | +| --- | --- | --- | --- | +| `chunks/*.mdx` | `type`, `location`, `position` required; `tool`, `title`, `variant`, `header`, `priority` optional | Markdown or MDX | `documentationChunks` on the toolkit, or on one tool when `tool:` is set | +| `imports/*.mdx` | `type: import` only | Exactly one ESM `import` declaration, and the body must start with `import` | `customImports` on the toolkit | +| `pages/**/*.mdx` | `type` only, any non-empty string | Markdown or MDX | `subPages`, with the path below `pages/` as `relativePath` | + +Chunks are the common case — a block of prose slotted into a named place: + +```mdx +--- +type: markdown +location: auth +position: after +header: "## Auth setup" +--- + +## Auth setup + +Connect your Google account before calling these tools. +``` + +**Imports and pages reach the JSON but nothing in the app reads them.** They +are validated and carried through, and then: the chunk renderer strips +`import` and `export` lines from chunk bodies and supplies a fixed component +set instead (see [Components](#components)), and a subpage produces no route. +Don't add either expecting it to render. + +For pages, `type` is a free-form label (existing examples use `install` and +`environment-variables`) and the path below `pages/` becomes `relativePath` — +`curation/jira/pages/environment-variables/page.mdx` yields +`"environment-variables/page.mdx"`. Path segments cannot be empty, `.`, or +`..`, and two pages cannot normalize to the same lowercase path. + +## Frontmatter reference + +Unknown keys are rejected, so a typo like `postion:` fails the run instead of +being silently dropped. No `language`, `slug`, or `order` key exists. + +| Key | Required | Allowed values | Effect | +| --- | --- | --- | --- | +| `type` | yes | `callout`, `markdown`, `code`, `warning`, `info`, `tip`, `section` | Picks the render path. See [Types](#types). | +| `location` | yes | `header`, `description`, `parameters`, `auth`, `secrets`, `output`, `footer`, `before_available_tools`, `after_available_tools`, `custom_section` | Names the slot. See [Locations](#locations). | +| `position` | yes | `before`, `after`, `replace` | Sub-slot within the location. `replace` also suppresses the default content, but only in some slots. | +| `tool` | no | `Toolkit.ToolName` | Promotes the chunk to one tool. Must be fully qualified and name the same toolkit as the directory. | +| `title` | no | any string | Callout heading. Only appears on the callout render path. | +| `variant` | no | `default`, `destructive`, `warning`, `info`, `success` | Overrides the callout color chosen from `type`. `destructive` renders as error, `success` as info. | +| `header` | no | any string, conventionally `"## Heading"` | Sets the block's anchor ID and adds a section-nav entry. Prints nothing — repeat the heading in the body if you want one visible. | +| `priority` | no | number, default `100` | Orders chunks within one location and position. Lower renders first. | + +### Types + +`type` picks the render path, and two of the three paths ignore it: + +| Chunk | Rendered as | +| --- | --- | +| `type: code` | Raw `
`, not MDX-compiled, no syntax highlighting, and no language field exists. Prefer a fenced code block in a `markdown` chunk. |
+| `type: markdown` | An MDX section with GitHub-flavored Markdown, so tables work. |
+| Any other type whose body contains a JSX tag, `
`, or `` | Also an MDX section, with the callout wrapper skipped. | +| Any other type with plain prose | Wrapped in a Nextra callout, body still MDX-compiled. Color comes from `variant` if set, else from `type`: `warning` warns, `info` and `tip` inform, `callout` is default gray. | + +`section` has no case of its own, so a plain-prose `section` chunk renders as a +**default callout** — rarely what an author wants for a heading-and-bullets +block, and what every existing plain-Markdown `section` chunk currently does. +Use `type: markdown` for prose that should sit flat on the page. + +### Locations + +A location names a slot, not an insertion point relative to a particular piece +of text, and the usable set differs between toolkit and tool level. A chunk in +a slot nothing renders is compiled, validated, written to the JSON, and never +displayed. + +#### Toolkit level (no `tool:` key) + +These render top to bottom in exactly this order: + +| # | `location` | `position` | Notes | +| --- | --- | --- | --- | +| 1 | `header` | `before` | First thing after the title, icon, and stats. | +| 2 | `description` | `before` | | +| 3 | `description` | `after` | | +| 4 | `header` | `replace` | Renders in place, suppressing nothing. | +| 5 | `header` | `after` | | +| — | | | *The generated toolkit summary.* | +| 6–7 | `auth` | `before`, `after` | | +| 8–9 | `before_available_tools` | `before`, `after` | | +| 10–11 | `custom_section` | `before`, `after` | | +| — | | | *The "Available tools" heading and table.* | +| 12–13 | `after_available_tools` | `before`, `after` | | +| — | | | *Every tool's expanded section.* | +| 14 | `footer` | `before` | | +| — | | | *The "Get Building" footer, unless a `footer` + `replace` chunk exists.* | +| 15 | `footer` | `replace` | The only toolkit-level `replace` that suppresses anything. | +| 16 | `footer` | `after` | | + +All four `header` and `description` slots therefore land above the generated +summary in the fixed order shown. `description` + `after` does not follow the +description text, it precedes the summary. + +Two groups render nowhere at toolkit level. First, `replace` on `description`, +`auth`, `before_available_tools`, `after_available_tools`, or `custom_section`. +Second, `parameters`, `secrets`, and `output` in any position, which are +tool-level only. + +#### Tool level (`tool: Toolkit.ToolName`) + +All three positions work in every slot, and `replace` suppresses the default +block: + +| `location` | Renders around, and `replace` suppresses | +| --- | --- | +| `description` | The tool's description text | +| `parameters` | The parameters table | +| `secrets` | The secrets list | +| `auth` | The OAuth scopes list | +| `output` | The output type block | + +Tool chunks are subject to the page's progressive disclosure: nothing renders +until the reader expands the tool, and `parameters`, `secrets`, `auth`, and +`output` additionally wait on the lazily fetched tool detail. **An `auth` chunk +only renders when the tool has OAuth scopes and the reader has clicked through +to the scope details**, so it never appears on a tool with no scopes — put auth +prose everyone should see in a toolkit-level `auth` chunk. `header`, `footer`, +`before_available_tools`, `after_available_tools`, and `custom_section` render +nowhere at tool level. + +### Ordering + +Filenames do not control display order, which surprises people, because every +existing file is numbered. The compiler sorts chunks by source path, so `001-`, +`002-` prefixes set the JSON array order — then the renderer ignores that and +sorts each location-and-position slot independently by `priority` ascending +(default `100`), then `header` alphabetically with headerless chunks last, then +body text as a tiebreak. Use `priority` to order within a slot. Keep the +numeric prefixes for readability, but don't rely on them. + +The section nav is built from every toolkit-level chunk with a `header`, sorted +by that same priority-then-header rule across all locations at once, so a +low-`priority` chunk in a late slot can appear early in the nav while rendering +late on the page. + +### Components + +Chunk bodies may use `Callout`, `Steps`, `Tabs`, `TabbedCodeBlock`, +`TableOfContents`, `ToolFooter`, `SignupLink`, and `DataTable` without +importing them. The renderer strips `import` and `export` lines and injects +that fixed set, so anything else — a component from an `imports/*.mdx` file, or +one imported inline — is undefined at render time and the block fails with +"Failed to render section" on the page. To add a component, extend +`MDX_COMPONENTS` in `documentation-chunk-renderer.tsx`. + +## Authoritative directory + +When `--custom-sections` points at a curation directory, that directory is the +only source of authored prose for **every** toolkit: + +- A toolkit with no directory has no authored prose. Its previously generated + JSON is not consulted for one. +- Deleting the last curation file for a toolkit clears that toolkit's prose on + the next run. There is no separate "remove this" step. +- Renaming a chunk file changes nothing but array order, since rendering is + keyed on frontmatter. + +Curation edits also count for `--skip-unchanged`: the generator fingerprints +the compiled curation, so editing a chunk regenerates that toolkit even when +its tools didn't change. + +## Failure modes + +The compiler fails the whole run rather than skipping a bad file or falling +back to stale content, and every message names the offending path. + +| What went wrong | Message | +| --- | --- | +| No YAML frontmatter | `Curation document must start with YAML frontmatter ()` | +| Frontmatter is not valid YAML | `Curation frontmatter is invalid (): ` | +| Missing, unknown, or wrongly typed key | `Curation chunk frontmatter has invalid schema (): ` | +| Same, but in a page or an import | `Curation page frontmatter has invalid schema ()`, `Curation import frontmatter has invalid schema ()` | +| Body is empty or whitespace | `Curation document body is empty ()` | +| Body does not compile as MDX | `Curation document has invalid MDX (): ` | +| An import body is not an `import` declaration | `Curation import must be an ESM import ()` | +| `tool:` is unqualified or names another toolkit | `Curation tool target must be fully qualified and match toolkit ()` | +| `tool:` names a tool the toolkit lacks | `Curation for targets unknown tool(s): ` | +| A page path has an empty, `.`, or `..` segment | `Curation page path is unsafe ()` | +| Two pages differ only by case | `Curation contains duplicate page paths ()` | +| A symlink anywhere under the root | `Curation directory may not contain symlinks ()` | +| Two toolkit directories normalize to one ID | `Curation toolkit directories normalize to the same ID: , ` | +| A leftover `.json` curation file | `JSON curation is no longer supported; convert this file to Markdown ()` | +| `--custom-sections` path is missing | `Configured curation directory does not exist: ` | +| `--custom-sections` points at a file | `Configured curation path is not a directory: ` | + +Only the unknown-`tool:` error needs the live tool list. Everything else is +detectable offline. + +## Checking your work + +```bash +cd toolkit-docs-generator +../node_modules/.bin/tsx src/cli/index.ts validate-curation +../node_modules/.bin/tsx src/cli/index.ts validate-curation --toolkit GoogleFlights +``` + +This needs no credentials. It reports per-toolkit counts, prints each failing +toolkit with its message, and exits non-zero on failure. It runs the same +compiler generation uses, so a pass means kinds, frontmatter, and MDX are all +valid. It cannot check `tool:` targets. + +Invoke `tsx` by path, not through `pnpm exec`, which resets the working +directory to the repo root and breaks the relative path. `pnpm dlx tsx` also +works. + +Seeing a chunk on a page needs generated JSON. Either wait for the automated +generation PR, or generate that one toolkit with Engine credentials and run +`pnpm dev` from the repo root: + +```bash +../node_modules/.bin/tsx src/cli/index.ts generate \ + --providers "GoogleFlights" \ + --tool-metadata-url "$ENGINE_API_URL" \ + --tool-metadata-key "$ENGINE_API_KEY" \ + --custom-sections ./curation \ + --skip-examples --skip-summary --skip-secret-coherence \ + --output data/toolkits +``` diff --git a/toolkit-docs-generator/README.md b/toolkit-docs-generator/README.md index 223425986..b6ddf7593 100644 --- a/toolkit-docs-generator/README.md +++ b/toolkit-docs-generator/README.md @@ -252,6 +252,17 @@ authored prose on the next generation run. Invalid frontmatter, invalid MDX, unknown tool targets, symlinks, unsafe subpage paths, and leftover JSON curation fail generation instead of silently falling back to stale generated content. +**[CURATION.md](CURATION.md) is the format reference**: every frontmatter key, +which `location` and `position` combinations actually render, and the full list +of failure messages. Before writing a curation file, check your work with: + +```bash +pnpm dlx tsx src/cli/index.ts validate-curation --toolkit GoogleFlights +``` + +That compiles the directory with the same code generation uses and needs no +credentials. Omit `--toolkit` to check everything. + ## Troubleshooting - **Nothing regenerated**: `--skip-unchanged` exits early when tool definitions did not change. diff --git a/toolkit-docs-generator/src/cli/index.ts b/toolkit-docs-generator/src/cli/index.ts index dedac9e17..0070e42f1 100644 --- a/toolkit-docs-generator/src/cli/index.ts +++ b/toolkit-docs-generator/src/cli/index.ts @@ -45,7 +45,10 @@ import { } from "../merger/data-merger"; import { createDesignSystemMetadataSource } from "../sources/design-system-metadata"; import { createEmptyCustomSectionsSource } from "../sources/in-memory"; -import { createMarkdownCurationSource } from "../sources/markdown-curation"; +import { + compileCurationDirectory, + createMarkdownCurationSource, +} from "../sources/markdown-curation"; import { createMockMetadataSource } from "../sources/mock-metadata"; import { createDesignSystemProviderIdResolver } from "../sources/oauth-provider-resolver"; import { @@ -62,6 +65,7 @@ import { ProviderVersionSchema, } from "../types/index"; import { readExclusionList } from "../utils/exclusion-list"; +import { normalizeId } from "../utils/fp"; import { readIgnoreList } from "../utils/ignore-list"; import { clearSafeOutputDir, @@ -2618,6 +2622,90 @@ program } }); +program + .command("validate-curation") + .description( + "Compile the authored curation directory and report problems per toolkit" + ) + .option( + "--custom-sections ", + "Path to the Markdown/MDX curation directory (defaults to ./curation when present)" + ) + .option("--toolkit ", "Only validate one toolkit directory") + .action(async (options: { customSections?: string; toolkit?: string }) => { + const rootPath = await resolveCustomSectionsPath(options.customSections); + if (!rootPath) { + console.log( + chalk.red( + "No curation directory found. Pass --custom-sections , or run from a directory containing curation/." + ) + ); + process.exit(1); + } + + let results: Awaited>; + try { + results = await compileCurationDirectory(rootPath); + } catch (error) { + // A problem with the root itself, so nothing below it was compiled. + console.log( + chalk.red(`✗ ${error instanceof Error ? error.message : error}`) + ); + process.exit(1); + } + + const wanted = options.toolkit ? normalizeId(options.toolkit) : undefined; + const selected = wanted + ? results.filter((result) => normalizeId(result.toolkitId) === wanted) + : results; + + if (selected.length === 0) { + console.log( + chalk.red( + options.toolkit + ? `No curation directory for ${options.toolkit} in ${rootPath}` + : `No toolkit directories found in ${rootPath}` + ) + ); + process.exit(1); + } + + let failures = 0; + let toolChunkTargets = 0; + for (const result of selected) { + if ("error" in result) { + failures++; + console.log(chalk.red(`✗ ${result.toolkitId}`)); + console.log(chalk.dim(` ${result.error.message}`)); + continue; + } + const { documentationChunks, customImports, subPages, toolChunks } = + result.sections; + const toolChunkCount = Object.values(toolChunks).reduce( + (total, chunks) => total + chunks.length, + 0 + ); + toolChunkTargets += Object.keys(toolChunks).length; + console.log( + `${chalk.green("✓")} ${result.toolkitId}: ${documentationChunks.length} chunks, ${toolChunkCount} tool chunks, ${customImports.length} imports, ${subPages.length} pages` + ); + } + + console.log( + `\n${selected.length} toolkit(s) checked, ${failures} with errors` + ); + if (toolChunkTargets > 0) { + console.log( + chalk.dim( + "Note: `tool:` targets are checked against the live tool list during generation, not here." + ) + ); + } + if (failures > 0) { + process.exit(1); + } + }); + program .command("list-toolkits") .description("List toolkits available in mock data") diff --git a/toolkit-docs-generator/src/sources/markdown-curation.ts b/toolkit-docs-generator/src/sources/markdown-curation.ts index ae6a2d5d3..36deed4e1 100644 --- a/toolkit-docs-generator/src/sources/markdown-curation.ts +++ b/toolkit-docs-generator/src/sources/markdown-curation.ts @@ -300,6 +300,112 @@ const loadToolkitDirectory = async ( }); }; +/** + * Outcome of compiling one toolkit directory. Compilation failures are + * returned rather than thrown so a caller that wants to report on every + * toolkit (see the `validate-curation` CLI command) is not limited to the + * first broken file. + */ +export type CurationCompileResult = + | { toolkitId: string; sections: CustomSections } + | { toolkitId: string; error: Error }; + +const assertCurationRoot = async (rootPath: string): Promise => { + let rootStats: Awaited>; + try { + rootStats = await stat(rootPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error( + `Configured curation directory does not exist: ${rootPath}` + ); + } + throw error; + } + if (!rootStats.isDirectory()) { + throw new Error(`Configured curation path is not a directory: ${rootPath}`); + } +}; + +/** + * Names of the toolkit directories directly below the root, in sorted order. + * Anything at the root that cannot be a toolkit directory either throws (a + * symlink, leftover JSON curation, a name colliding with another directory + * once normalized) or is skipped. + */ +const listToolkitDirectoryNames = async ( + rootPath: string +): Promise => { + const entries = (await readdir(rootPath, { withFileTypes: true })).sort( + (left, right) => left.name.localeCompare(right.name) + ); + const names: string[] = []; + const normalizedIds = new Map(); + + for (const entry of entries) { + const entryPath = join(rootPath, entry.name); + if (entry.isSymbolicLink()) { + throw new Error( + `Curation directory may not contain symlinks (${entryPath})` + ); + } + if (entry.isFile() && extensionOf(entry.name) === ".json") { + rejectJsonFiles([entryPath]); + } + if (!entry.isDirectory()) { + continue; + } + + const normalizedId = normalizeId(entry.name); + const duplicate = normalizedIds.get(normalizedId); + if (duplicate) { + throw new Error( + `Curation toolkit directories normalize to the same ID: ${duplicate}, ${entry.name}` + ); + } + normalizedIds.set(normalizedId, entry.name); + names.push(entry.name); + } + + return names; +}; + +/** + * Walk a curation root and compile every toolkit directory below it, in + * sorted directory order. + * + * Problems with the root itself — a missing or non-directory path, a symlink, + * leftover JSON curation, two directories that normalize to the same toolkit + * ID — throw, because they make the whole directory uninterpretable rather + * than spoiling one toolkit. + */ +export const compileCurationDirectory = async ( + rootPath: string +): Promise => { + await assertCurationRoot(rootPath); + const toolkitIds = await listToolkitDirectoryNames(rootPath); + const results: CurationCompileResult[] = []; + + for (const toolkitId of toolkitIds) { + try { + results.push({ + toolkitId, + sections: await loadToolkitDirectory( + join(rootPath, toolkitId), + toolkitId + ), + }); + } catch (error) { + results.push({ + toolkitId, + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + + return results; +}; + export class MarkdownCurationSource implements ICustomSectionsSource { private readonly rootPath: string; private cachedData: Readonly> | null = null; @@ -313,50 +419,13 @@ export class MarkdownCurationSource implements ICustomSectionsSource { return this.cachedData; } - let rootStats: Awaited>; - try { - rootStats = await stat(this.rootPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error( - `Configured curation directory does not exist: ${this.rootPath}` - ); - } - throw error; - } - if (!rootStats.isDirectory()) { - throw new Error( - `Configured curation path is not a directory: ${this.rootPath}` - ); - } - - const entries = ( - await readdir(this.rootPath, { withFileTypes: true }) - ).sort((left, right) => left.name.localeCompare(right.name)); + const results = await compileCurationDirectory(this.rootPath); const data: Record = {}; - const normalizedIds = new Map(); - for (const entry of entries) { - const entryPath = join(this.rootPath, entry.name); - if (entry.isSymbolicLink()) { - throw new Error( - `Curation directory may not contain symlinks (${entryPath})` - ); - } - if (entry.isFile() && extensionOf(entry.name) === ".json") { - rejectJsonFiles([entryPath]); - } - if (!entry.isDirectory()) { - continue; - } - const normalizedId = normalizeId(entry.name); - const duplicate = normalizedIds.get(normalizedId); - if (duplicate) { - throw new Error( - `Curation toolkit directories normalize to the same ID: ${duplicate}, ${entry.name}` - ); + for (const result of results) { + if ("error" in result) { + throw result.error; } - normalizedIds.set(normalizedId, entry.name); - data[entry.name] = await loadToolkitDirectory(entryPath, entry.name); + data[result.toolkitId] = result.sections; } this.cachedData = data; diff --git a/toolkit-docs-generator/tests/sources/markdown-curation.test.ts b/toolkit-docs-generator/tests/sources/markdown-curation.test.ts index 2a0e67b04..25dfb6052 100644 --- a/toolkit-docs-generator/tests/sources/markdown-curation.test.ts +++ b/toolkit-docs-generator/tests/sources/markdown-curation.test.ts @@ -2,7 +2,10 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; -import { createMarkdownCurationSource } from "../../src/sources/markdown-curation"; +import { + compileCurationDirectory, + createMarkdownCurationSource, +} from "../../src/sources/markdown-curation"; const createTempDir = async (): Promise => mkdtemp(join(tmpdir(), "markdown-curation-")); @@ -232,3 +235,51 @@ import StarterToolInfo from "@/app/_components/starter-tool-info"; ).rejects.toThrow("may not contain symlinks"); }); }); + +describe("compileCurationDirectory", () => { + let tempDir: string | null = null; + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it("reports a failure per toolkit instead of stopping at the first one", async () => { + tempDir = await createTempDir(); + await writeDocument( + tempDir, + "github/chunks/bad.mdx", + chunk("", "Unclosed") + ); + await writeDocument(tempDir, "slack/chunks/ok.mdx", chunk()); + await writeDocument( + tempDir, + "zoom/chunks/bad.mdx", + chunk("unknown: true\n") + ); + + const results = await compileCurationDirectory(tempDir); + + expect( + results.map((result) => [ + result.toolkitId, + "error" in result ? "error" : "ok", + ]) + ).toEqual([ + ["github", "error"], + ["slack", "ok"], + ["zoom", "error"], + ]); + }); + + it("throws on root-level problems, which spoil the whole directory", async () => { + tempDir = await createTempDir(); + await writeFile(join(tempDir, "github.json"), "{}"); + + await expect(compileCurationDirectory(tempDir)).rejects.toThrow( + "JSON curation is no longer supported" + ); + }); +});