@tsdoctor/model 0.1.0 → 0.2.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/ApiItems.js +111 -0
- package/CrossLinker.js +81 -0
- package/EntryPoints.js +76 -0
- package/Model.js +90 -0
- package/README.md +55 -52
- package/Render.js +182 -0
- package/Routes.js +110 -0
- package/Signature.js +100 -0
- package/StructuredData.js +18 -0
- package/SyntheticBases.js +83 -0
- package/Tsdoc.js +250 -0
- package/_virtual/_rolldown/runtime.js +18 -0
- package/index.d.ts +477 -66
- package/index.js +11 -6
- package/internal/prose.js +20 -0
- package/internal/text.js +11 -0
- package/package.json +11 -1
- package/cross-linker.js +0 -37
- package/formatter.js +0 -70
- package/model-loader.js +0 -24
- package/render.js +0 -118
- package/tsdoc.js +0 -158
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
|
-
[](https://www.npmjs.com/package/@tsdoctor/model)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://nodejs.org/)
|
|
6
|
-
[](https://www.typescriptlang.org/)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
7
|
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```bash
|
|
19
21
|
pnpm add @tsdoctor/model
|
|
20
22
|
```
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
Requires Node.js >=24.11.0. This is an ESM-only package.
|
|
23
25
|
|
|
24
|
-
|
|
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
|
|
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 {
|
|
34
|
+
import { Effect } from "effect";
|
|
35
|
+
import { Model, Render } from "@tsdoctor/model";
|
|
33
36
|
|
|
34
|
-
const
|
|
37
|
+
const program = Effect.gen(function* () {
|
|
38
|
+
const pkg = yield* Model.load("./temp/my-pkg.api.json");
|
|
35
39
|
|
|
36
|
-
const docs =
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
###
|
|
64
|
+
### Render a single item
|
|
54
65
|
|
|
55
|
-
|
|
66
|
+
`Render.item` renders one `ApiItem` without walking the whole package — useful when a caller already has categorized items (via `ApiItems.categorize`) and wants to drive its own page-assembly loop.
|
|
56
67
|
|
|
57
68
|
```ts
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
81
|
+
import { Render } from "@tsdoctor/model";
|
|
77
82
|
|
|
78
|
-
const docs =
|
|
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
|
-
|
|
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
|
-
##
|
|
91
|
+
## Features
|
|
98
92
|
|
|
99
|
-
|
|
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
|
|