@tsdoctor/model 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # @tsdoctor/model
2
+
3
+ [![npm](https://img.shields.io/npm/v/@tsdoctor/model?label=npm&color=cb3837)](https://www.npmjs.com/package/@tsdoctor/model)
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/)
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.
9
+
10
+ ## Why @tsdoctor/model
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.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install @tsdoctor/model
18
+ # or
19
+ pnpm add @tsdoctor/model
20
+ ```
21
+
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.
23
+
24
+ This is an ESM-only package. Import it from a module context (`"type": "module"` or `.mjs`).
25
+
26
+ ## Quick start
27
+
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.
29
+
30
+ ```ts
31
+ import { mkdir, writeFile } from "node:fs/promises";
32
+ import { loadApiModel, renderPackage } from "@tsdoctor/model";
33
+
34
+ const pkg = await loadApiModel("./temp/my-pkg.api.json");
35
+
36
+ const docs = renderPackage(pkg, {
37
+ packageName: "my-pkg",
38
+ routeFor: (ref) => `/api/${ref.slug}`,
39
+ });
40
+
41
+ await mkdir("./out", { recursive: true });
42
+ for (const doc of docs) {
43
+ await writeFile(`./out/${doc.slug}.md`, doc.markdown);
44
+ }
45
+
46
+ console.log(docs.map((d) => `${d.kind}: ${d.name}`));
47
+ // e.g. [ 'function: loadApiModel', 'class: CrossLinker', 'type: RenderedDoc' ]
48
+ // (the actual list depends on your model's exports)
49
+ ```
50
+
51
+ Each entry in the returned array is a `RenderedDoc` with `name`, `kind`, `slug`, `summary`, `packageName` and the assembled `markdown`.
52
+
53
+ ### Inject frontmatter
54
+
55
+ Pass a `frontmatter` function to prepend a block — YAML, TOML or whatever your target expects. Omit it for bare bodies.
56
+
57
+ ```ts
58
+ const docs = renderPackage(pkg, {
59
+ packageName: "my-pkg",
60
+ frontmatter: (meta) => `---\ntitle: ${meta.name}\nkind: ${meta.kind}\n---\n\n`,
61
+ });
62
+
63
+ console.log(docs[0].markdown.startsWith("---"));
64
+ // true
65
+ ```
66
+
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.
72
+
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.
74
+
75
+ ```ts
76
+ import { isEmittable, renderPackage } from "@tsdoctor/model";
77
+
78
+ const docs = renderPackage(pkg, {
79
+ packageName: "my-pkg",
80
+ filter: (item) => isEmittable(item) && !item.displayName.startsWith("Internal"),
81
+ });
82
+
83
+ console.log(docs.every((d) => !d.name.startsWith("Internal")));
84
+ // true
85
+ ```
86
+
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.
96
+
97
+ ## Generating a model
98
+
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`.
100
+
101
+ ## License
102
+
103
+ [MIT](LICENSE)
@@ -0,0 +1,37 @@
1
+ //#region src/cross-linker.ts
2
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3
+ /**
4
+ * Links known API item names in prose to their docs, using an injected
5
+ * {@link RouteFormatter} so each consumer supplies its own URL scheme.
6
+ *
7
+ * @public
8
+ */
9
+ var CrossLinker = class {
10
+ byName;
11
+ routeFor;
12
+ constructor(refs, routeFor) {
13
+ this.byName = new Map(refs.map((r) => [r.name, r]));
14
+ this.routeFor = routeFor;
15
+ }
16
+ /** Wrap known item names in markdown links, skipping code spans + existing links. */
17
+ addLinks(text) {
18
+ let result = text;
19
+ const names = [...this.byName.keys()].sort((a, b) => b.length - a.length);
20
+ for (const name of names) {
21
+ const ref = this.byName.get(name);
22
+ if (!ref) continue;
23
+ const route = this.routeFor(ref);
24
+ const regex = new RegExp(`\\b${escapeRegExp(name)}\\b`, "g");
25
+ result = result.replace(regex, (match, offset) => {
26
+ const before = result.slice(0, offset);
27
+ if (before.endsWith("](") || before.endsWith("[")) return match;
28
+ if ((before.match(/`/g) || []).length % 2 === 1) return match;
29
+ return `[${match}](${route})`;
30
+ });
31
+ }
32
+ return result;
33
+ }
34
+ };
35
+
36
+ //#endregion
37
+ export { CrossLinker };
package/formatter.js ADDED
@@ -0,0 +1,70 @@
1
+ //#region src/formatter.ts
2
+ /**
3
+ * Formats an API Extractor `Excerpt` into a clean, line-wrapped type signature string.
4
+ *
5
+ * @public
6
+ */
7
+ var TypeSignatureFormatter = class {
8
+ maxLineLength;
9
+ indent;
10
+ constructor(opts = {}) {
11
+ this.maxLineLength = opts.maxLineLength ?? 80;
12
+ this.indent = opts.indent ?? " ";
13
+ }
14
+ format(excerpt) {
15
+ if (!excerpt.spannedTokens || excerpt.spannedTokens.length === 0) return this.stripExportDeclare(excerpt.text);
16
+ const tokens = excerpt.spannedTokens;
17
+ let currentLine = "";
18
+ const lines = [];
19
+ let bracketDepth = 0;
20
+ let lastTokenText = "";
21
+ for (let i = 0; i < tokens.length; i++) {
22
+ let tokenText = tokens[i].text;
23
+ if (i === 0) tokenText = this.stripExportDeclare(tokenText);
24
+ if (tokenText.trim() === "") continue;
25
+ if (tokenText === "{" || tokenText === "[" || tokenText === "(") bracketDepth++;
26
+ else if (tokenText === "}" || tokenText === "]" || tokenText === ")") bracketDepth--;
27
+ const isOperator = tokenText.trim() === "|" || tokenText.trim() === "&";
28
+ if (lastTokenText && this.needsSpaceBefore(lastTokenText, tokenText)) currentLine += " ";
29
+ currentLine += tokenText;
30
+ lastTokenText = tokenText;
31
+ if (isOperator && bracketDepth === 0 && currentLine.length > this.maxLineLength && i < tokens.length - 1) {
32
+ lines.push(currentLine.trimEnd());
33
+ currentLine = this.indent;
34
+ }
35
+ }
36
+ if (currentLine.trim()) lines.push(currentLine.trimEnd());
37
+ if (lines.length <= 1) return lines.length === 1 ? lines[0].trimStart() : "";
38
+ return this.stripExportDeclare(lines.join("\n"));
39
+ }
40
+ stripExportDeclare(text) {
41
+ let result = text.trim().replace(/^export\s+declare\s+/i, "").replace(/^export\s+/i, "").replace(/^declare\s+/i, "");
42
+ result = result.replace(/\bexport\s+declare\s+/gi, "").replace(/\bexport\s+/gi, "").replace(/\bdeclare\s+/gi, "");
43
+ return result;
44
+ }
45
+ needsSpaceBefore(prevText, currentText) {
46
+ if (/\s$/.test(prevText)) return false;
47
+ if (/^\s/.test(currentText)) return false;
48
+ if (currentText.trim().startsWith("<")) return false;
49
+ if (currentText.trim().match(/^[,;]/)) return false;
50
+ if (prevText.trim().endsWith(",")) return true;
51
+ if (currentText.trim() === "=" || currentText.trim().startsWith("=")) return true;
52
+ if (prevText.trim().endsWith("=")) return true;
53
+ if (currentText.trim() === "|" || currentText.trim() === "&") return true;
54
+ if (prevText.trim() === "|" || prevText.trim() === "&") return true;
55
+ if (prevText.trim() === "{" && currentText.trim() !== "}") return true;
56
+ if (currentText.trim() === "}" && prevText.trim() !== "{") return true;
57
+ if (prevText.trim().match(/^[[(]$/)) return false;
58
+ if (currentText.trim().match(/^[\])]$/)) return false;
59
+ if (prevText.trim().endsWith(":")) return true;
60
+ if (prevText.trim().endsWith("?:")) return true;
61
+ if (currentText.trim().startsWith(":") && !prevText.trim().match(/[,;:?]$/)) return false;
62
+ if (currentText.trim().startsWith("{") && /[a-zA-Z0-9_>]$/.test(prevText.trim())) return true;
63
+ const prevEndsAlnum = /[a-zA-Z0-9_>]$/.test(prevText.trim());
64
+ const currStartsAlnum = /^[a-zA-Z0-9_<]/.test(currentText.trim());
65
+ return prevEndsAlnum && currStartsAlnum;
66
+ }
67
+ };
68
+
69
+ //#endregion
70
+ export { TypeSignatureFormatter };
package/index.d.ts ADDED
@@ -0,0 +1,211 @@
1
+ import { ApiItem, ApiPackage, Excerpt } from "@microsoft/api-extractor-model";
2
+ import { DocNode } from "@microsoft/tsdoc";
3
+ //#region src/types.d.ts
4
+ /**
5
+ * URL-stable slug for an item kind, used in routes and generated doc paths.
6
+ *
7
+ * @public
8
+ */
9
+ type ItemKindSlug = "class" | "interface" | "function" | "type" | "variable" | "enum" | "namespace";
10
+ /**
11
+ * A reference to a renderable top-level API item.
12
+ *
13
+ * @public
14
+ */
15
+ interface ApiItemRef {
16
+ readonly name: string;
17
+ readonly kind: ItemKindSlug;
18
+ /** Lowercased name for the file/url, e.g. "managedsection". */
19
+ readonly slug: string;
20
+ }
21
+ /**
22
+ * Metadata handed to the injected frontmatter renderer for one doc.
23
+ *
24
+ * @public
25
+ */
26
+ interface DocMeta extends ApiItemRef {
27
+ readonly summary: string;
28
+ readonly packageName: string;
29
+ }
30
+ /**
31
+ * Injected: turn an item reference into a crosslink URL (the only scheme difference).
32
+ *
33
+ * @public
34
+ */
35
+ type RouteFormatter = (ref: ApiItemRef) => string;
36
+ /**
37
+ * Injected: produce the frontmatter block (incl. trailing blank line) for a doc, or "".
38
+ *
39
+ * @public
40
+ */
41
+ type FrontmatterRenderer = (meta: DocMeta) => string;
42
+ /**
43
+ * One rendered API doc = its metadata plus the assembled markdown (frontmatter + body).
44
+ *
45
+ * @public
46
+ */
47
+ interface RenderedDoc extends DocMeta {
48
+ readonly markdown: string;
49
+ }
50
+ /**
51
+ * Options for {@link renderPackage}: the package name plus the optional injected
52
+ * route, frontmatter, and filter services.
53
+ *
54
+ * @public
55
+ */
56
+ interface RenderPackageOptions {
57
+ /** Package display name (used in fallbacks + handed to the frontmatter renderer). */
58
+ readonly packageName: string;
59
+ /** Injected crosslink scheme. Omit → no cross-linking. */
60
+ readonly routeFor?: RouteFormatter;
61
+ /** Injected frontmatter. Omit → bodies only. */
62
+ readonly frontmatter?: FrontmatterRenderer;
63
+ /**
64
+ * Predicate deciding whether a top-level item is emitted (and registered as a
65
+ * crosslink target). Returns `true` to keep the item. Omit → the default rule
66
+ * {@link isEmittable} drops compiler-synthetic forgotten exports
67
+ * (`isExported === false`). Providing a filter fully replaces the default; compose
68
+ * with {@link isEmittable} to retain the forgotten-export drop.
69
+ */
70
+ readonly filter?: (item: ApiItem) => boolean;
71
+ }
72
+ //#endregion
73
+ //#region src/cross-linker.d.ts
74
+ /**
75
+ * Links known API item names in prose to their docs, using an injected
76
+ * {@link RouteFormatter} so each consumer supplies its own URL scheme.
77
+ *
78
+ * @public
79
+ */
80
+ declare class CrossLinker {
81
+ private readonly byName;
82
+ private readonly routeFor;
83
+ constructor(refs: ReadonlyArray<ApiItemRef>, routeFor: RouteFormatter);
84
+ /** Wrap known item names in markdown links, skipping code spans + existing links. */
85
+ addLinks(text: string): string;
86
+ }
87
+ //#endregion
88
+ //#region src/formatter.d.ts
89
+ /**
90
+ * Formats an API Extractor `Excerpt` into a clean, line-wrapped type signature string.
91
+ *
92
+ * @public
93
+ */
94
+ declare class TypeSignatureFormatter {
95
+ private readonly maxLineLength;
96
+ private readonly indent;
97
+ constructor(opts?: {
98
+ maxLineLength?: number;
99
+ indent?: string;
100
+ });
101
+ format(excerpt: Excerpt): string;
102
+ private stripExportDeclare;
103
+ private needsSpaceBefore;
104
+ }
105
+ //#endregion
106
+ //#region src/model-loader.d.ts
107
+ /**
108
+ * Load a `.api.json` model file and return its first (only) package.
109
+ *
110
+ * @public
111
+ */
112
+ declare function loadApiModel(modelPath: string): Promise<ApiPackage>;
113
+ //#endregion
114
+ //#region src/render.d.ts
115
+ /**
116
+ * The default emit rule for {@link renderPackage}: drop compiler-synthetic
117
+ * forgotten exports — items the model retains only because API Extractor ran with
118
+ * `includeForgottenExports: true` (e.g. the `*_base` classes TypeScript hoists for
119
+ * Effect class mixins). Those carry `isExported === false` on `ApiExportedMixin`.
120
+ * Every other item, including any lacking the flag, is kept.
121
+ *
122
+ * @public
123
+ */
124
+ declare const isEmittable: (item: ApiItem) => boolean;
125
+ /**
126
+ * Options for {@link renderItem}: the package name used in fallbacks and an
127
+ * optional crosslinker applied to the rendered prose.
128
+ *
129
+ * @public
130
+ */
131
+ interface RenderItemOptions {
132
+ readonly packageName: string;
133
+ /** Optional crosslinker applied to prose (summaries, params, returns, deprecation). */
134
+ readonly crossLinker?: CrossLinker;
135
+ }
136
+ /**
137
+ * Render one API item to a markdown body (no frontmatter).
138
+ *
139
+ * @public
140
+ */
141
+ declare function renderItem(item: ApiItem, opts: RenderItemOptions): string;
142
+ /**
143
+ * Walk a package's first entry point and assemble one RenderedDoc per top-level member.
144
+ *
145
+ * @public
146
+ */
147
+ declare function renderPackage(apiPackage: ApiPackage, opts: RenderPackageOptions): RenderedDoc[];
148
+ //#endregion
149
+ //#region src/tsdoc.d.ts
150
+ /**
151
+ * Recursively flatten a TSDoc DocNode tree to plain text (code spans → backticks).
152
+ *
153
+ * @public
154
+ */
155
+ declare function extractPlainText(node: DocNode): string;
156
+ /**
157
+ * The TSDoc `@summary` section as a single cleaned line.
158
+ *
159
+ * @public
160
+ */
161
+ declare function getSummary(item: ApiItem): string;
162
+ /**
163
+ * `@param` blocks merged with parameter types from the declaration excerpt.
164
+ *
165
+ * @public
166
+ */
167
+ declare function getParams(item: ApiItem): Array<{
168
+ name: string;
169
+ type?: string;
170
+ description: string;
171
+ }>;
172
+ /**
173
+ * The `@returns` block description, if present.
174
+ *
175
+ * @public
176
+ */
177
+ declare function getReturns(item: ApiItem): {
178
+ description: string;
179
+ } | null;
180
+ /**
181
+ * All `@example` fenced-code blocks (falls back to plain text).
182
+ *
183
+ * @public
184
+ */
185
+ declare function getExamples(item: ApiItem): Array<{
186
+ language: string;
187
+ code: string;
188
+ }>;
189
+ /**
190
+ * Reads the deprecation-block message from an ApiItem, if one is present.
191
+ *
192
+ * @public
193
+ */
194
+ declare function getDeprecation(item: ApiItem): {
195
+ message: string;
196
+ } | null;
197
+ /**
198
+ * The release tag (Public/Beta/Alpha/Internal) or "Public" when absent.
199
+ *
200
+ * @public
201
+ */
202
+ declare function getReleaseTag(item: ApiItem): string;
203
+ /**
204
+ * True when the item carries the given TSDoc modifier tag (without the `@`).
205
+ *
206
+ * @public
207
+ */
208
+ declare function hasModifierTag(item: ApiItem, tagName: string): boolean;
209
+ //#endregion
210
+ export { type ApiItemRef, CrossLinker, type DocMeta, type FrontmatterRenderer, type ItemKindSlug, type RenderItemOptions, type RenderPackageOptions, type RenderedDoc, type RouteFormatter, TypeSignatureFormatter, extractPlainText, getDeprecation, getExamples, getParams, getReleaseTag, getReturns, getSummary, hasModifierTag, isEmittable, loadApiModel, renderItem, renderPackage };
211
+ //# sourceMappingURL=index.d.ts.map
package/index.js ADDED
@@ -0,0 +1,7 @@
1
+ import { CrossLinker } from "./cross-linker.js";
2
+ import { TypeSignatureFormatter } from "./formatter.js";
3
+ import { loadApiModel } from "./model-loader.js";
4
+ import { extractPlainText, getDeprecation, getExamples, getParams, getReleaseTag, getReturns, getSummary, hasModifierTag } from "./tsdoc.js";
5
+ import { isEmittable, renderItem, renderPackage } from "./render.js";
6
+
7
+ export { CrossLinker, TypeSignatureFormatter, extractPlainText, getDeprecation, getExamples, getParams, getReleaseTag, getReturns, getSummary, hasModifierTag, isEmittable, loadApiModel, renderItem, renderPackage };
@@ -0,0 +1,24 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { ApiModel } from "@microsoft/api-extractor-model";
4
+
5
+ //#region src/model-loader.ts
6
+ /**
7
+ * Load a Microsoft API Extractor `.api.json` model from disk and return its
8
+ * single `ApiPackage`. Ported from rspress-plugin-api-extractor's ApiModelLoader.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+ /**
13
+ * Load a `.api.json` model file and return its first (only) package.
14
+ *
15
+ * @public
16
+ */
17
+ async function loadApiModel(modelPath) {
18
+ const resolved = resolve(modelPath);
19
+ if (!existsSync(resolved)) throw new Error(`API model file not found: ${resolved}`);
20
+ return new ApiModel().loadPackage(resolved);
21
+ }
22
+
23
+ //#endregion
24
+ export { loadApiModel };
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@tsdoctor/model",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Render Microsoft API Extractor models into LLM-lean markdown. Pure model loading, TSDoc extraction, type-signature formatting, and per-item markdown rendering.",
6
+ "keywords": [
7
+ "api-extractor",
8
+ "tsdoc",
9
+ "llms",
10
+ "documentation",
11
+ "markdown",
12
+ "typescript",
13
+ "esm"
14
+ ],
15
+ "homepage": "https://github.com/spencerbeggs/tsdoctor#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/spencerbeggs/tsdoctor/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/spencerbeggs/tsdoctor.git",
22
+ "directory": "packages/model"
23
+ },
24
+ "license": "MIT",
25
+ "author": {
26
+ "name": "C. Spencer Beggs",
27
+ "email": "spencer@beggs.codes",
28
+ "url": "https://spencerbeg.gs"
29
+ },
30
+ "sideEffects": false,
31
+ "type": "module",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./index.d.ts",
35
+ "import": "./index.js",
36
+ "default": "./index.js"
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "dependencies": {
41
+ "@microsoft/api-extractor-model": "^7.33.10",
42
+ "@microsoft/tsdoc": "^0.16.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=24.11.0"
46
+ }
47
+ }
package/render.js ADDED
@@ -0,0 +1,118 @@
1
+ import { CrossLinker } from "./cross-linker.js";
2
+ import { TypeSignatureFormatter } from "./formatter.js";
3
+ import { getDeprecation, getExamples, getParams, getReturns, getSummary } from "./tsdoc.js";
4
+ import { ApiItemContainerMixin } from "@microsoft/api-extractor-model";
5
+
6
+ //#region src/render.ts
7
+ const KIND_SLUG = {
8
+ Class: "class",
9
+ Interface: "interface",
10
+ Function: "function",
11
+ TypeAlias: "type",
12
+ Variable: "variable",
13
+ Enum: "enum",
14
+ Namespace: "namespace"
15
+ };
16
+ /**
17
+ * The default emit rule for {@link renderPackage}: drop compiler-synthetic
18
+ * forgotten exports — items the model retains only because API Extractor ran with
19
+ * `includeForgottenExports: true` (e.g. the `*_base` classes TypeScript hoists for
20
+ * Effect class mixins). Those carry `isExported === false` on `ApiExportedMixin`.
21
+ * Every other item, including any lacking the flag, is kept.
22
+ *
23
+ * @public
24
+ */
25
+ const isEmittable = (item) => item.isExported !== false;
26
+ const formatter = new TypeSignatureFormatter();
27
+ const signatureOf = (item) => {
28
+ const declared = item;
29
+ return declared.excerpt?.text ? formatter.format(declared.excerpt).trim() : "";
30
+ };
31
+ /**
32
+ * Render one API item to a markdown body (no frontmatter).
33
+ *
34
+ * @public
35
+ */
36
+ function renderItem(item, opts) {
37
+ const link = (text) => opts.crossLinker ? opts.crossLinker.addLinks(text) : text;
38
+ const lines = [`# ${item.displayName}`, ""];
39
+ const deprecation = getDeprecation(item);
40
+ if (deprecation) lines.push(`> **Deprecated:** ${link(deprecation.message)}`, "");
41
+ const summary = getSummary(item);
42
+ if (summary) lines.push(link(summary), "");
43
+ const signature = signatureOf(item);
44
+ if (signature) lines.push("```ts", signature, "```", "");
45
+ const params = getParams(item);
46
+ if (params.length > 0) {
47
+ lines.push("## Parameters", "");
48
+ for (const p of params) {
49
+ const type = p.type ? ` \`${p.type}\`` : "";
50
+ const desc = p.description ? ` — ${link(p.description)}` : "";
51
+ lines.push(`- \`${p.name}\`${type}${desc}`);
52
+ }
53
+ lines.push("");
54
+ }
55
+ const returns = getReturns(item);
56
+ if (returns) lines.push("## Returns", "", link(returns.description), "");
57
+ const members = item.members;
58
+ if (Array.isArray(members) && members.length > 0 && ApiItemContainerMixin.isBaseClassOf(item)) {
59
+ lines.push("## Members", "");
60
+ for (const m of members) {
61
+ const sig = signatureOf(m);
62
+ const mSummary = getSummary(m);
63
+ lines.push(`### ${m.displayName}`, "");
64
+ if (sig) lines.push("```ts", sig, "```", "");
65
+ if (mSummary) lines.push(link(mSummary), "");
66
+ }
67
+ }
68
+ const examples = getExamples(item);
69
+ if (examples.length > 0) {
70
+ lines.push("## Examples", "");
71
+ for (const ex of examples) lines.push(`\`\`\`${ex.language}`, ex.code, "```", "");
72
+ }
73
+ return `${lines.join("\n").trim()}\n`;
74
+ }
75
+ /**
76
+ * Walk a package's first entry point and assemble one RenderedDoc per top-level member.
77
+ *
78
+ * @public
79
+ */
80
+ function renderPackage(apiPackage, opts) {
81
+ const entryPoint = apiPackage.entryPoints[0];
82
+ if (!entryPoint) return [];
83
+ const keep = opts.filter ?? isEmittable;
84
+ const pairs = [];
85
+ for (const member of entryPoint.members) {
86
+ const kind = KIND_SLUG[member.kind];
87
+ if (kind === void 0) continue;
88
+ if (!keep(member)) continue;
89
+ pairs.push({
90
+ item: member,
91
+ ref: {
92
+ name: member.displayName,
93
+ kind,
94
+ slug: member.displayName.toLowerCase()
95
+ }
96
+ });
97
+ }
98
+ const crossLinker = opts.routeFor ? new CrossLinker(pairs.map((p) => p.ref), opts.routeFor) : void 0;
99
+ return pairs.map(({ item, ref }) => {
100
+ const body = renderItem(item, {
101
+ packageName: opts.packageName,
102
+ ...crossLinker ? { crossLinker } : {}
103
+ });
104
+ const meta = {
105
+ ...ref,
106
+ summary: getSummary(item),
107
+ packageName: opts.packageName
108
+ };
109
+ const frontmatter = opts.frontmatter ? opts.frontmatter(meta) : "";
110
+ return {
111
+ ...meta,
112
+ markdown: frontmatter + body
113
+ };
114
+ });
115
+ }
116
+
117
+ //#endregion
118
+ export { isEmittable, renderItem, renderPackage };
@@ -0,0 +1,11 @@
1
+ // This file is read by tools that parse documentation comments conforming to the TSDoc standard.
2
+ // It should be published with your NPM package. It should not be tracked by Git.
3
+ {
4
+ "tsdocVersion": "0.12",
5
+ "toolPackages": [
6
+ {
7
+ "packageName": "@microsoft/api-extractor",
8
+ "packageVersion": "7.59.0"
9
+ }
10
+ ]
11
+ }
package/tsdoc.js ADDED
@@ -0,0 +1,158 @@
1
+ import { ApiDocumentedItem, ApiReleaseTagMixin, ReleaseTag } from "@microsoft/api-extractor-model";
2
+
3
+ //#region src/tsdoc.ts
4
+ /**
5
+ * Recursively flatten a TSDoc DocNode tree to plain text (code spans → backticks).
6
+ *
7
+ * @public
8
+ */
9
+ function extractPlainText(node) {
10
+ const nodeAny = node;
11
+ if (node.kind === "PlainText") return nodeAny.text || "";
12
+ if (node.kind === "SoftBreak") return " ";
13
+ if (node.kind === "CodeSpan") return `\`${nodeAny.code || ""}\``;
14
+ if (node.kind === "LinkTag") {
15
+ if (nodeAny.linkText) return extractPlainText(nodeAny.linkText);
16
+ return (nodeAny.codeDestination?.memberReferences?.[0]?.memberIdentifier)?.identifier || "";
17
+ }
18
+ const parts = [];
19
+ if (typeof nodeAny.getChildNodes === "function") for (const child of nodeAny.getChildNodes()) {
20
+ const childText = extractPlainText(child);
21
+ if (childText) parts.push(childText);
22
+ }
23
+ return parts.join("");
24
+ }
25
+ /**
26
+ * The TSDoc `@summary` section as a single cleaned line.
27
+ *
28
+ * @public
29
+ */
30
+ function getSummary(item) {
31
+ if (item instanceof ApiDocumentedItem) {
32
+ const tsdoc = item.tsdocComment;
33
+ if (tsdoc?.summarySection) return extractPlainText(tsdoc.summarySection).replace(/\s+/g, " ").trim();
34
+ }
35
+ return "";
36
+ }
37
+ /**
38
+ * `@param` blocks merged with parameter types from the declaration excerpt.
39
+ *
40
+ * @public
41
+ */
42
+ function getParams(item) {
43
+ const out = [];
44
+ const paramTypes = /* @__PURE__ */ new Map();
45
+ const parameters = item.parameters;
46
+ if (Array.isArray(parameters)) for (const param of parameters) {
47
+ const excerpt = param.parameterTypeExcerpt;
48
+ const name = param.name || "";
49
+ if (excerpt?.text) paramTypes.set(name, String(excerpt.text).trim());
50
+ }
51
+ if (item instanceof ApiDocumentedItem) {
52
+ const tsdoc = item.tsdocComment;
53
+ if (tsdoc?.params) {
54
+ for (const block of tsdoc.params.blocks) {
55
+ const blockAny = block;
56
+ const name = blockAny.parameterName || "";
57
+ const description = extractPlainText(blockAny.content).replace(/\s+/g, " ").trim();
58
+ const type = paramTypes.get(name);
59
+ out.push({
60
+ name,
61
+ ...type != null ? { type } : {},
62
+ description
63
+ });
64
+ }
65
+ return out;
66
+ }
67
+ }
68
+ for (const [name, type] of paramTypes.entries()) out.push({
69
+ name,
70
+ type,
71
+ description: ""
72
+ });
73
+ return out;
74
+ }
75
+ /**
76
+ * The `@returns` block description, if present.
77
+ *
78
+ * @public
79
+ */
80
+ function getReturns(item) {
81
+ if (item instanceof ApiDocumentedItem) {
82
+ const tsdoc = item.tsdocComment;
83
+ if (tsdoc?.returnsBlock) {
84
+ const description = extractPlainText(tsdoc.returnsBlock.content).replace(/\s+/g, " ").trim();
85
+ return description.length > 0 ? { description } : null;
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+ /**
91
+ * All `@example` fenced-code blocks (falls back to plain text).
92
+ *
93
+ * @public
94
+ */
95
+ function getExamples(item) {
96
+ const examples = [];
97
+ if (!(item instanceof ApiDocumentedItem)) return examples;
98
+ const tsdoc = item.tsdocComment;
99
+ for (const block of tsdoc?.customBlocks || []) {
100
+ if (block.blockTag?.tagNameWithUpperCase !== "@EXAMPLE") continue;
101
+ const content = block.content;
102
+ let found = false;
103
+ for (const node of content?.nodes || []) if (node.kind === "FencedCode") {
104
+ examples.push({
105
+ language: node.language || "typescript",
106
+ code: node.code || ""
107
+ });
108
+ found = true;
109
+ }
110
+ if (!found) {
111
+ const text = extractPlainText(content).trim();
112
+ if (text) examples.push({
113
+ language: "typescript",
114
+ code: text
115
+ });
116
+ }
117
+ }
118
+ return examples;
119
+ }
120
+ /**
121
+ * Reads the deprecation-block message from an ApiItem, if one is present.
122
+ *
123
+ * @public
124
+ */
125
+ function getDeprecation(item) {
126
+ if (item instanceof ApiDocumentedItem) {
127
+ const tsdoc = item.tsdocComment;
128
+ if (tsdoc?.deprecatedBlock) return { message: extractPlainText(tsdoc.deprecatedBlock.content).replace(/\s+/g, " ").trim() };
129
+ }
130
+ return null;
131
+ }
132
+ /**
133
+ * The release tag (Public/Beta/Alpha/Internal) or "Public" when absent.
134
+ *
135
+ * @public
136
+ */
137
+ function getReleaseTag(item) {
138
+ if (ApiReleaseTagMixin.isBaseClassOf(item)) switch (item.releaseTag) {
139
+ case ReleaseTag.Public: return "Public";
140
+ case ReleaseTag.Beta: return "Beta";
141
+ case ReleaseTag.Alpha: return "Alpha";
142
+ case ReleaseTag.Internal: return "Internal";
143
+ default: return "Public";
144
+ }
145
+ return "Public";
146
+ }
147
+ /**
148
+ * True when the item carries the given TSDoc modifier tag (without the `@`).
149
+ *
150
+ * @public
151
+ */
152
+ function hasModifierTag(item, tagName) {
153
+ if (item instanceof ApiDocumentedItem) return ((item.tsdocComment?.modifierTagSet)?.nodes || []).some((t) => t.tagName === `@${tagName}`);
154
+ return false;
155
+ }
156
+
157
+ //#endregion
158
+ export { extractPlainText, getDeprecation, getExamples, getParams, getReleaseTag, getReturns, getSummary, hasModifierTag };