@tsdoctor/model 0.2.1 → 0.3.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 CHANGED
@@ -1,4 +1,5 @@
1
1
  import { __exportAll } from "./_virtual/_rolldown/runtime.js";
2
+ import { memberAnchors as memberAnchors$1, memberRouteKeys as memberRouteKeys$1 } from "./Routes.js";
2
3
  import { hasModifier } from "./Tsdoc.js";
3
4
  import { ApiItemKind } from "@microsoft/api-extractor-model";
4
5
 
@@ -6,6 +7,8 @@ import { ApiItemKind } from "@microsoft/api-extractor-model";
6
7
  var ApiItems_exports = /* @__PURE__ */ __exportAll({
7
8
  categorize: () => categorize,
8
9
  inheritance: () => inheritance,
10
+ memberAnchors: () => memberAnchors,
11
+ memberRouteKeys: () => memberRouteKeys,
9
12
  namespaceMembers: () => namespaceMembers,
10
13
  sourceLink: () => sourceLink
11
14
  });
@@ -106,6 +109,60 @@ function sourceLink(item, target) {
106
109
  const baseUrl = `${target.url}/${ref}`;
107
110
  return lineNumber ? `${baseUrl}/${filePath}#L${lineNumber}` : `${baseUrl}/${filePath}`;
108
111
  }
112
+ /**
113
+ * The slot a class member occupies, for anchor disambiguation.
114
+ *
115
+ * @remarks
116
+ * Getters and setters are `Method` items whose display name carries the
117
+ * `get `/`set ` prefix — that is how API Extractor models them.
118
+ */
119
+ function memberSlot(member) {
120
+ const isStatic = member.isStatic === true;
121
+ if (member.kind === "Method" && (member.displayName.startsWith("get ") || member.displayName.startsWith("set "))) return "getter";
122
+ const isMethod = member.kind === "Method" || member.kind === "MethodSignature";
123
+ if (isStatic) return isMethod ? "static-method" : "static-property";
124
+ return isMethod ? "instance-method" : "instance-property";
125
+ }
126
+ /**
127
+ * Anchor id for every member of a class or interface, keyed by the member's
128
+ * canonical reference.
129
+ *
130
+ * @remarks
131
+ * The ONE place an API item is turned into the {@link Routes.MemberRef} shape
132
+ * anchor computation needs. Both the cross-link route map and the rendered
133
+ * page call this, so a member's `#fragment` and its `id=` cannot disagree —
134
+ * which they did before Task 1.1, when each side derived anchors separately.
135
+ *
136
+ * A canonical reference already distinguishes a static member from an
137
+ * instance member of the same name (`Foo.bar` vs `Foo#bar`), so it is the
138
+ * natural key; a member without one falls back to its display name.
139
+ *
140
+ * @public
141
+ */
142
+ function memberAnchors(item) {
143
+ return memberAnchors$1(memberRefs(item));
144
+ }
145
+ /** The {@link Routes.MemberRef} view of a class or interface's members. */
146
+ function memberRefs(item) {
147
+ return item.members.map((member) => ({
148
+ id: member.canonicalReference?.toString() ?? member.displayName,
149
+ displayName: member.displayName,
150
+ slot: memberSlot(member)
151
+ }));
152
+ }
153
+ /**
154
+ * Cross-link keys for a class or interface's members, mapped to the member's
155
+ * canonical reference.
156
+ *
157
+ * @remarks
158
+ * The {@link Routes.memberRouteKeys} vocabulary over real API items — see
159
+ * there for which keys are emitted and why `Class#member` is not one of them.
160
+ *
161
+ * @public
162
+ */
163
+ function memberRouteKeys(item) {
164
+ return memberRouteKeys$1(item.displayName, memberRefs(item));
165
+ }
109
166
 
110
167
  //#endregion
111
- export { ApiItems_exports, categorize, inheritance, namespaceMembers, sourceLink };
168
+ export { ApiItems_exports, categorize, inheritance, memberAnchors, memberRouteKeys, namespaceMembers, sourceLink };
package/CrossLinker.js CHANGED
@@ -10,6 +10,24 @@ import { escapeRegExp } from "./internal/text.js";
10
10
  * @packageDocumentation
11
11
  */
12
12
  /**
13
+ * A word-boundary-anchored pattern for a literal name.
14
+ *
15
+ * @remarks
16
+ * `\b` is an assertion ABOUT the adjacent character, not a delimiter: after a
17
+ * non-word character it matches only when a word character follows. A key in
18
+ * TSDoc selector form — `Registry.(create:instance)` — ends in `)`, so a
19
+ * trailing `\b` made it unmatchable in every realistic sentence position
20
+ * ("See Registry.(create:instance) for details." did not match). Escaping was
21
+ * never the problem; the boundary was. Names that end in a word character —
22
+ * every plain identifier and every `Class.member` key — get the same `\b`
23
+ * they always did, so this is a strict widening.
24
+ */
25
+ const boundedPattern = (name) => {
26
+ const lead = /^\w/.test(name) ? "\\b" : "";
27
+ const trail = /\w$/.test(name) ? "\\b" : "(?!\\w)";
28
+ return `${lead}${escapeRegExp(name)}${trail}`;
29
+ };
30
+ /**
13
31
  * Links known API item names in prose to their documentation routes. Matching
14
32
  * is longest-name-first with word boundaries, skipping code spans and existing
15
33
  * links.
@@ -47,7 +65,7 @@ var CrossLinker = class CrossLinker {
47
65
  for (const name of this.orderedNames) {
48
66
  const route = this.routesByName.get(name);
49
67
  if (route === void 0) continue;
50
- const regex = new RegExp(`\\b${escapeRegExp(name)}\\b`, "g");
68
+ const regex = new RegExp(boundedPattern(name), "g");
51
69
  result = result.replace(regex, (match, offset) => {
52
70
  const before = result.slice(0, offset);
53
71
  if (before.endsWith("](") || before.endsWith("[")) return match;
@@ -66,7 +84,7 @@ var CrossLinker = class CrossLinker {
66
84
  for (const name of this.orderedNames) {
67
85
  const route = this.routesByName.get(name);
68
86
  if (route === void 0) continue;
69
- const regex = new RegExp(`\\b${escapeRegExp(name)}\\b(?![a-zA-Z])`, "g");
87
+ const regex = new RegExp(`${boundedPattern(name)}(?![a-zA-Z])`, "g");
70
88
  result = result.replace(regex, (match, offset) => {
71
89
  const beforeMatch = result.substring(0, offset);
72
90
  if (beforeMatch.includes("<a") && !beforeMatch.includes("</a>")) return match;
package/Model.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { __exportAll } from "./_virtual/_rolldown/runtime.js";
2
2
  import { ApiModel } from "@microsoft/api-extractor-model";
3
+ import { Effect, Schema } from "effect";
3
4
  import { existsSync } from "node:fs";
4
5
  import { resolve } from "node:path";
5
- import { Effect, Schema } from "effect";
6
6
 
7
7
  //#region src/Model.ts
8
8
  /**
package/README.md CHANGED
@@ -92,9 +92,10 @@ Supplying `filter` fully replaces the default, so compose it with `Render.isEmit
92
92
 
93
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
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.
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; `memberAnchors(item)` and `memberRouteKeys(item)` are the `ApiClass` / `ApiInterface` views of the `Routes` functions below.
96
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.
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, with `memberAnchor` as its named alias for one member.
98
+ - **`Routes` member anchors and keys** — `memberAnchors(members)` computes every member's anchor in one pass, keyed by `MemberRef.id`, prefixing the lower-priority member when two sanitize alike (`static create()` keeps `#create`, the instance one becomes `#instance-create`). `memberRouteKeys(className, members)` returns the cross-link keys those anchors answer to: a bare `Class.member` resolves to the static member when a class has both, with `Class.(member:static)`, `Class.(member:instance)` and `Class.prototype.member` emitted to disambiguate. Feed both from the same member list so a page's `id=` and a link's `#fragment` cannot drift.
98
99
  - **`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
100
  - **`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
101
  - **`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.
package/Render.js CHANGED
@@ -4,8 +4,8 @@ import { CrossLinker } from "./CrossLinker.js";
4
4
  import { phrasingFromMarkdown } from "./internal/prose.js";
5
5
  import { format } from "./Signature.js";
6
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
7
  import { Result } from "effect";
8
+ import { Blockquote, Code, Heading, InlineCode, List, ListItem, Markdown, Paragraph, Root, Strong, Text } from "@effected/markdown";
9
9
 
10
10
  //#region src/Render.ts
11
11
  var Render_exports = /* @__PURE__ */ __exportAll({
package/Routes.js CHANGED
@@ -13,6 +13,9 @@ var Routes_exports = /* @__PURE__ */ __exportAll({
13
13
  RouteCandidate: () => RouteCandidate,
14
14
  RouteCollisionError: () => RouteCollisionError,
15
15
  detectCollisions: () => detectCollisions,
16
+ memberAnchor: () => memberAnchor,
17
+ memberAnchors: () => memberAnchors,
18
+ memberRouteKeys: () => memberRouteKeys,
16
19
  sanitizeId: () => sanitizeId
17
20
  });
18
21
  /**
@@ -96,8 +99,17 @@ var RouteCollisionError = class extends Schema.TaggedError()("RouteCollisionErro
96
99
  /**
97
100
  * Sanitize a display name into a valid HTML anchor id: lowercase,
98
101
  * 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.
102
+ * disambiguation.
103
+ *
104
+ * @remarks
105
+ * The ONE canonical implementation. This docstring made that claim before it
106
+ * was true: the RSPress adapter carried a second, subtly different sanitizer
107
+ * for page-side `id=` attributes (it kept `_`, being in `\w`, and mapped `$`
108
+ * to `-`), so `get_value` was linked as `#get-value` and rendered as
109
+ * `id="get_value"` — a cross-link that landed nowhere. Both sides call this
110
+ * now. Do not add a second spelling; if page ids ever genuinely need
111
+ * different treatment from route anchors, that is a design change, not a
112
+ * local helper.
101
113
  *
102
114
  * @public
103
115
  */
@@ -105,6 +117,149 @@ function sanitizeId(displayName, prefix = "") {
105
117
  const sanitized = displayName.toLowerCase().replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "");
106
118
  return prefix ? `${prefix}-${sanitized}` : sanitized;
107
119
  }
120
+ /**
121
+ * The anchor for a single member, given an already-decided prefix.
122
+ *
123
+ * @remarks
124
+ * A thin alias over {@link sanitizeId} that names the intent at the call site.
125
+ * Prefer {@link memberAnchors} when rendering a whole class — deciding the
126
+ * prefix per member is the part that is easy to get wrong.
127
+ *
128
+ * @public
129
+ */
130
+ function memberAnchor(displayName, prefix = "") {
131
+ return sanitizeId(displayName, prefix);
132
+ }
133
+ /**
134
+ * Slots ordered by which keeps the bare anchor when names collide.
135
+ *
136
+ * @remarks
137
+ * Static slots lead because the bare cross-link key `Class.member` canonically
138
+ * means the static member (see {@link memberRouteKeys}). One naming decision
139
+ * applied to both halves: the name that resolves to a member is the name that
140
+ * member's anchor uses.
141
+ */
142
+ const SLOT_PRIORITY = [
143
+ "static-method",
144
+ "static-property",
145
+ "instance-method",
146
+ "getter",
147
+ "instance-property"
148
+ ];
149
+ /**
150
+ * The prefix a losing slot is disambiguated with.
151
+ *
152
+ * @remarks
153
+ * The prefix marks the NON-canonical side, so an instance member displaced by
154
+ * a static one becomes `instance-create`. TypeScript forbids two members of
155
+ * one class sharing a name within the same static-ness, so in practice a
156
+ * collision is exactly one static and one instance member and only
157
+ * `"instance"` is ever emitted; the static entries are a total-map fallback.
158
+ */
159
+ const SLOT_PREFIX = {
160
+ "static-method": "static",
161
+ "static-property": "static",
162
+ "instance-method": "instance",
163
+ getter: "instance",
164
+ "instance-property": "instance"
165
+ };
166
+ /**
167
+ * Compute the anchor for every member of one class, keyed by
168
+ * {@link MemberRef.id}.
169
+ *
170
+ * @remarks
171
+ * When several members sanitize to the same anchor, the highest-priority slot
172
+ * keeps the bare anchor and every other member is prefixed
173
+ * (`instance-create`). Priority runs static method, static property, instance
174
+ * method, getter, instance property — static first, so the anchor agrees with
175
+ * the bare cross-link key, which resolves to the static member.
176
+ *
177
+ * The per-MEMBER keying is load-bearing. A previous implementation keyed the
178
+ * prefix by sanitized NAME, so both halves of a `static create()` / `create()`
179
+ * collision looked up the same entry and both rendered
180
+ * `id="static-create"` — two elements sharing one HTML id, and the instance
181
+ * member displaced from the anchor its cross-link pointed at. Keying by
182
+ * member means the loser moves and the winner does not.
183
+ *
184
+ * @public
185
+ */
186
+ function memberAnchors(members) {
187
+ const bySanitized = /* @__PURE__ */ new Map();
188
+ for (const member of members) {
189
+ const base = sanitizeId(member.displayName);
190
+ const bucket = bySanitized.get(base);
191
+ if (bucket) bucket.push(member);
192
+ else bySanitized.set(base, [member]);
193
+ }
194
+ const anchors = /* @__PURE__ */ new Map();
195
+ for (const bucket of bySanitized.values()) {
196
+ const winner = bucket.length === 1 ? bucket[0] : [...bucket].sort((a, b) => SLOT_PRIORITY.indexOf(a.slot) - SLOT_PRIORITY.indexOf(b.slot))[0];
197
+ for (const member of bucket) {
198
+ const prefix = member === winner ? "" : SLOT_PREFIX[member.slot];
199
+ anchors.set(member.id, memberAnchor(member.displayName, prefix));
200
+ }
201
+ }
202
+ return anchors;
203
+ }
204
+ /** Static slots, for the `:static` / `:instance` selector split. */
205
+ const STATIC_SLOTS = /* @__PURE__ */ new Set(["static-property", "static-method"]);
206
+ /**
207
+ * Cross-link keys for one class's members, mapped to the member they resolve
208
+ * to ({@link MemberRef.id}).
209
+ *
210
+ * @remarks
211
+ * The bare `Class.member` key resolves to the STATIC member when a class has
212
+ * both a static and an instance member of that name. `Registry.create` is the
213
+ * static access expression in TypeScript — the instance one is
214
+ * `registry.create` — so a prose author writing the qualified form means the
215
+ * static member.
216
+ *
217
+ * The disambiguating keys use TSDoc declaration-reference selectors, the
218
+ * vocabulary API Extractor canonical references already carry:
219
+ *
220
+ * - `Registry.create` — the static member (the common case)
221
+ * - `Registry.(create:instance)` — the instance member
222
+ * - `Registry.(create:static)` — the static member, explicitly
223
+ * - `Registry.prototype.create` — an alias for the instance member; real
224
+ * JavaScript rather than invented syntax, and what a reader guesses
225
+ *
226
+ * `Class#member` is deliberately NOT emitted. `#` is the URL fragment
227
+ * delimiter, so such a key reads ambiguously beside a route, and in modern
228
+ * TypeScript `#` denotes a PRIVATE field (`this.#count`) — the JSDoc
229
+ * convention predates both and has aged badly.
230
+ *
231
+ * Selector keys are emitted ONLY when a collision exists. On the
232
+ * overwhelmingly common class with no name collision the bare key is
233
+ * complete, and every extra key is one more pattern the prose cross-linker
234
+ * compiles and tests against every string it links.
235
+ *
236
+ * @public
237
+ */
238
+ function memberRouteKeys(className, members) {
239
+ const byName = /* @__PURE__ */ new Map();
240
+ for (const member of members) {
241
+ const bucket = byName.get(member.displayName);
242
+ if (bucket) bucket.push(member);
243
+ else byName.set(member.displayName, [member]);
244
+ }
245
+ const keys = /* @__PURE__ */ new Map();
246
+ for (const [displayName, bucket] of byName) {
247
+ const statics = bucket.filter((m) => STATIC_SLOTS.has(m.slot));
248
+ const instances = bucket.filter((m) => !STATIC_SLOTS.has(m.slot));
249
+ const collides = statics.length > 0 && instances.length > 0;
250
+ const bare = statics[0] ?? instances[0];
251
+ if (bare) keys.set(`${className}.${displayName}`, bare.id);
252
+ if (!collides) continue;
253
+ const staticMember = statics[0];
254
+ const instanceMember = instances[0];
255
+ if (staticMember) keys.set(`${className}.(${displayName}:static)`, staticMember.id);
256
+ if (instanceMember) {
257
+ keys.set(`${className}.(${displayName}:instance)`, instanceMember.id);
258
+ keys.set(`${className}.prototype.${displayName}`, instanceMember.id);
259
+ }
260
+ }
261
+ return keys;
262
+ }
108
263
 
109
264
  //#endregion
110
- export { RouteCandidate, RouteCollisionError, Routes_exports, detectCollisions, sanitizeId };
265
+ export { RouteCandidate, RouteCollisionError, Routes_exports, detectCollisions, memberAnchor, memberAnchors, memberRouteKeys, sanitizeId };
package/index.d.ts CHANGED
@@ -45,7 +45,7 @@ declare function entryPointName(displayName: string): string;
45
45
  */
46
46
  declare function resolve(apiPackage: ApiPackage): ResolvedEntryItem[];
47
47
  declare namespace ApiItems_d_exports {
48
- export { CategorizedItems, CategorySpec, Inheritance, NamespaceMember, SourceLinkTarget, categorize, inheritance, namespaceMembers, sourceLink };
48
+ export { CategorizedItems, CategorySpec, Inheritance, NamespaceMember, SourceLinkTarget, categorize, inheritance, memberAnchors$1 as memberAnchors, memberRouteKeys$1 as memberRouteKeys, namespaceMembers, sourceLink };
49
49
  }
50
50
  /**
51
51
  * The category rules `categorize` reads — a structural subset of a consumer's
@@ -133,6 +133,34 @@ declare function inheritance(item: ApiClass | ApiInterface): Inheritance;
133
133
  * @public
134
134
  */
135
135
  declare function sourceLink(item: ApiItem, target?: SourceLinkTarget): string | null;
136
+ /**
137
+ * Anchor id for every member of a class or interface, keyed by the member's
138
+ * canonical reference.
139
+ *
140
+ * @remarks
141
+ * The ONE place an API item is turned into the {@link Routes.MemberRef} shape
142
+ * anchor computation needs. Both the cross-link route map and the rendered
143
+ * page call this, so a member's `#fragment` and its `id=` cannot disagree —
144
+ * which they did before Task 1.1, when each side derived anchors separately.
145
+ *
146
+ * A canonical reference already distinguishes a static member from an
147
+ * instance member of the same name (`Foo.bar` vs `Foo#bar`), so it is the
148
+ * natural key; a member without one falls back to its display name.
149
+ *
150
+ * @public
151
+ */
152
+ declare function memberAnchors$1(item: ApiClass | ApiInterface): ReadonlyMap<string, string>;
153
+ /**
154
+ * Cross-link keys for a class or interface's members, mapped to the member's
155
+ * canonical reference.
156
+ *
157
+ * @remarks
158
+ * The {@link Routes.memberRouteKeys} vocabulary over real API items — see
159
+ * there for which keys are emitted and why `Class#member` is not one of them.
160
+ *
161
+ * @public
162
+ */
163
+ declare function memberRouteKeys$1(item: ApiClass | ApiInterface): ReadonlyMap<string, string>;
136
164
  //#endregion
137
165
  //#region src/types.d.ts
138
166
  /**
@@ -335,7 +363,7 @@ declare function item(apiItem: ApiItem, opts: RenderItemOptions): string;
335
363
  */
336
364
  declare function docs(apiPackage: ApiPackage, opts: RenderPackageOptions): RenderedDoc[];
337
365
  declare namespace Routes_d_exports {
338
- export { RouteCandidate, RouteCollision, RouteCollisionError, detectCollisions, sanitizeId };
366
+ export { MemberRef, MemberSlot, RouteCandidate, RouteCollision, RouteCollisionError, detectCollisions, memberAnchor, memberAnchors, memberRouteKeys, sanitizeId };
339
367
  }
340
368
  declare const RouteCandidate_base: Schema.Class<RouteCandidate, Schema.Struct<{
341
369
  /** Stable identity (e.g. `"displayName::kind"` or a namespace qualified name). */
@@ -404,12 +432,112 @@ declare class RouteCollisionError extends RouteCollisionError_base {
404
432
  /**
405
433
  * Sanitize a display name into a valid HTML anchor id: lowercase,
406
434
  * spaces/underscores → hyphens, other specials stripped, optional prefix for
407
- * disambiguation. The ONE canonical implementation — anchor generation and
408
- * cross-link routes must agree on it by construction.
435
+ * disambiguation.
436
+ *
437
+ * @remarks
438
+ * The ONE canonical implementation. This docstring made that claim before it
439
+ * was true: the RSPress adapter carried a second, subtly different sanitizer
440
+ * for page-side `id=` attributes (it kept `_`, being in `\w`, and mapped `$`
441
+ * to `-`), so `get_value` was linked as `#get-value` and rendered as
442
+ * `id="get_value"` — a cross-link that landed nowhere. Both sides call this
443
+ * now. Do not add a second spelling; if page ids ever genuinely need
444
+ * different treatment from route anchors, that is a design change, not a
445
+ * local helper.
409
446
  *
410
447
  * @public
411
448
  */
412
449
  declare function sanitizeId(displayName: string, prefix?: string): string;
450
+ /**
451
+ * Which slot a class member occupies. Two members may share a display name
452
+ * while occupying different slots (a `static create()` beside an instance
453
+ * `create()`), which is what {@link memberAnchors} disambiguates.
454
+ *
455
+ * @public
456
+ */
457
+ type MemberSlot = "static-property" | "static-method" | "instance-property" | "instance-method" | "getter";
458
+ /**
459
+ * One class member, identified for anchor computation.
460
+ *
461
+ * @public
462
+ */
463
+ interface MemberRef {
464
+ /**
465
+ * Caller-supplied stable identity, unique per member. An API Extractor
466
+ * `canonicalReference` is the natural choice: it already distinguishes a
467
+ * static member from an instance member of the same name (`Foo.bar` vs
468
+ * `Foo#bar`), so the caller never has to re-derive that distinction.
469
+ */
470
+ readonly id: string;
471
+ /** The member's display name, as written in the source. */
472
+ readonly displayName: string;
473
+ /** The slot the member occupies. */
474
+ readonly slot: MemberSlot;
475
+ }
476
+ /**
477
+ * The anchor for a single member, given an already-decided prefix.
478
+ *
479
+ * @remarks
480
+ * A thin alias over {@link sanitizeId} that names the intent at the call site.
481
+ * Prefer {@link memberAnchors} when rendering a whole class — deciding the
482
+ * prefix per member is the part that is easy to get wrong.
483
+ *
484
+ * @public
485
+ */
486
+ declare function memberAnchor(displayName: string, prefix?: string): string;
487
+ /**
488
+ * Compute the anchor for every member of one class, keyed by
489
+ * {@link MemberRef.id}.
490
+ *
491
+ * @remarks
492
+ * When several members sanitize to the same anchor, the highest-priority slot
493
+ * keeps the bare anchor and every other member is prefixed
494
+ * (`instance-create`). Priority runs static method, static property, instance
495
+ * method, getter, instance property — static first, so the anchor agrees with
496
+ * the bare cross-link key, which resolves to the static member.
497
+ *
498
+ * The per-MEMBER keying is load-bearing. A previous implementation keyed the
499
+ * prefix by sanitized NAME, so both halves of a `static create()` / `create()`
500
+ * collision looked up the same entry and both rendered
501
+ * `id="static-create"` — two elements sharing one HTML id, and the instance
502
+ * member displaced from the anchor its cross-link pointed at. Keying by
503
+ * member means the loser moves and the winner does not.
504
+ *
505
+ * @public
506
+ */
507
+ declare function memberAnchors(members: readonly MemberRef[]): ReadonlyMap<string, string>;
508
+ /**
509
+ * Cross-link keys for one class's members, mapped to the member they resolve
510
+ * to ({@link MemberRef.id}).
511
+ *
512
+ * @remarks
513
+ * The bare `Class.member` key resolves to the STATIC member when a class has
514
+ * both a static and an instance member of that name. `Registry.create` is the
515
+ * static access expression in TypeScript — the instance one is
516
+ * `registry.create` — so a prose author writing the qualified form means the
517
+ * static member.
518
+ *
519
+ * The disambiguating keys use TSDoc declaration-reference selectors, the
520
+ * vocabulary API Extractor canonical references already carry:
521
+ *
522
+ * - `Registry.create` — the static member (the common case)
523
+ * - `Registry.(create:instance)` — the instance member
524
+ * - `Registry.(create:static)` — the static member, explicitly
525
+ * - `Registry.prototype.create` — an alias for the instance member; real
526
+ * JavaScript rather than invented syntax, and what a reader guesses
527
+ *
528
+ * `Class#member` is deliberately NOT emitted. `#` is the URL fragment
529
+ * delimiter, so such a key reads ambiguously beside a route, and in modern
530
+ * TypeScript `#` denotes a PRIVATE field (`this.#count`) — the JSDoc
531
+ * convention predates both and has aged badly.
532
+ *
533
+ * Selector keys are emitted ONLY when a collision exists. On the
534
+ * overwhelmingly common class with no name collision the bare key is
535
+ * complete, and every extra key is one more pattern the prose cross-linker
536
+ * compiles and tests against every string it links.
537
+ *
538
+ * @public
539
+ */
540
+ declare function memberRouteKeys(className: string, members: readonly MemberRef[]): ReadonlyMap<string, string>;
413
541
  declare namespace Signature_d_exports {
414
542
  export { FormatOptions, format, linkReferences, stripExportDeclare };
415
543
  }
package/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Routes_exports } from "./Routes.js";
1
2
  import { Tsdoc_exports } from "./Tsdoc.js";
2
3
  import { ApiItems_exports } from "./ApiItems.js";
3
4
  import { CrossLinker } from "./CrossLinker.js";
@@ -5,7 +6,6 @@ import { EntryPoints_exports } from "./EntryPoints.js";
5
6
  import { Model_exports } from "./Model.js";
6
7
  import { Signature_exports } from "./Signature.js";
7
8
  import { Render_exports } from "./Render.js";
8
- import { Routes_exports } from "./Routes.js";
9
9
  import { StructuredData_exports } from "./StructuredData.js";
10
10
  import { SyntheticBases_exports } from "./SyntheticBases.js";
11
11
 
package/internal/prose.js CHANGED
@@ -1,5 +1,5 @@
1
- import { Markdown, Text } from "@effected/markdown";
2
1
  import { Result } from "effect";
2
+ import { Markdown, Text } from "@effected/markdown";
3
3
 
4
4
  //#region src/internal/prose.ts
5
5
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsdoctor/model",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
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
6
  "keywords": [
@@ -38,7 +38,7 @@
38
38
  "./package.json": "./package.json"
39
39
  },
40
40
  "dependencies": {
41
- "@microsoft/api-extractor-model": "^7.33.10",
41
+ "@microsoft/api-extractor-model": "^7.33.11",
42
42
  "@microsoft/tsdoc": "^0.16.0"
43
43
  },
44
44
  "peerDependencies": {