ADR 0006: API docs generated from the types, not a docs framework
Status: Accepted (2026-07-19) · Phase 4 · slice 3 (docs from types)
Context
The public API is a single curated barrel (src/index.ts, ~86 exported symbols) that is already richly documented in TSDoc-style leading summaries. worksheet.ts alone carries 119 doc blocks, over strict, precise types. CLAUDE.md §2 states the goal plainly: the types are the docs. Slice 3 of the Phase 4 plan asks for first-class API docs generated from that surface, plus migration notes framed as "a different, better library" rather than a compatibility shim.
The obvious move is TypeDoc. We reject it, for the same reason ADR-0029 rejected Vitest: it is a large transitive dependency tree (its own TS wrapper, themes, a markdown plugin) carried to produce output whose shape we do not control, to document one barrel. This fork exists to shed exactly that kind of weight (CLAUDE.md §2, §4), and every prior toolchain call has been the hand-rolled, zero-dependency one: a hand-written SAX reader, hand-rolled Expect<Equal> type tests, pure tsc over a bundler, node --test over Vitest.
Decision
Docs are generated by a script we own, over the TypeScript compiler API
scripts/gen-docs.ts opens the repo's own tsconfig.json through the compiler API, walks the symbols src/index.ts re-exports (checker.getExportsOfModule, aliases resolved), and for each one renders its JSDoc summary, its @throws/@param/@returns/@example tags, and a body-stripped TypeScript signature. Interfaces, type aliases, enums, and consts print their declaration verbatim (a const's type comes from the checker); functions print every overload; classes print a signature overview of their public members plus a prose list of each documented member, so worksheet.ts's 119 member docs survive into the reference. TSDoc {@link X} references are rendered as code spans. Output is one Markdown page per originating module plus an index, under docs/api/.
typescript is already the toolchain, so this adds no new dependency, and the docs cannot describe a shape the compiler wouldn't accept, because the compiler is what produces them.
A signature is sliced from source text, not re-printed
A declaration renders as its own source text from getStart to the start of its body (or to its end, when it has none), rather than as a ts.createPrinter re-print of a body-stripping transform. The reference then shows the shape the author wrote: a wrapped union stays wrapped instead of collapsing to one 230-character line, indentation is the repo's, and a JSDoc on an interface member survives into the block that renders it, where the printer's removeComments had dropped it (interface members previously carried no documentation in the reference at all). Starting at getStart still excludes the declaration's own leading JSDoc, which is rendered separately as prose above the block.
The prose member list under a class is the one place that re-collapses a signature to a single line, because it puts one inside an inline code span.
This is also what let the toolchain move to TypeScript 7 (ADR 0028): its unstable/* API provides the checker, the symbol/JSDoc accessors, and the AST position and text accessors this needs, but no printer, no transform, and no EmitHint.
The summary prose is read the same way, from the JSDoc block
A declaration's summary comes from its JSDoc block via getTextOfJSDocComment, not from Symbol.getDocumentationComment(). The checker's string has {@link Target} already flattened to a bare Target, which would strip the code span off every cross-reference in the reference. The AST still carries the tag, so {@link} renders. A const is the one kind whose block hangs off the enclosing statement rather than the declarator, and the lookup walks out to find it.
The docs are a gated artifact, not a hand-maintained one
npm run docs regenerates; npm run docs:check regenerates and fails if the committed docs/api/ has drifted (git add --intent-to-add plus git diff --exit-code, so an added or removed page is caught, not only edits to existing ones). docs:check is a step in the Corpus CI workflow. The pages carry a "do not edit by hand" banner. Entries are sorted by symbol name so generation is deterministic across environments and the drift check is byte-exact.
The inherited README is replaced, not patched
The 3029-line ExcelJS README (still instructing npm install exceljs) is gone. The new README.md documents the actual API: synchronous Uint8Array I/O via free functions readXlsx/writeXlsx, the CellValue union, bounded-memory streaming reads, CSV. It frames the library as an independent rebuild, not a drop-in. docs/migrating-from-exceljs.md is the translation guide, covering the three shifts (sync byte-native I/O, one typed value union, undefined axes over sentinels), a method-mapping table, and an honest "what is not here yet". Every code block in both was typechecked against the real public barrel before landing. The README linked a README_zh.md translation that no longer existed in the tree; the link is dropped.
Consequences
- Positive: zero new dependencies; the reference is guaranteed to match the shipped types and is regression-guarded like any other artifact; the entry docs finally describe this library instead of ExcelJS; the generator is ~260 lines we fully control.
- Negative / deferred: the generator handles the constructs the current public API uses (interfaces, type aliases, enums, consts, functions with overloads, classes), so a new construct such as a namespace export would need a case added; there is no cross-page hyperlinking of
{@link}targets yet (they render as code spans); there is no rendered HTML site, just Markdown, which is deliberate, because Markdown renders on the repo host and in editors with no build. - Revisit when: the public API grows a construct the renderer doesn't cover, or a browsable hosted docs site becomes worth a build step.