@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/Render.js ADDED
@@ -0,0 +1,182 @@
1
+ import { __exportAll } from "./_virtual/_rolldown/runtime.js";
2
+ import { deprecation, examples, params, returns, summary } from "./Tsdoc.js";
3
+ import { CrossLinker } from "./CrossLinker.js";
4
+ import { phrasingFromMarkdown } from "./internal/prose.js";
5
+ import { format } from "./Signature.js";
6
+ import { ApiItemContainerMixin } from "@microsoft/api-extractor-model";
7
+ import { Blockquote, Code, Heading, InlineCode, List, ListItem, Markdown, Paragraph, Root, Strong, Text } from "@effected/markdown";
8
+ import { Result } from "effect";
9
+
10
+ //#region src/Render.ts
11
+ var Render_exports = /* @__PURE__ */ __exportAll({
12
+ docs: () => docs,
13
+ isEmittable: () => isEmittable,
14
+ item: () => item,
15
+ tree: () => tree
16
+ });
17
+ const KIND_SLUG = {
18
+ Class: "class",
19
+ Interface: "interface",
20
+ Function: "function",
21
+ TypeAlias: "type",
22
+ Variable: "variable",
23
+ Enum: "enum",
24
+ Namespace: "namespace"
25
+ };
26
+ /**
27
+ * The default emit rule for {@link docs}: drop compiler-synthetic forgotten
28
+ * exports — items the model retains only because API Extractor ran with
29
+ * `includeForgottenExports: true` (e.g. the `*_base` classes TypeScript hoists
30
+ * for Effect class mixins). Those carry `isExported === false` on
31
+ * `ApiExportedMixin`. Every other item, including any lacking the flag, is
32
+ * kept.
33
+ *
34
+ * @public
35
+ */
36
+ const isEmittable = (item) => item.isExported !== false;
37
+ const signatureOf = (item) => {
38
+ const declared = item;
39
+ return declared.excerpt?.text ? format(declared.excerpt).trim() : "";
40
+ };
41
+ /**
42
+ * Render one API item's markdown body as flow nodes — the pre-serialization
43
+ * form of {@link item}.
44
+ *
45
+ * @alpha
46
+ */
47
+ function tree(apiItem, opts) {
48
+ const link = (text) => opts.crossLinker ? opts.crossLinker.link(text) : text;
49
+ const prose = (text) => phrasingFromMarkdown(link(text));
50
+ const nodes = [new Heading({
51
+ depth: 1,
52
+ children: [new Text({ value: apiItem.displayName })]
53
+ })];
54
+ const deprecation$1 = deprecation(apiItem);
55
+ if (deprecation$1) nodes.push(new Blockquote({ children: [new Paragraph({ children: [
56
+ new Strong({ children: [new Text({ value: "Deprecated:" })] }),
57
+ new Text({ value: " " }),
58
+ ...prose(deprecation$1.message)
59
+ ] })] }));
60
+ const summary$1 = summary(apiItem);
61
+ if (summary$1) nodes.push(new Paragraph({ children: [...prose(summary$1)] }));
62
+ const signature = signatureOf(apiItem);
63
+ if (signature) nodes.push(new Code({
64
+ value: signature,
65
+ lang: "ts"
66
+ }));
67
+ const params$1 = params(apiItem);
68
+ if (params$1.length > 0) {
69
+ nodes.push(new Heading({
70
+ depth: 2,
71
+ children: [new Text({ value: "Parameters" })]
72
+ }));
73
+ nodes.push(new List({
74
+ ordered: false,
75
+ spread: false,
76
+ children: params$1.map((p) => {
77
+ const children = [new InlineCode({ value: p.name })];
78
+ if (p.type) children.push(new Text({ value: " " }), new InlineCode({ value: p.type }));
79
+ if (p.description) children.push(new Text({ value: " — " }), ...prose(p.description));
80
+ return new ListItem({
81
+ spread: false,
82
+ children: [new Paragraph({ children })]
83
+ });
84
+ })
85
+ }));
86
+ }
87
+ const returns$1 = returns(apiItem);
88
+ if (returns$1) {
89
+ nodes.push(new Heading({
90
+ depth: 2,
91
+ children: [new Text({ value: "Returns" })]
92
+ }));
93
+ nodes.push(new Paragraph({ children: [...prose(returns$1.description)] }));
94
+ }
95
+ const members = apiItem.members;
96
+ if (Array.isArray(members) && members.length > 0 && ApiItemContainerMixin.isBaseClassOf(apiItem)) {
97
+ nodes.push(new Heading({
98
+ depth: 2,
99
+ children: [new Text({ value: "Members" })]
100
+ }));
101
+ for (const m of members) {
102
+ nodes.push(new Heading({
103
+ depth: 3,
104
+ children: [new Text({ value: m.displayName })]
105
+ }));
106
+ const sig = signatureOf(m);
107
+ if (sig) nodes.push(new Code({
108
+ value: sig,
109
+ lang: "ts"
110
+ }));
111
+ const mSummary = summary(m);
112
+ if (mSummary) nodes.push(new Paragraph({ children: [...prose(mSummary)] }));
113
+ }
114
+ }
115
+ const examples$1 = examples(apiItem);
116
+ if (examples$1.length > 0) {
117
+ nodes.push(new Heading({
118
+ depth: 2,
119
+ children: [new Text({ value: "Examples" })]
120
+ }));
121
+ for (const ex of examples$1) nodes.push(new Code({
122
+ value: ex.code.replace(/\n$/, ""),
123
+ lang: ex.language
124
+ }));
125
+ }
126
+ return nodes;
127
+ }
128
+ /**
129
+ * Render one API item to a markdown body string (no frontmatter).
130
+ *
131
+ * @public
132
+ */
133
+ function item(apiItem, opts) {
134
+ const root = new Root({ children: [...tree(apiItem, opts)] });
135
+ const trimmed = Result.getOrThrow(Markdown.stringifyResult(root)).trim();
136
+ return trimmed ? `${trimmed}\n` : "\n";
137
+ }
138
+ /**
139
+ * Walk a package's first entry point and assemble one RenderedDoc per
140
+ * top-level member.
141
+ *
142
+ * @public
143
+ */
144
+ function docs(apiPackage, opts) {
145
+ const entryPoint = apiPackage.entryPoints[0];
146
+ if (!entryPoint) return [];
147
+ const keep = opts.filter ?? isEmittable;
148
+ const pairs = [];
149
+ for (const member of entryPoint.members) {
150
+ const kind = KIND_SLUG[member.kind];
151
+ if (kind === void 0) continue;
152
+ if (!keep(member)) continue;
153
+ pairs.push({
154
+ item: member,
155
+ ref: {
156
+ name: member.displayName,
157
+ kind,
158
+ slug: member.displayName.toLowerCase()
159
+ }
160
+ });
161
+ }
162
+ const crossLinker = opts.routeFor ? CrossLinker.fromRefs(pairs.map((p) => p.ref), opts.routeFor) : void 0;
163
+ return pairs.map(({ item: member, ref }) => {
164
+ const body = item(member, {
165
+ packageName: opts.packageName,
166
+ ...crossLinker ? { crossLinker } : {}
167
+ });
168
+ const meta = {
169
+ ...ref,
170
+ summary: summary(member),
171
+ packageName: opts.packageName
172
+ };
173
+ const frontmatter = opts.frontmatter ? opts.frontmatter(meta) : "";
174
+ return {
175
+ ...meta,
176
+ markdown: frontmatter + body
177
+ };
178
+ });
179
+ }
180
+
181
+ //#endregion
182
+ export { Render_exports, docs, isEmittable, item, tree };
package/Routes.js ADDED
@@ -0,0 +1,110 @@
1
+ import { __exportAll } from "./_virtual/_rolldown/runtime.js";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/Routes.ts
5
+ /**
6
+ * Output-route computation: collision detection over route candidates and the
7
+ * canonical anchor-id sanitizer. Detection is pure; the typed
8
+ * {@link RouteCollisionError} is the artifact a consumer fails its build with.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+ var Routes_exports = /* @__PURE__ */ __exportAll({
13
+ RouteCandidate: () => RouteCandidate,
14
+ RouteCollisionError: () => RouteCollisionError,
15
+ detectCollisions: () => detectCollisions,
16
+ sanitizeId: () => sanitizeId
17
+ });
18
+ /**
19
+ * A candidate output route for collision detection. All-string and
20
+ * serializable — candidates cross the error boundary inside
21
+ * {@link RouteCollisionError} and may be persisted by consumer diagnostics.
22
+ *
23
+ * @public
24
+ */
25
+ var RouteCandidate = class extends Schema.Class("RouteCandidate")({
26
+ /** Stable identity (e.g. `"displayName::kind"` or a namespace qualified name). */
27
+ id: Schema.String,
28
+ /** Human-readable name for error messages (original, non-lowercased). */
29
+ displayName: Schema.String,
30
+ /** Category folder name, e.g. `"variable"`. */
31
+ folder: Schema.String,
32
+ /** Lowercased sanitized last path segment, e.g. `"foo"` — the value used in the route. */
33
+ baseName: Schema.String,
34
+ /** API item kind string, e.g. `"Variable"`. */
35
+ kind: Schema.String,
36
+ /** canonicalReference string, used for deterministic ordering. */
37
+ canonicalRef: Schema.String
38
+ }) {};
39
+ /**
40
+ * Group candidates by their final route (`${folder}/${baseName}`) and return
41
+ * the groups with more than one distinct item. The route key is the lowercased
42
+ * path the file is written to, so detection matches generation (and what a
43
+ * case-insensitive filesystem would merge). Companion pairs (same name,
44
+ * different folders) land under different keys and are never collisions.
45
+ *
46
+ * Output is deterministic: collisions ordered by route, items within a
47
+ * collision ordered by canonicalReference.
48
+ *
49
+ * @public
50
+ */
51
+ function detectCollisions(candidates) {
52
+ const byKey = /* @__PURE__ */ new Map();
53
+ for (const candidate of candidates) {
54
+ const key = `${candidate.folder}/${candidate.baseName}`;
55
+ const group = byKey.get(key) ?? [];
56
+ group.push(candidate);
57
+ byKey.set(key, group);
58
+ }
59
+ const collisions = [];
60
+ for (const [route, group] of byKey) if (group.length > 1) {
61
+ const items = [...group].sort((a, b) => a.canonicalRef < b.canonicalRef ? -1 : a.canonicalRef > b.canonicalRef ? 1 : 0);
62
+ collisions.push({
63
+ route,
64
+ items
65
+ });
66
+ }
67
+ collisions.sort((a, b) => a.route < b.route ? -1 : a.route > b.route ? 1 : 0);
68
+ return collisions;
69
+ }
70
+ /**
71
+ * Two or more distinct API items resolve to the same documentation route — a
72
+ * naming or category-configuration problem the build must fail on. The
73
+ * `message` names every colliding item with its kind and canonical reference,
74
+ * plus remediation guidance.
75
+ *
76
+ * @public
77
+ */
78
+ var RouteCollisionError = class extends Schema.TaggedError()("RouteCollisionError", {
79
+ baseRoute: Schema.String,
80
+ collisions: Schema.Array(Schema.Struct({
81
+ route: Schema.String,
82
+ items: Schema.Array(RouteCandidate)
83
+ }))
84
+ }) {
85
+ get message() {
86
+ const lines = [];
87
+ for (const collision of this.collisions) {
88
+ lines.push(`Route collision: ${collision.items.length} API items resolve to the same documentation path "${this.baseRoute}/${collision.route}":`);
89
+ for (const item of collision.items) lines.push(` - ${item.displayName} (${item.kind}) [${item.canonicalRef}]`);
90
+ }
91
+ lines.push("");
92
+ lines.push("Item names must be unique per category folder. Paths are lowercased, so names differing only in case collide. Rename one of the items, or configure categories so they map to different folders.");
93
+ return lines.join("\n");
94
+ }
95
+ };
96
+ /**
97
+ * Sanitize a display name into a valid HTML anchor id: lowercase,
98
+ * spaces/underscores → hyphens, other specials stripped, optional prefix for
99
+ * disambiguation. The ONE canonical implementation — anchor generation and
100
+ * cross-link routes must agree on it by construction.
101
+ *
102
+ * @public
103
+ */
104
+ function sanitizeId(displayName, prefix = "") {
105
+ const sanitized = displayName.toLowerCase().replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "");
106
+ return prefix ? `${prefix}-${sanitized}` : sanitized;
107
+ }
108
+
109
+ //#endregion
110
+ export { RouteCandidate, RouteCollisionError, Routes_exports, detectCollisions, sanitizeId };
package/Signature.js ADDED
@@ -0,0 +1,100 @@
1
+ import { __exportAll } from "./_virtual/_rolldown/runtime.js";
2
+ import { escapeRegExp } from "./internal/text.js";
3
+
4
+ //#region src/Signature.ts
5
+ var Signature_exports = /* @__PURE__ */ __exportAll({
6
+ format: () => format,
7
+ linkReferences: () => linkReferences,
8
+ stripExportDeclare: () => stripExportDeclare
9
+ });
10
+ /**
11
+ * Strip `export` / `declare` modifiers from a declaration text.
12
+ *
13
+ * @public
14
+ */
15
+ function stripExportDeclare(text) {
16
+ let result = text.trim().replace(/^export\s+declare\s+/i, "").replace(/^export\s+/i, "").replace(/^declare\s+/i, "");
17
+ result = result.replace(/\bexport\s+declare\s+/gi, "").replace(/\bexport\s+/gi, "").replace(/\bdeclare\s+/gi, "");
18
+ return result;
19
+ }
20
+ function needsSpaceBefore(prevText, currentText) {
21
+ if (/\s$/.test(prevText)) return false;
22
+ if (/^\s/.test(currentText)) return false;
23
+ if (currentText.trim().startsWith("<")) return false;
24
+ if (currentText.trim().match(/^[,;]/)) return false;
25
+ if (prevText.trim().endsWith(",")) return true;
26
+ if (currentText.trim() === "=" || currentText.trim().startsWith("=")) return true;
27
+ if (prevText.trim().endsWith("=")) return true;
28
+ if (currentText.trim() === "|" || currentText.trim() === "&") return true;
29
+ if (prevText.trim() === "|" || prevText.trim() === "&") return true;
30
+ if (prevText.trim() === "{" && currentText.trim() !== "}") return true;
31
+ if (currentText.trim() === "}" && prevText.trim() !== "{") return true;
32
+ if (prevText.trim().match(/^[[(]$/)) return false;
33
+ if (currentText.trim().match(/^[\])]$/)) return false;
34
+ if (prevText.trim().endsWith(":")) return true;
35
+ if (prevText.trim().endsWith("?:")) return true;
36
+ if (currentText.trim().startsWith(":") && !prevText.trim().match(/[,;:?]$/)) return false;
37
+ if (currentText.trim().startsWith("{") && /[a-zA-Z0-9_>]$/.test(prevText.trim())) return true;
38
+ const prevEndsAlnum = /[a-zA-Z0-9_>]$/.test(prevText.trim());
39
+ const currStartsAlnum = /^[a-zA-Z0-9_<]/.test(currentText.trim());
40
+ return prevEndsAlnum && currStartsAlnum;
41
+ }
42
+ /**
43
+ * Format an API Extractor `Excerpt` into a clean type signature string,
44
+ * wrapping long top-level unions/intersections.
45
+ *
46
+ * @public
47
+ */
48
+ function format(excerpt, options) {
49
+ const maxLineLength = options?.maxLineLength ?? 80;
50
+ const indent = options?.indent ?? " ";
51
+ if (!excerpt.spannedTokens || excerpt.spannedTokens.length === 0) return stripExportDeclare(excerpt.text);
52
+ const tokens = excerpt.spannedTokens;
53
+ let currentLine = "";
54
+ const lines = [];
55
+ let bracketDepth = 0;
56
+ let lastTokenText = "";
57
+ for (let i = 0; i < tokens.length; i++) {
58
+ let tokenText = tokens[i].text;
59
+ if (i === 0) tokenText = stripExportDeclare(tokenText);
60
+ if (tokenText.trim() === "") continue;
61
+ if (tokenText === "{" || tokenText === "[" || tokenText === "(") bracketDepth++;
62
+ else if (tokenText === "}" || tokenText === "]" || tokenText === ")") bracketDepth--;
63
+ const isOperator = tokenText.trim() === "|" || tokenText.trim() === "&";
64
+ if (lastTokenText && needsSpaceBefore(lastTokenText, tokenText)) currentLine += " ";
65
+ currentLine += tokenText;
66
+ lastTokenText = tokenText;
67
+ if (isOperator && bracketDepth === 0 && currentLine.length > maxLineLength && i < tokens.length - 1) {
68
+ lines.push(currentLine.trimEnd());
69
+ currentLine = indent;
70
+ }
71
+ }
72
+ if (currentLine.trim()) lines.push(currentLine.trimEnd());
73
+ if (lines.length <= 1) return lines.length === 1 ? lines[0].trimStart() : "";
74
+ return stripExportDeclare(lines.join("\n"));
75
+ }
76
+ /**
77
+ * Inject markdown cross-links into already-formatted signature text. Reference
78
+ * tokens in the excerpt whose canonical reference appears in
79
+ * `routesByCanonicalRef` have their display text wrapped in a markdown link.
80
+ *
81
+ * @public
82
+ */
83
+ function linkReferences(text, excerpt, routesByCanonicalRef) {
84
+ if (!excerpt.spannedTokens || routesByCanonicalRef.size === 0) return text;
85
+ const typeReferences = /* @__PURE__ */ new Map();
86
+ for (const token of excerpt.spannedTokens) if (token.kind === "Reference" && token.canonicalReference) {
87
+ const canonicalRef = token.canonicalReference.toString();
88
+ const route = routesByCanonicalRef.get(canonicalRef);
89
+ if (route && token.text) typeReferences.set(token.text.trim(), route);
90
+ }
91
+ let result = text;
92
+ for (const [typeName, route] of typeReferences.entries()) {
93
+ const regex = new RegExp(`\\b${escapeRegExp(typeName)}\\b`, "g");
94
+ result = result.replace(regex, `[${typeName}](${route})`);
95
+ }
96
+ return result;
97
+ }
98
+
99
+ //#endregion
100
+ export { Signature_exports, format, linkReferences, stripExportDeclare };
@@ -0,0 +1,18 @@
1
+ import { __exportAll } from "./_virtual/_rolldown/runtime.js";
2
+
3
+ //#region src/StructuredData.ts
4
+ var StructuredData_exports = /* @__PURE__ */ __exportAll({ derive: () => derive });
5
+ /**
6
+ * Derive schema.org structured data for a documented package.
7
+ *
8
+ * @remarks
9
+ * Not implemented yet — this is the phase-4 seam. Calling it throws.
10
+ *
11
+ * @alpha
12
+ */
13
+ function derive(_apiPackage, _manifest) {
14
+ throw new Error("@tsdoctor/model: StructuredData.derive is a phase-4 seam and is not implemented yet");
15
+ }
16
+
17
+ //#endregion
18
+ export { StructuredData_exports, derive };
@@ -0,0 +1,83 @@
1
+ import { __exportAll } from "./_virtual/_rolldown/runtime.js";
2
+ import { ApiExportedMixin, ApiItemKind, ExcerptTokenKind } from "@microsoft/api-extractor-model";
3
+
4
+ //#region src/SyntheticBases.ts
5
+ var SyntheticBases_exports = /* @__PURE__ */ __exportAll({
6
+ BASE_CLASS_ANCHOR: () => BASE_CLASS_ANCHOR,
7
+ detect: () => detect
8
+ });
9
+ /**
10
+ * Anchor id of the inline "Base Class" section rendered on the owner class
11
+ * page. Must match the slug RSPress derives from the `## Base Class` heading
12
+ * emitted by the owner-class page.
13
+ *
14
+ * @public
15
+ */
16
+ const BASE_CLASS_ANCHOR = "base-class";
17
+ const EMPTY_DETECTION = {
18
+ bases: /* @__PURE__ */ new Map(),
19
+ baseByOwner: /* @__PURE__ */ new Map()
20
+ };
21
+ /**
22
+ * Strip the trailing meaning (`:class`, `:var`, `:function(1)`, ...) from a
23
+ * canonical reference string so the reference token in an extends clause
24
+ * (`example!~Person_base`) matches the declaration's canonical reference
25
+ * (`example!~Person_base:var`).
26
+ */
27
+ function stripMeaning(canonicalRef) {
28
+ return canonicalRef.replace(/:[a-z]+(\(\d+\))?$/i, "");
29
+ }
30
+ /** True when the item carries ApiExportedMixin and is NOT exported from its entry point. */
31
+ function isUnexported(item) {
32
+ return ApiExportedMixin.isBaseClassOf(item) && !item.isExported;
33
+ }
34
+ /**
35
+ * Detect synthetic base declarations among top-level API items.
36
+ *
37
+ * An item qualifies when it is unexported (hoisted into the model only because
38
+ * something references it) AND at least one class's extends clause references
39
+ * its canonical symbol. Unexported items with no class referencing them
40
+ * (genuine forgotten exports) are left alone, as are extends references whose
41
+ * target is absent from the model.
42
+ *
43
+ * @public
44
+ */
45
+ function detect(items) {
46
+ const unexportedByRef = /* @__PURE__ */ new Map();
47
+ for (const item of items) {
48
+ if (!isUnexported(item)) continue;
49
+ const ref = item.canonicalReference?.toString();
50
+ if (ref) unexportedByRef.set(stripMeaning(ref), item);
51
+ }
52
+ if (unexportedByRef.size === 0) return EMPTY_DETECTION;
53
+ const owners = /* @__PURE__ */ new Map();
54
+ const baseByOwner = /* @__PURE__ */ new Map();
55
+ for (const item of items) {
56
+ if (item.kind !== ApiItemKind.Class) continue;
57
+ const apiClass = item;
58
+ const extendsType = apiClass.extendsType;
59
+ if (!extendsType) continue;
60
+ for (const token of extendsType.excerpt.spannedTokens) {
61
+ if (token.kind !== ExcerptTokenKind.Reference || !token.canonicalReference) continue;
62
+ const base = unexportedByRef.get(stripMeaning(token.canonicalReference.toString()));
63
+ if (!base || base === item || baseByOwner.has(item)) continue;
64
+ baseByOwner.set(item, base);
65
+ const ownerList = owners.get(base);
66
+ if (ownerList) ownerList.push(apiClass);
67
+ else owners.set(base, [apiClass]);
68
+ }
69
+ }
70
+ if (owners.size === 0) return EMPTY_DETECTION;
71
+ const bases = /* @__PURE__ */ new Map();
72
+ for (const [baseItem, ownerClasses] of owners) bases.set(baseItem, {
73
+ baseItem,
74
+ ownerClasses
75
+ });
76
+ return {
77
+ bases,
78
+ baseByOwner
79
+ };
80
+ }
81
+
82
+ //#endregion
83
+ export { BASE_CLASS_ANCHOR, SyntheticBases_exports, detect };