@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 +21 -0
- package/README.md +103 -0
- package/cross-linker.js +37 -0
- package/formatter.js +70 -0
- package/index.d.ts +211 -0
- package/index.js +7 -0
- package/model-loader.js +24 -0
- package/package.json +47 -0
- package/render.js +118 -0
- package/tsdoc-metadata.json +11 -0
- package/tsdoc.js +158 -0
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
|
+
[](https://www.npmjs.com/package/@tsdoctor/model)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](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)
|
package/cross-linker.js
ADDED
|
@@ -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 };
|
package/model-loader.js
ADDED
|
@@ -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 };
|