@tsdoctor/model 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ApiItems.js ADDED
@@ -0,0 +1,111 @@
1
+ import { __exportAll } from "./_virtual/_rolldown/runtime.js";
2
+ import { hasModifier } from "./Tsdoc.js";
3
+ import { ApiItemKind } from "@microsoft/api-extractor-model";
4
+
5
+ //#region src/ApiItems.ts
6
+ var ApiItems_exports = /* @__PURE__ */ __exportAll({
7
+ categorize: () => categorize,
8
+ inheritance: () => inheritance,
9
+ namespaceMembers: () => namespaceMembers,
10
+ sourceLink: () => sourceLink
11
+ });
12
+ /** Extract the flat top-level item list from either source shape. */
13
+ function topLevelItems(source) {
14
+ if (Array.isArray(source)) return source.map((r) => r.item);
15
+ const entryPoint = source.entryPoints[0];
16
+ return entryPoint ? entryPoint.members : [];
17
+ }
18
+ /**
19
+ * Group top-level API items into categories. A category's `tsdocModifier`
20
+ * takes precedence over its `itemKinds`; categories declaring a modifier are
21
+ * checked first. Items no category matches land in `uncategorized`.
22
+ *
23
+ * @public
24
+ */
25
+ function categorize(source, categories) {
26
+ const items = {};
27
+ for (const categoryKey of Object.keys(categories)) items[categoryKey] = [];
28
+ const sortedCategories = Object.entries(categories).sort((a, b) => {
29
+ const [, configA] = a;
30
+ const [, configB] = b;
31
+ if (configA.tsdocModifier && !configB.tsdocModifier) return -1;
32
+ if (!configA.tsdocModifier && configB.tsdocModifier) return 1;
33
+ return 0;
34
+ });
35
+ const uncategorized = [];
36
+ for (const member of topLevelItems(source)) {
37
+ let categorized = false;
38
+ for (const [categoryKey, config] of sortedCategories) {
39
+ if (config.tsdocModifier && hasModifier(member, config.tsdocModifier)) {
40
+ items[categoryKey].push(member);
41
+ categorized = true;
42
+ break;
43
+ }
44
+ if (config.itemKinds?.includes(member.kind)) {
45
+ items[categoryKey].push(member);
46
+ categorized = true;
47
+ break;
48
+ }
49
+ }
50
+ if (!categorized) uncategorized.push(member);
51
+ }
52
+ return {
53
+ items,
54
+ uncategorized
55
+ };
56
+ }
57
+ /**
58
+ * Extract all members of top-level namespaces as a flat list with qualified
59
+ * names.
60
+ *
61
+ * @public
62
+ */
63
+ function namespaceMembers(source) {
64
+ const members = [];
65
+ for (const item of topLevelItems(source)) if (item.kind === ApiItemKind.Namespace) {
66
+ const namespace = item;
67
+ for (const member of namespace.members) members.push({
68
+ item: member,
69
+ namespace,
70
+ qualifiedName: `${namespace.displayName}.${member.displayName}`
71
+ });
72
+ }
73
+ return members;
74
+ }
75
+ /**
76
+ * Read extends/implements information from a class or interface declaration.
77
+ *
78
+ * @public
79
+ */
80
+ function inheritance(item) {
81
+ const result = {};
82
+ if (item.kind === ApiItemKind.Class) {
83
+ const apiClass = item;
84
+ if (apiClass.extendsType) result.extends = [apiClass.extendsType.excerpt.text];
85
+ const implementsTypes = apiClass.implementsTypes || [];
86
+ if (implementsTypes.length > 0) result.implements = implementsTypes.map((type) => type.excerpt.text);
87
+ } else if (item.kind === ApiItemKind.Interface) {
88
+ const extendsTypes = item.extendsTypes || [];
89
+ if (extendsTypes.length > 0) result.extends = extendsTypes.map((type) => type.excerpt.text);
90
+ }
91
+ return result;
92
+ }
93
+ /**
94
+ * Build a source-code URL (with line number when available) for an API item,
95
+ * or `null` when no target or file path is known.
96
+ *
97
+ * @public
98
+ */
99
+ function sourceLink(item, target) {
100
+ if (!target) return null;
101
+ const itemAny = item;
102
+ const filePath = itemAny.fileUrlPath || itemAny.filePath;
103
+ if (!filePath) return null;
104
+ const lineNumber = itemAny.fileLineNumber || itemAny.line;
105
+ const ref = target.ref || "blob/main";
106
+ const baseUrl = `${target.url}/${ref}`;
107
+ return lineNumber ? `${baseUrl}/${filePath}#L${lineNumber}` : `${baseUrl}/${filePath}`;
108
+ }
109
+
110
+ //#endregion
111
+ export { ApiItems_exports, categorize, inheritance, namespaceMembers, sourceLink };
package/CrossLinker.js ADDED
@@ -0,0 +1,81 @@
1
+ import { escapeRegExp } from "./internal/text.js";
2
+
3
+ //#region src/CrossLinker.ts
4
+ /**
5
+ * Link known API item names in prose to their docs. Immutable: construct one
6
+ * per build from either a precomputed name → route map ({@link CrossLinker.fromRoutes})
7
+ * or item refs plus an injected URL scheme ({@link CrossLinker.fromRefs}).
8
+ * Pure.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+ /**
13
+ * Links known API item names in prose to their documentation routes. Matching
14
+ * is longest-name-first with word boundaries, skipping code spans and existing
15
+ * links.
16
+ *
17
+ * @public
18
+ */
19
+ var CrossLinker = class CrossLinker {
20
+ routesByName;
21
+ /** Names sorted longest-first so "HookEvent" matches before "Hook". */
22
+ orderedNames;
23
+ constructor(routesByName) {
24
+ this.routesByName = routesByName;
25
+ this.orderedNames = [...routesByName.keys()].sort((a, b) => b.length - a.length);
26
+ }
27
+ /**
28
+ * Build from a precomputed name → route map (member anchors and qualified
29
+ * names already baked into the routes). The primary pipeline path.
30
+ */
31
+ static fromRoutes(routes) {
32
+ return new CrossLinker(new Map(routes));
33
+ }
34
+ /**
35
+ * Build from item refs plus an injected {@link RouteFormatter}, so each
36
+ * consumer supplies its own URL scheme. Routes are evaluated eagerly at
37
+ * construction.
38
+ */
39
+ static fromRefs(refs, routeFor) {
40
+ return new CrossLinker(new Map(refs.map((r) => [r.name, routeFor(r)])));
41
+ }
42
+ /** The identity cross-linker: `link(text)` returns `text` unchanged. */
43
+ static empty = new CrossLinker(/* @__PURE__ */ new Map());
44
+ /** Wrap known item names in markdown links, skipping code spans + existing links. */
45
+ link(text) {
46
+ let result = text;
47
+ for (const name of this.orderedNames) {
48
+ const route = this.routesByName.get(name);
49
+ if (route === void 0) continue;
50
+ const regex = new RegExp(`\\b${escapeRegExp(name)}\\b`, "g");
51
+ result = result.replace(regex, (match, offset) => {
52
+ const before = result.slice(0, offset);
53
+ if (before.endsWith("](") || before.endsWith("[")) return match;
54
+ if ((before.match(/`/g) || []).length % 2 === 1) return match;
55
+ return `[${match}](${route})`;
56
+ });
57
+ }
58
+ return result;
59
+ }
60
+ /**
61
+ * Wrap known item names in HTML `<a>` anchors — for text rendered as HTML
62
+ * rather than markdown. Skips matches inside an open `<a>` tag.
63
+ */
64
+ linkHtml(text) {
65
+ let result = text;
66
+ for (const name of this.orderedNames) {
67
+ const route = this.routesByName.get(name);
68
+ if (route === void 0) continue;
69
+ const regex = new RegExp(`\\b${escapeRegExp(name)}\\b(?![a-zA-Z])`, "g");
70
+ result = result.replace(regex, (match, offset) => {
71
+ const beforeMatch = result.substring(0, offset);
72
+ if (beforeMatch.includes("<a") && !beforeMatch.includes("</a>")) return match;
73
+ return `<a href="${route}">${match}</a>`;
74
+ });
75
+ }
76
+ return result;
77
+ }
78
+ };
79
+
80
+ //#endregion
81
+ export { CrossLinker };
package/EntryPoints.js ADDED
@@ -0,0 +1,76 @@
1
+ import { __exportAll } from "./_virtual/_rolldown/runtime.js";
2
+
3
+ //#region src/EntryPoints.ts
4
+ var EntryPoints_exports = /* @__PURE__ */ __exportAll({
5
+ entryPointName: () => entryPointName,
6
+ resolve: () => resolve
7
+ });
8
+ /**
9
+ * Derive an entry point name from its display name in the API model.
10
+ *
11
+ * - Empty string (main entry `.` in package.json) maps to `"default"`
12
+ * - Named entries (e.g. `"testing"`) keep their name
13
+ *
14
+ * @public
15
+ */
16
+ function entryPointName(displayName) {
17
+ return displayName === "" ? "default" : displayName;
18
+ }
19
+ /**
20
+ * Create a stable identity key for an API item based on its display name and kind.
21
+ * Used to detect re-exports across entry points.
22
+ */
23
+ function itemKey(item) {
24
+ return `${item.displayName}::${item.kind}`;
25
+ }
26
+ /**
27
+ * Resolve all entry points from an API package into a flat list of
28
+ * deduplicated items.
29
+ *
30
+ * - Re-exported items (same displayName + kind across entries) are
31
+ * deduplicated to a single entry with `availableFrom` listing all
32
+ * entry points. The defining entry point prefers `"default"`.
33
+ * - Items with different kinds but the same displayName (e.g. the
34
+ * Effect const + type companion pattern) remain as separate entries.
35
+ *
36
+ * @param apiPackage - The merged API package with 1+ entry points
37
+ * @returns Flat array of resolved items
38
+ *
39
+ * @public
40
+ */
41
+ function resolve(apiPackage) {
42
+ const itemsByKey = /* @__PURE__ */ new Map();
43
+ for (const entryPoint of apiPackage.entryPoints) {
44
+ const epName = entryPointName(entryPoint.displayName);
45
+ for (const member of entryPoint.members) {
46
+ const key = itemKey(member);
47
+ const existing = itemsByKey.get(key) || [];
48
+ existing.push({
49
+ item: member,
50
+ entryPointName: epName
51
+ });
52
+ itemsByKey.set(key, existing);
53
+ }
54
+ }
55
+ const resolved = [];
56
+ for (const [, entries] of itemsByKey) if (entries.length === 1) {
57
+ const { item, entryPointName: epName } = entries[0];
58
+ resolved.push({
59
+ item,
60
+ definingEntryPoint: epName,
61
+ availableFrom: [epName]
62
+ });
63
+ } else {
64
+ const definingEntry = entries.find((e) => e.entryPointName === "default") || entries[0];
65
+ const allEntryPoints = [...new Set(entries.map((e) => e.entryPointName))];
66
+ resolved.push({
67
+ item: definingEntry.item,
68
+ definingEntryPoint: definingEntry.entryPointName,
69
+ availableFrom: allEntryPoints
70
+ });
71
+ }
72
+ return resolved;
73
+ }
74
+
75
+ //#endregion
76
+ export { EntryPoints_exports, entryPointName, resolve };
package/Model.js ADDED
@@ -0,0 +1,90 @@
1
+ import { __exportAll } from "./_virtual/_rolldown/runtime.js";
2
+ import { ApiModel } from "@microsoft/api-extractor-model";
3
+ import { existsSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { Effect, Schema } from "effect";
6
+
7
+ //#region src/Model.ts
8
+ /**
9
+ * Effect-typed loading of Microsoft API Extractor `.api.json` models.
10
+ *
11
+ * @remarks
12
+ * `load` deliberately requires no `FileSystem` service: the underlying
13
+ * `@microsoft/api-extractor-model` deserializer only exposes a file-path
14
+ * entry point (`ApiModel#loadPackage` / `ApiPackage.loadFromJsonFile`) and
15
+ * performs its own synchronous fs read — injecting a `FileSystem` the loader
16
+ * would silently bypass would be dishonest dependency injection. Failures are
17
+ * typed on the error channel instead.
18
+ *
19
+ * @packageDocumentation
20
+ */
21
+ var Model_exports = /* @__PURE__ */ __exportAll({
22
+ EmptyModelError: () => EmptyModelError,
23
+ ModelNotFoundError: () => ModelNotFoundError,
24
+ ModelParseError: () => ModelParseError,
25
+ firstPackage: () => firstPackage,
26
+ load: () => load
27
+ });
28
+ /**
29
+ * The `.api.json` file does not exist at the resolved path.
30
+ *
31
+ * @public
32
+ */
33
+ var ModelNotFoundError = class extends Schema.TaggedError()("ModelNotFoundError", { modelPath: Schema.String }) {
34
+ get message() {
35
+ return `API model file not found: ${this.modelPath}`;
36
+ }
37
+ };
38
+ /**
39
+ * The `.api.json` file exists but could not be deserialized (malformed JSON,
40
+ * unsupported schema version, …). `reason` carries the deserializer's message.
41
+ *
42
+ * @public
43
+ */
44
+ var ModelParseError = class extends Schema.TaggedError()("ModelParseError", {
45
+ modelPath: Schema.String,
46
+ reason: Schema.String
47
+ }) {
48
+ get message() {
49
+ return `Failed to load API model at ${this.modelPath}: ${this.reason}`;
50
+ }
51
+ };
52
+ /**
53
+ * An in-memory `ApiModel` carries no packages (or is otherwise unusable).
54
+ *
55
+ * @public
56
+ */
57
+ var EmptyModelError = class extends Schema.TaggedError()("EmptyModelError", { reason: Schema.String }) {
58
+ get message() {
59
+ return this.reason;
60
+ }
61
+ };
62
+ /**
63
+ * Load a `.api.json` model file and return its single `ApiPackage`.
64
+ *
65
+ * @public
66
+ */
67
+ const load = (modelPath) => Effect.suspend(() => {
68
+ const resolved = resolve(modelPath);
69
+ if (!existsSync(resolved)) return Effect.fail(new ModelNotFoundError({ modelPath: resolved }));
70
+ return Effect.try({
71
+ try: () => new ApiModel().loadPackage(resolved),
72
+ catch: (cause) => new ModelParseError({
73
+ modelPath: resolved,
74
+ reason: cause instanceof Error ? cause.message : String(cause)
75
+ })
76
+ });
77
+ });
78
+ /**
79
+ * Extract the first (only) package from an already-constructed `ApiModel` —
80
+ * the user-supplied-loader path, where the caller obtained the model itself.
81
+ *
82
+ * @public
83
+ */
84
+ const firstPackage = (model) => {
85
+ const pkg = model.packages[0];
86
+ return pkg !== void 0 ? Effect.succeed(pkg) : Effect.fail(new EmptyModelError({ reason: "API model contains no packages" }));
87
+ };
88
+
89
+ //#endregion
90
+ export { EmptyModelError, ModelNotFoundError, ModelParseError, Model_exports, firstPackage, load };
package/README.md CHANGED
@@ -1,102 +1,105 @@
1
1
  # @tsdoctor/model
2
2
 
3
- [![npm](https://img.shields.io/npm/v/@tsdoctor/model?label=npm&color=cb3837)](https://www.npmjs.com/package/@tsdoctor/model)
3
+ [![npm](https://img.shields.io/npm/v/@tsdoctor%2Fmodel?label=npm&color=cb3837)](https://www.npmjs.com/package/@tsdoctor/model)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
- [![Node.js](https://img.shields.io/badge/Node.js-5fa04e.svg)](https://nodejs.org/)
6
- [![TypeScript](https://img.shields.io/badge/TypeScript-3178c6.svg)](https://www.typescriptlang.org/)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](https://www.typescriptlang.org/)
7
7
 
8
- Turn a Microsoft API Extractor `.api.json` model into plain markdown that reads cleanly for both people and language models. You get one markdown document per top-level export an H1, the summary, a fenced `ts` signature, parameters, returns, container members and examples — with no site chrome, no HTML and no framework coupling.
8
+ 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 and markdown rendering.
9
9
 
10
10
  ## Why @tsdoctor/model
11
11
 
12
- API Extractor already understands your `.d.ts` surface. What it will not do is hand you docs you can drop into a prompt, a wiki or a static site. That is the gap this package closes. The body of every rendered doc stays the same no matter where it ends up. Two things change between consumers: the frontmatter block at the top and the URL scheme for cross-links. You inject both, so one renderer feeds an RSPress site, an MCP server or a folder of bare `.md` files.
12
+ 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.
13
13
 
14
14
  ## Install
15
15
 
16
16
  ```bash
17
17
  npm install @tsdoctor/model
18
- # or
18
+ ```
19
+
20
+ ```bash
19
21
  pnpm add @tsdoctor/model
20
22
  ```
21
23
 
22
- The package is also published to GitHub Packages as `@spencerbeggs/@tsdoctor/model` if you prefer the scoped name; point your registry at `https://npm.pkg.github.com` for that scope and install it the same way.
24
+ Requires Node.js >=24.11.0. This is an ESM-only package.
23
25
 
24
- This is an ESM-only package. Import it from a module context (`"type": "module"` or `.mjs`).
26
+ `@effected/markdown` and `effect` are required peers (`Render` builds its output as `@effected/markdown` node trees). `@effected/package-json` is an optional peer, needed only by the `StructuredData` seam.
25
27
 
26
28
  ## Quick start
27
29
 
28
- Load a model, render it, then write each doc wherever you want. The library does no I/O of its own. File writing is yours.
30
+ 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.
29
31
 
30
32
  ```ts
31
33
  import { mkdir, writeFile } from "node:fs/promises";
32
- import { loadApiModel, renderPackage } from "@tsdoctor/model";
34
+ import { Effect } from "effect";
35
+ import { Model, Render } from "@tsdoctor/model";
33
36
 
34
- const pkg = await loadApiModel("./temp/my-pkg.api.json");
37
+ const program = Effect.gen(function* () {
38
+ const pkg = yield* Model.load("./temp/my-pkg.api.json");
35
39
 
36
- const docs = renderPackage(pkg, {
37
- packageName: "my-pkg",
38
- routeFor: (ref) => `/api/${ref.slug}`,
39
- });
40
+ const docs = Render.docs(pkg, {
41
+ packageName: "my-pkg",
42
+ routeFor: (ref) => `/api/${ref.slug}`,
43
+ frontmatter: (meta) => `---\ntitle: ${meta.name}\nkind: ${meta.kind}\n---\n\n`,
44
+ });
45
+
46
+ yield* Effect.promise(() => mkdir("./out", { recursive: true }));
47
+ for (const doc of docs) {
48
+ yield* Effect.promise(() => writeFile(`./out/${doc.slug}.md`, doc.markdown));
49
+ }
40
50
 
41
- await mkdir("./out", { recursive: true });
42
- for (const doc of docs) {
43
- await writeFile(`./out/${doc.slug}.md`, doc.markdown);
44
- }
51
+ return docs;
52
+ });
45
53
 
54
+ const docs = await Effect.runPromise(program);
46
55
  console.log(docs.map((d) => `${d.kind}: ${d.name}`));
47
- // e.g. [ 'function: loadApiModel', 'class: CrossLinker', 'type: RenderedDoc' ]
56
+ // e.g. [ 'function: load', 'class: CrossLinker', 'interface: RenderedDoc' ]
48
57
  // (the actual list depends on your model's exports)
49
58
  ```
50
59
 
51
- Each entry in the returned array is a `RenderedDoc` with `name`, `kind`, `slug`, `summary`, `packageName` and the assembled `markdown`.
60
+ `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.
61
+
62
+ `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.
52
63
 
53
- ### Inject frontmatter
64
+ ### Render a single item
54
65
 
55
- Pass a `frontmatter` function to prepend a blockYAML, TOML or whatever your target expects. Omit it for bare bodies.
66
+ `Render.item` renders one `ApiItem` without walking the whole packageuseful when a caller already has categorized items (via `ApiItems.categorize`) and wants to drive its own page-assembly loop.
56
67
 
57
68
  ```ts
58
- const docs = renderPackage(pkg, {
59
- packageName: "my-pkg",
60
- frontmatter: (meta) => `---\ntitle: ${meta.name}\nkind: ${meta.kind}\n---\n\n`,
61
- });
69
+ import { Render } from "@tsdoctor/model";
62
70
 
63
- console.log(docs[0].markdown.startsWith("---"));
64
- // true
71
+ const body = Render.item(apiItem, { packageName: "my-pkg" });
72
+ console.log(body.startsWith("#"));
73
+ // true — every rendered body opens with an H1 of the item's display name
65
74
  ```
66
75
 
67
- `routeFor` and `frontmatter` are independent. Supply either, both or neither.
68
-
69
- ### Filter exports
70
-
71
- By default `renderPackage` drops compiler-synthetic forgotten exports — the `*_base` classes TypeScript hoists for Effect class mixins, which API Extractor keeps in the model when it runs with `includeForgottenExports: true`. They stay in the `.api.json` (downstream `.d.ts` reconstruction needs them) but never reach the rendered markdown. That default lives in the exported `isEmittable` predicate.
76
+ ### Filter which items get a page
72
77
 
73
- Pass your own `filter` to change which top-level items are emitted. A filter that returns `true` keeps the item, both as a rendered doc and as a crosslink target. Supplying a `filter` fully replaces the default, so compose it with `isEmittable` when you want to keep the forgotten-export drop alongside your own rule.
78
+ 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.
74
79
 
75
80
  ```ts
76
- import { isEmittable, renderPackage } from "@tsdoctor/model";
81
+ import { Render } from "@tsdoctor/model";
77
82
 
78
- const docs = renderPackage(pkg, {
83
+ const docs = Render.docs(pkg, {
79
84
  packageName: "my-pkg",
80
- filter: (item) => isEmittable(item) && !item.displayName.startsWith("Internal"),
85
+ filter: (item) => Render.isEmittable(item) && !item.displayName.startsWith("Internal"),
81
86
  });
82
-
83
- console.log(docs.every((d) => !d.name.startsWith("Internal")));
84
- // true
85
87
  ```
86
88
 
87
- ## Features
88
-
89
- - `loadApiModel(path)` reads a `.api.json` file from disk and returns its `ApiPackage`.
90
- - `renderPackage(pkg, opts)` walks the first entry point and returns one `RenderedDoc` per top-level member, dropping compiler-synthetic forgotten exports unless you pass your own `filter`.
91
- - `renderItem(item, opts)` renders a single API item to a markdown body, with an optional `CrossLinker`.
92
- - `isEmittable(item)` is the default emit rule — it drops forgotten exports (`isExported === false`) and keeps everything else; compose it into a custom `filter` to keep that behaviour.
93
- - `CrossLinker` wraps known item names in prose with links, skipping code spans and existing links, using your injected route scheme.
94
- - `TypeSignatureFormatter` formats an API Extractor `Excerpt` into a clean type signature, wrapping long unions across lines.
95
- - TSDoc helpers — `getSummary`, `getParams`, `getReturns`, `getExamples`, `getDeprecation`, `getReleaseTag`, `hasModifierTag` and `extractPlainText` — pull plain data off an `ApiItem` with no rendering.
89
+ Supplying `filter` fully replaces the default, so compose it with `Render.isEmittable` to keep the forgotten-export drop.
96
90
 
97
- ## Generating a model
91
+ ## Features
98
92
 
99
- This package consumes the JSON that [Microsoft API Extractor](https://api-extractor.com/) produces. Run API Extractor against your project first with `docModel.enabled` set in your `api-extractor.json`, then feed the resulting `.api.json` to `loadApiModel`.
93
+ - **`Model`** Effect-typed `.api.json` loading. `Model.load(path)` returns the package's `ApiPackage` or fails with `ModelNotFoundError` / `ModelParseError`; `Model.firstPackage(apiModel)` extracts a package from a caller-constructed `ApiModel`, failing with `EmptyModelError` if it has none.
94
+ - **`Tsdoc`** — pure extraction off an `ApiItem`: `summary`, `params`, `returns`, `examples`, `deprecation`, `releaseTag`, `hasModifier`, `seeReferences`, plus `plainText`/`toMarkdown` for walking a raw TSDoc `DocNode` tree yourself.
95
+ - **`ApiItems`** — `categorize(items, categories)` groups top-level items by category key (returning `{ items, uncategorized }` so the caller decides how to handle the leftovers), `namespaceMembers` flattens namespace contents with qualified names, `inheritance` reads extends/implements, `sourceLink` builds a source-code URL.
96
+ - **`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.
97
+ - **`Routes`** — `RouteCandidate`, `detectCollisions`, and the typed `RouteCollisionError` for failing a build when two distinct items would resolve to the same output route; `sanitizeId` is the single anchor-id sanitizer for member routes.
98
+ - **`SyntheticBases`** — `detect(items)` finds the unexported `*_base` declarations an exported class's `extends` clause references, so an adapter can inline them instead of generating (or silently dropping) a page for them; `BASE_CLASS_ANCHOR` is the matching anchor id.
99
+ - **`Signature`** — `format(excerpt)` turns an API Extractor `Excerpt` into a clean, line-wrapped type signature string; `stripExportDeclare` strips `export`/`declare` modifiers from declaration text.
100
+ - **`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`); `link` returns markdown links, `linkHtml` returns `<a>` anchors.
101
+ - **`Render`** — the markdown output system. `Render.docs(pkg, opts)` renders a whole package; `Render.item(apiItem, opts)` renders one item; `Render.isEmittable` is the default emit rule. `Render.tree` (`@alpha`) exposes the pre-serialization `@effected/markdown` node tree for a future page-IR consumer.
102
+ - **`StructuredData`** (`@alpha`) — a reserved seam for schema.org JSON-LD derivation. `StructuredData.derive` is not implemented yet and throws if called.
100
103
 
101
104
  ## License
102
105