@tsdoctor/model
v0.10.1
Published
Render Microsoft API Extractor models into LLM-lean markdown. Pure model loading, TSDoc extraction, type-signature formatting, and per-item markdown rendering.
Maintainers
Readme
@tsdoctor/model
Framework-neutral analysis and rendering for Microsoft API Extractor .api.json models: Effect-typed loading, pure TSDoc extraction, categorization, multi-entry-point resolution, route/collision computation, synthetic-base detection, type-signature formatting, prose cross-linking, declaration reconstruction and markdown rendering.
Why @tsdoctor/model
API Extractor's .api.json gives you a full symbol graph but no opinion on what to do with it. Turning that graph into documentation means walking TSDoc comments, formatting signatures, deduplicating re-exports across entry points, and deciding where each item's page lives — the same handful of problems every static-doc adapter (RSPress, VitePress, an MCP server, a folder of plain markdown) solves independently. This package solves them once, as pure functions and namespace modules with no I/O beyond loading the model file, so an adapter supplies only the two things that actually differ between consumers: frontmatter and the URL scheme.
Install
npm install @tsdoctor/modelpnpm add @tsdoctor/modelRequires Node.js >=24.11.0. This is an ESM-only package.
@effected/markdown, @effected/yaml and effect are required peers — Render builds its output as @effected/markdown node trees, and the frontmatter helpers parse and emit through @effected/yaml. @tsdoctor/vfs is an ordinary dependency, resolved for you: ApiExtractedPackage extends its VirtualPackage.
Quick start
Load a model, render every top-level export to markdown, and write the files wherever you want — the library does no file I/O beyond the Model.load read itself.
import { mkdir, writeFile } from "node:fs/promises";
import { Effect } from "effect";
import { Model, Render } from "@tsdoctor/model";
const program = Effect.gen(function* () {
const pkg = yield* Model.load("./temp/my-pkg.api.json");
const docs = Render.docs(pkg, {
packageName: "my-pkg",
routeFor: (ref) => `/api/${ref.slug}`,
frontmatter: (meta) => `---\ntitle: ${meta.name}\nkind: ${meta.kind}\n---\n\n`,
});
yield* Effect.promise(() => mkdir("./out", { recursive: true }));
for (const doc of docs) {
yield* Effect.promise(() => writeFile(`./out/${doc.slug}.md`, doc.markdown));
}
return docs;
});
const docs = await Effect.runPromise(program);
console.log(docs.map((d) => `${d.kind}: ${d.name}`));
// e.g. [ 'function: load', 'class: CrossLinker', 'interface: RenderedDoc' ]
// (the actual list depends on your model's exports)Model.load fails with ModelNotFoundError or ModelParseError on the Effect error channel rather than throwing — a missing or malformed .api.json is an expected failure mode for a build pipeline, not a defect. Render.docs returns one RenderedDoc (name, kind, slug, summary, packageName, markdown) per top-level, emittable export.
routeFor and frontmatter are independent — supply either, both, or neither. Without routeFor, item names in prose are left unlinked; without frontmatter, markdown is the bare body.
Render a single item
Render.item renders one ApiItem without walking the whole package — useful when a caller already has categorized items (via ApiItems.categorize) and wants to drive its own page-assembly loop.
import { Render } from "@tsdoctor/model";
const body = Render.item(apiItem, { packageName: "my-pkg" });
console.log(body.startsWith("#"));
// true — every rendered body opens with an H1 of the item's display nameFilter which items get a page
By default Render.docs drops compiler-synthetic forgotten exports — the *_base declarations TypeScript hoists for Effect class mixins (Schema.Class, Data.TaggedError), which stay in the model under includeForgottenExports: true but should never be their own page. That default lives in the exported Render.isEmittable predicate; SyntheticBases.detect finds the same declarations for adapters that want to inline them on the owning class's page instead of dropping them silently.
import { Render } from "@tsdoctor/model";
const docs = Render.docs(pkg, {
packageName: "my-pkg",
filter: (item) => Render.isEmittable(item) && !item.displayName.startsWith("Internal"),
});Supplying filter fully replaces the default, so compose it with Render.isEmittable to keep the forgotten-export drop.
Features
Model— Effect-typed.api.jsonloading.Model.load(path)returns the package'sApiPackageor fails withModelNotFoundError/ModelParseError;Model.firstPackage(apiModel)extracts a package from a caller-constructedApiModel, failing withEmptyModelErrorif it has none.Tsdoc— pure extraction off anApiItem:summary,params,returns,examples,deprecation,releaseTag,hasModifier,seeReferences, plusplainText/toMarkdownfor walking a raw TSDocDocNodetree yourself.ApiItems—categorize(items, categories)groups top-level items by category key (returning{ items, uncategorized }so the caller decides how to handle the leftovers),namespaceMembersflattens namespace contents with qualified names,inheritancereads extends/implements,sourceLinkbuilds a source-code URL;memberAnchors(item)andmemberRouteKeys(item)are theApiClass/ApiInterfaceviews of theRoutesfunctions below.EntryPoints—resolve(apiPackage)deduplicates items re-exported from more than one entry point (e.g..and./testing) into a flat list, recording every entry point each item is available from.Routes—RouteCandidate,detectCollisions, and the typedRouteCollisionErrorfor failing a build when two distinct items would resolve to the same output route;sanitizeIdis the single anchor-id sanitizer for member routes, withmemberAnchoras its named alias for one member.Routesmember anchors and keys —memberAnchors(members)computes every member's anchor in one pass, keyed byMemberRef.id, prefixing the lower-priority member when two sanitize alike (static create()keeps#create, the instance one becomes#instance-create).memberRouteKeys(className, members)returns the cross-link keys those anchors answer to: a bareClass.memberresolves to the static member when a class has both, withClass.(member:static),Class.(member:instance)andClass.prototype.memberemitted to disambiguate. Feed both from the same member list so a page'sid=and a link's#fragmentcannot drift.SyntheticBases—detect(items)finds the unexported*_basedeclarations an exported class'sextendsclause references, so an adapter can inline them instead of generating (or silently dropping) a page for them;BASE_CLASS_ANCHORis the matching anchor id.Signature—format(excerpt)turns an API ExtractorExcerptinto a clean, line-wrapped type signature string;stripExportDeclarestripsexport/declaremodifiers from declaration text.CrossLinker— an immutable class that wraps known item names in prose with links, skipping code spans and existing links. Build one per build from a precomputed route map (CrossLinker.fromRoutes) or from item refs plus an injected URL scheme (CrossLinker.fromRefs);linkreturns markdown links,linkHtmlreturns<a>anchors.Render— the markdown output system.Render.docs(pkg, opts)renders a whole package;Render.item(apiItem, opts)renders one item;Render.isEmittableis the default emit rule.Render.tree(@alpha) exposes the pre-serialization@effected/markdownnode tree for a future page-IR consumer.ApiExtractedPackage— reconstructs.d.tstext from a model and renders it to aVfs, one declaration file per entry point behind a syntheticpackage.json, so a type-checker can resolve the documented package the way a consumer would. Built onVirtualPackagefrom@tsdoctor/vfs;fromApiModel(path)loads a model file,fromPackage(apiPackage, name)takes one already in memory.TypeReferenceExtractor— finds the types a package's declarations reference but do not own, and emits theimport typestatements those declarations need.extractImportscovers a package,extractImportsForEntryPointone entry point, and the staticformatImportsrenders anImportStatement[]to source lines. Built-in and self-referencing types are filtered out, and a namespaced reference imports its namespace root rather than the leaf member.- Frontmatter —
parseFrontmatter(text)splits a document into{ data, content },stringifyFrontmatter(data)emits the YAML block, andemitFrontmatterBlock(data, body)assembles a whole document. Quoting is chosen so a YAML 1.1 consumer decodes the same values a YAML 1.2 one does — an unquoted ISO timestamp would otherwise arrive as aDatein one and a string in the other.
