@rarusoft/dendrite-wiki 0.1.0-alpha.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.
Files changed (74) hide show
  1. package/README.md +79 -0
  2. package/dist/api-extractor/extract.js +269 -0
  3. package/dist/api-extractor/language-extractor.js +15 -0
  4. package/dist/api-extractor/python-extractor.js +358 -0
  5. package/dist/api-extractor/render.js +195 -0
  6. package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
  7. package/dist/api-extractor/types.js +11 -0
  8. package/dist/api-extractor/typescript-extractor.js +50 -0
  9. package/dist/api-extractor/walk.js +178 -0
  10. package/dist/api-reference.js +438 -0
  11. package/dist/benchmark-events.js +129 -0
  12. package/dist/benchmark.js +270 -0
  13. package/dist/binder-export.js +381 -0
  14. package/dist/canonical-target.js +168 -0
  15. package/dist/chart-insert.js +377 -0
  16. package/dist/chart-prompts.js +414 -0
  17. package/dist/context-cache.js +98 -0
  18. package/dist/contradicts-shipped-memory.js +232 -0
  19. package/dist/diff-context.js +142 -0
  20. package/dist/doctor.js +220 -0
  21. package/dist/generated-docs.js +219 -0
  22. package/dist/i18n.js +71 -0
  23. package/dist/index.js +49 -0
  24. package/dist/librarian.js +255 -0
  25. package/dist/maintenance-actions.js +244 -0
  26. package/dist/maintenance-inbox.js +842 -0
  27. package/dist/maintenance-runner.js +62 -0
  28. package/dist/page-drift.js +225 -0
  29. package/dist/page-inbox.js +168 -0
  30. package/dist/report-export.js +339 -0
  31. package/dist/review-bridge.js +1386 -0
  32. package/dist/search-index.js +199 -0
  33. package/dist/store.js +1617 -0
  34. package/dist/telemetry-defaults.js +44 -0
  35. package/dist/telemetry-report.js +263 -0
  36. package/dist/telemetry.js +544 -0
  37. package/dist/wiki-synthesis.js +901 -0
  38. package/package.json +35 -0
  39. package/src/api-extractor/extract.ts +333 -0
  40. package/src/api-extractor/language-extractor.ts +37 -0
  41. package/src/api-extractor/python-extractor.ts +380 -0
  42. package/src/api-extractor/render.ts +267 -0
  43. package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
  44. package/src/api-extractor/types.ts +41 -0
  45. package/src/api-extractor/typescript-extractor.ts +56 -0
  46. package/src/api-extractor/walk.ts +209 -0
  47. package/src/api-reference.ts +552 -0
  48. package/src/benchmark-events.ts +216 -0
  49. package/src/benchmark.ts +376 -0
  50. package/src/binder-export.ts +437 -0
  51. package/src/canonical-target.ts +192 -0
  52. package/src/chart-insert.ts +478 -0
  53. package/src/chart-prompts.ts +417 -0
  54. package/src/context-cache.ts +129 -0
  55. package/src/contradicts-shipped-memory.ts +311 -0
  56. package/src/diff-context.ts +187 -0
  57. package/src/doctor.ts +260 -0
  58. package/src/generated-docs.ts +316 -0
  59. package/src/i18n.ts +106 -0
  60. package/src/index.ts +59 -0
  61. package/src/librarian.ts +331 -0
  62. package/src/maintenance-actions.ts +314 -0
  63. package/src/maintenance-inbox.ts +1132 -0
  64. package/src/maintenance-runner.ts +85 -0
  65. package/src/page-drift.ts +292 -0
  66. package/src/page-inbox.ts +254 -0
  67. package/src/report-export.ts +392 -0
  68. package/src/review-bridge.ts +1729 -0
  69. package/src/search-index.ts +266 -0
  70. package/src/store.ts +2171 -0
  71. package/src/telemetry-defaults.ts +50 -0
  72. package/src/telemetry-report.ts +365 -0
  73. package/src/telemetry.ts +757 -0
  74. package/src/wiki-synthesis.ts +1307 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Python `LanguageExtractor` — the second built-in, validating the A7 pluggability layer.
3
+ *
4
+ * Implements the same `LanguageExtractor` contract as `./typescript-extractor.ts`, but
5
+ * for Python source trees. Detection looks for the conventional Python project signals
6
+ * (`pyproject.toml`, `setup.py`, `setup.cfg`, `requirements.txt`); extraction shells out
7
+ * to a local Python 3.9+ interpreter with a small embedded helper script that walks the
8
+ * Python AST via the standard-library `ast` module and emits an `ApiFileReference` shape
9
+ * directly. The Python interpreter is found via PATH (`python3` then `python`), and its
10
+ * version is verified before the extractor claims a project — projects with Python
11
+ * sources but no usable Python on the developer's machine cleanly fall through to the
12
+ * "no extractor claims this project" path in the orchestrator.
13
+ *
14
+ * Mapping from Python kinds to the language-agnostic `ApiSymbolKind`: `def` and
15
+ * `async def` → `function`; `class` → `class` (or `enum` when the class subclasses
16
+ * `Enum`/`IntEnum`/`StrEnum`/`Flag`); module-level assignments → `variable` (or
17
+ * `type-alias` when the target is PascalCase or annotated with `TypeAlias`). Names with
18
+ * a leading underscore are dropped per Python's privacy convention. `@deprecated`
19
+ * decorators flow through to the `isDeprecated` flag the renderer's callout uses.
20
+ *
21
+ * Docstrings come back as the `docComment` field verbatim — Python's docstring
22
+ * conventions (Google, NumPy, Sphinx) vary, so first-cut rendering keeps the prose as-is
23
+ * rather than guessing at a specific style. A future polish pass could parse Google-
24
+ * style sections and convert them to the same `ApiDocTag` shape JSDoc uses.
25
+ *
26
+ * KNOWN LIMITATION (tracked in docs/wiki/api-reference-roadmap.md): only top-level
27
+ * symbols are extracted. Class bodies are NOT recursed into, so methods, properties,
28
+ * `@classmethod`s, and `@staticmethod`s of a documented class do not appear on the page.
29
+ * The class itself surfaces with its docstring, but its members do not. This is
30
+ * deliberately scoped out of the v0 extractor — proper handling needs design choices
31
+ * around how to render `Class.method` (flat namespaced symbol vs. nested section), how
32
+ * to surface decorator metadata, and whether `@property` getters should map to
33
+ * `kind: 'variable'` or `kind: 'function'`. Until that design pass lands, Python class
34
+ * pages are deliberately thinner than their TypeScript counterparts.
35
+ */
36
+
37
+ import { spawn } from 'node:child_process';
38
+ import { promises as fs } from 'node:fs';
39
+ import path from 'node:path';
40
+ import type { ApiFileReference } from './types.js';
41
+ import type { LanguageExtractor } from './language-extractor.js';
42
+ import { walkProjectSources, type WalkOptions } from './walk.js';
43
+
44
+ // Embedded Python helper script. Sent to the interpreter via stdin so command-line length
45
+ // limits never become a concern (Windows cmd.exe caps total command line at ~8KB). The
46
+ // script reads one source path from argv and emits the ApiFileReference JSON to stdout.
47
+ const PYTHON_HELPER_SCRIPT = `import ast
48
+ import json
49
+ import sys
50
+
51
+
52
+ def is_public(name):
53
+ return bool(name) and not name.startswith('_')
54
+
55
+
56
+ def get_decorator_names(node):
57
+ names = []
58
+ decorators = getattr(node, 'decorator_list', []) or []
59
+ for dec in decorators:
60
+ if isinstance(dec, ast.Name):
61
+ names.append(dec.id)
62
+ elif isinstance(dec, ast.Attribute):
63
+ names.append(dec.attr)
64
+ elif isinstance(dec, ast.Call):
65
+ if isinstance(dec.func, ast.Name):
66
+ names.append(dec.func.id)
67
+ elif isinstance(dec.func, ast.Attribute):
68
+ names.append(dec.func.attr)
69
+ return names
70
+
71
+
72
+ def is_deprecated(decorator_names):
73
+ return any(name.lower() == 'deprecated' for name in decorator_names)
74
+
75
+
76
+ def render_function_signature(node):
77
+ is_async = isinstance(node, ast.AsyncFunctionDef)
78
+ args = ast.unparse(node.args) if node.args else ''
79
+ returns = ''
80
+ if node.returns is not None:
81
+ returns = ' -> ' + ast.unparse(node.returns)
82
+ prefix = 'async def' if is_async else 'def'
83
+ return prefix + ' ' + node.name + '(' + args + ')' + returns
84
+
85
+
86
+ def render_class_signature(node):
87
+ parts = []
88
+ for base in node.bases:
89
+ parts.append(ast.unparse(base))
90
+ for kw in node.keywords:
91
+ if kw.arg:
92
+ parts.append(kw.arg + '=' + ast.unparse(kw.value))
93
+ else:
94
+ parts.append('**' + ast.unparse(kw.value))
95
+ head = 'class ' + node.name
96
+ if parts:
97
+ head = head + '(' + ', '.join(parts) + ')'
98
+ return head
99
+
100
+
101
+ ENUM_BASE_NAMES = {'Enum', 'IntEnum', 'StrEnum', 'Flag', 'IntFlag'}
102
+
103
+
104
+ def is_enum_class(node):
105
+ for base in node.bases:
106
+ if isinstance(base, ast.Name) and base.id in ENUM_BASE_NAMES:
107
+ return True
108
+ if isinstance(base, ast.Attribute) and base.attr in ENUM_BASE_NAMES:
109
+ return True
110
+ return False
111
+
112
+
113
+ def render_assign_signature(node):
114
+ try:
115
+ return ast.unparse(node)
116
+ except Exception:
117
+ return None
118
+
119
+
120
+ def is_type_alias(node, target_name):
121
+ # PEP 613 TypeAlias annotation
122
+ if isinstance(node, ast.AnnAssign):
123
+ ann = node.annotation
124
+ if isinstance(ann, ast.Name) and ann.id == 'TypeAlias':
125
+ return True
126
+ if isinstance(ann, ast.Attribute) and ann.attr == 'TypeAlias':
127
+ return True
128
+ # PEP 695 type statement (Python 3.12+)
129
+ if hasattr(ast, 'TypeAlias') and isinstance(node, getattr(ast, 'TypeAlias')):
130
+ return True
131
+ # Heuristic fallback: PascalCase target name on a value-only assignment. We require
132
+ # at least one lowercase letter so SCREAMING_CASE constants (DEFAULT_NAME, MAX_RETRIES,
133
+ # etc.) stay classified as variables instead of being treated as type aliases.
134
+ if isinstance(node, ast.Assign) and target_name and target_name[0].isupper():
135
+ has_lowercase = any(c.islower() for c in target_name)
136
+ return has_lowercase
137
+ return False
138
+
139
+
140
+ def collect_assign_target(node):
141
+ if isinstance(node, ast.Assign):
142
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
143
+ return node.targets[0].id
144
+ return None
145
+ if isinstance(node, ast.AnnAssign):
146
+ if isinstance(node.target, ast.Name):
147
+ return node.target.id
148
+ return None
149
+ if hasattr(ast, 'TypeAlias') and isinstance(node, getattr(ast, 'TypeAlias')):
150
+ return node.name.id
151
+ return None
152
+
153
+
154
+ def parse_file(path):
155
+ with open(path, 'r', encoding='utf-8') as handle:
156
+ source = handle.read()
157
+ tree = ast.parse(source, filename=path)
158
+ file_doc = ast.get_docstring(tree)
159
+
160
+ symbols = []
161
+ for node in tree.body:
162
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
163
+ if not is_public(node.name):
164
+ continue
165
+ decorator_names = get_decorator_names(node)
166
+ symbols.append({
167
+ 'name': node.name,
168
+ 'kind': 'function',
169
+ 'signature': render_function_signature(node),
170
+ 'docComment': ast.get_docstring(node),
171
+ 'tags': [],
172
+ 'sourceLine': node.lineno,
173
+ 'isDeprecated': is_deprecated(decorator_names),
174
+ })
175
+ elif isinstance(node, ast.ClassDef):
176
+ if not is_public(node.name):
177
+ continue
178
+ decorator_names = get_decorator_names(node)
179
+ kind = 'enum' if is_enum_class(node) else 'class'
180
+ symbols.append({
181
+ 'name': node.name,
182
+ 'kind': kind,
183
+ 'signature': render_class_signature(node),
184
+ 'docComment': ast.get_docstring(node),
185
+ 'tags': [],
186
+ 'sourceLine': node.lineno,
187
+ 'isDeprecated': is_deprecated(decorator_names),
188
+ })
189
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)) or (
190
+ hasattr(ast, 'TypeAlias') and isinstance(node, getattr(ast, 'TypeAlias'))
191
+ ):
192
+ target_name = collect_assign_target(node)
193
+ if not target_name or not is_public(target_name):
194
+ continue
195
+ sig = render_assign_signature(node)
196
+ if not sig:
197
+ continue
198
+ kind = 'type-alias' if is_type_alias(node, target_name) else 'variable'
199
+ symbols.append({
200
+ 'name': target_name,
201
+ 'kind': kind,
202
+ 'signature': sig,
203
+ 'docComment': None,
204
+ 'tags': [],
205
+ 'sourceLine': node.lineno,
206
+ 'isDeprecated': False,
207
+ })
208
+
209
+ symbols.sort(key=lambda s: s['sourceLine'])
210
+
211
+ return {
212
+ 'sourcePath': '',
213
+ 'moduleSlug': '',
214
+ 'symbols': symbols,
215
+ 'fileDocComment': file_doc,
216
+ }
217
+
218
+
219
+ if __name__ == '__main__':
220
+ if len(sys.argv) < 2:
221
+ print('usage: helper.py <source-path>', file=sys.stderr)
222
+ sys.exit(2)
223
+ source_path = sys.argv[1]
224
+ result = parse_file(source_path)
225
+ json.dump(result, sys.stdout, ensure_ascii=False)
226
+ `;
227
+
228
+ const PYTHON_DEFAULT_INCLUDE = ['**/*.py'];
229
+ const PYTHON_DEFAULT_EXCLUDE = [
230
+ '**/tests/**',
231
+ '**/test_*.py',
232
+ '**/*_test.py',
233
+ '**/__pycache__/**',
234
+ '**/.venv/**',
235
+ '**/venv/**',
236
+ '**/env/**',
237
+ '**/build/**',
238
+ '**/dist/**',
239
+ '**/site-packages/**',
240
+ '**/node_modules/**'
241
+ ];
242
+
243
+ interface CommandResult {
244
+ stdout: string;
245
+ stderr: string;
246
+ exitCode: number;
247
+ }
248
+
249
+ async function runCommand(command: string, args: string[], stdin?: string): Promise<CommandResult> {
250
+ return new Promise((resolve, reject) => {
251
+ const child = spawn(command, args, { shell: false });
252
+ let stdout = '';
253
+ let stderr = '';
254
+ child.stdout.on('data', (chunk) => {
255
+ stdout += chunk.toString('utf8');
256
+ });
257
+ child.stderr.on('data', (chunk) => {
258
+ stderr += chunk.toString('utf8');
259
+ });
260
+ child.on('error', reject);
261
+ child.on('close', (code) => resolve({ stdout, stderr, exitCode: code ?? -1 }));
262
+ // Suppress EPIPE on the stdin stream — fired synchronously when the child process
263
+ // dies before reading (interpreter crash on import, version mismatch, etc.). Without
264
+ // this handler the error becomes an uncaught exception. The child's exit code +
265
+ // stderr surface the underlying problem through the regular `close` path.
266
+ child.stdin.on('error', () => {});
267
+ if (stdin) {
268
+ child.stdin.write(stdin);
269
+ }
270
+ child.stdin.end();
271
+ });
272
+ }
273
+
274
+ async function exists(filePath: string): Promise<boolean> {
275
+ try {
276
+ await fs.access(filePath);
277
+ return true;
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ async function findPythonInterpreter(): Promise<string | null> {
284
+ const candidates = process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python'];
285
+ for (const candidate of candidates) {
286
+ try {
287
+ const result = await runCommand(candidate, ['--version']);
288
+ if (result.exitCode !== 0) continue;
289
+ // `python --version` writes to stdout on 3.4+, sometimes to stderr on older releases.
290
+ const versionText = `${result.stdout}\n${result.stderr}`;
291
+ const match = versionText.match(/Python (\d+)\.(\d+)/);
292
+ if (!match) continue;
293
+ const major = Number(match[1]);
294
+ const minor = Number(match[2]);
295
+ // ast.unparse requires Python 3.9+. Below that the helper script can't run.
296
+ if (major > 3 || (major === 3 && minor >= 9)) {
297
+ return candidate;
298
+ }
299
+ } catch {
300
+ // Spawn failed (interpreter not found or not executable); try the next candidate.
301
+ }
302
+ }
303
+ return null;
304
+ }
305
+
306
+ // Memoize the interpreter lookup so we don't pay --version overhead on every extract().
307
+ let cachedInterpreter: string | null | undefined;
308
+ async function getPython(): Promise<string | null> {
309
+ if (cachedInterpreter !== undefined) {
310
+ return cachedInterpreter;
311
+ }
312
+ cachedInterpreter = await findPythonInterpreter();
313
+ return cachedInterpreter;
314
+ }
315
+
316
+ // Test-only escape hatch: clear the memoized interpreter so tests can simulate "Python
317
+ // disappears mid-run" or run multiple isolated detection scenarios.
318
+ export function resetPythonInterpreterCache(): void {
319
+ cachedInterpreter = undefined;
320
+ }
321
+
322
+ export const pythonExtractor: LanguageExtractor = {
323
+ id: 'python',
324
+
325
+ async detect(rootDir: string): Promise<boolean> {
326
+ // Only claim a project that BOTH (a) shows a Python project signal in its root and
327
+ // (b) has a usable Python 3.9+ interpreter on PATH. If Python isn't installed, the
328
+ // orchestrator falls through to the next extractor (e.g., the TS one) or returns an
329
+ // empty result — we never throw at detect-time.
330
+ const signals = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt'];
331
+ let hasSignal = false;
332
+ for (const signal of signals) {
333
+ if (await exists(path.join(rootDir, signal))) {
334
+ hasSignal = true;
335
+ break;
336
+ }
337
+ }
338
+ if (!hasSignal) {
339
+ return false;
340
+ }
341
+ return (await getPython()) !== null;
342
+ },
343
+
344
+ async walk(rootDir: string, options?: WalkOptions): Promise<string[]> {
345
+ return walkProjectSources(rootDir, {
346
+ include: options?.include ?? PYTHON_DEFAULT_INCLUDE,
347
+ exclude: options?.exclude ?? PYTHON_DEFAULT_EXCLUDE,
348
+ // The @internal-tag walker convention is JSDoc-specific; Python's privacy convention
349
+ // is the leading-underscore name. The extract step honors that already.
350
+ respectInternalConvention: false
351
+ });
352
+ },
353
+
354
+ async extract(sourcePath: string, options?: { rootDir?: string }): Promise<ApiFileReference> {
355
+ const python = await getPython();
356
+ if (!python) {
357
+ throw new Error(
358
+ 'pythonExtractor.extract: Python 3.9+ is required but no usable interpreter was found on PATH'
359
+ );
360
+ }
361
+ const rootDir = options?.rootDir ?? process.cwd();
362
+ const absolute = path.isAbsolute(sourcePath) ? sourcePath : path.resolve(rootDir, sourcePath);
363
+ const relative = path.relative(rootDir, absolute).replace(/\\/g, '/');
364
+
365
+ const result = await runCommand(python, ['-', absolute], PYTHON_HELPER_SCRIPT);
366
+ if (result.exitCode !== 0) {
367
+ throw new Error(
368
+ `pythonExtractor.extract: helper failed for ${relative} (exit ${result.exitCode}): ${result.stderr.trim()}`
369
+ );
370
+ }
371
+
372
+ const parsed = JSON.parse(result.stdout) as ApiFileReference;
373
+ parsed.sourcePath = relative;
374
+ // Mirror typeScriptExtractor's slug shape: `api/<dir>/<basename>` with the language
375
+ // extension stripped. Python files outside a top-level `src/` keep their full path.
376
+ const stripped = relative.replace(/^src\//, '').replace(/\.py$/i, '');
377
+ parsed.moduleSlug = `api/${stripped}`;
378
+ return parsed;
379
+ }
380
+ };
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Renders an `ApiFileReference` to a markdown page body.
3
+ *
4
+ * Pure module — no IO, no global state, no `Date.now()`. Every source of non-determinism
5
+ * (timestamp, source-link path base, link resolver) is passed in by the caller. That keeps
6
+ * the byte-stable round-trip property easy to enforce in tests: identical input produces
7
+ * identical output, and `npm run wiki:refresh` run twice produces zero diffs.
8
+ *
9
+ * The output shape is the contract: frontmatter (lifecycle: generated + source-coverage:
10
+ * api-reference + source-file: ...), an H1 of the file path, the file-level doc comment if
11
+ * any, an "Exports" table-of-contents, then per-symbol sections with kind, source link,
12
+ * signature code block, doc body, and per-tag sub-sections (`@param` table, `@returns`,
13
+ * `@throws`, `@example`, `@see`, `@since`, unknown tags). Cross-reference resolution for
14
+ * `{@link Foo}` is handled via an optional `LinkResolver` callback supplied by the
15
+ * orchestrator — this module never knows about other files.
16
+ */
17
+
18
+ import type { ApiFileReference, ApiSymbol, ApiSymbolKind } from './types.js';
19
+
20
+ export interface LinkResolution {
21
+ // Resolved URL — null means the link could not be resolved; the renderer falls back to
22
+ // emitting `display` as plain text.
23
+ url: string | null;
24
+ // Text to display in the rendered link. Defaults to the link target if the @link form had
25
+ // no override text.
26
+ display: string;
27
+ // Optional HTML comment appended after the display text. Used by the orchestrator's
28
+ // resolver to mark ambiguous links inline for downstream diagnostics.
29
+ comment?: string;
30
+ }
31
+
32
+ // Resolver invoked for each `{@link Target [text]}` occurrence the renderer encounters in
33
+ // any text field (doc bodies, tag descriptions). When undefined, the renderer leaves the
34
+ // `{@link ...}` literal in place — the A1 behavior, preserved for tests.
35
+ export type LinkResolver = (target: string, displayText: string | undefined) => LinkResolution;
36
+
37
+ export interface RenderOptions {
38
+ // ISO timestamp written into frontmatter as `last-generated`. If omitted, the field is
39
+ // omitted from the frontmatter — A1 keeps this optional so the single-file proof can be
40
+ // tested without injecting time.
41
+ generatedAt?: string;
42
+ // Relative path prefix used to build source-line links. Defaults to '../..' which is
43
+ // correct for pages at `docs/wiki/<name>.md`. The A2 orchestrator computes a proper
44
+ // depth-aware base and passes it in. Used as the fallback when `sourceLinkResolver` is
45
+ // not set; mostly for tests + IDE-relative viewing of the raw markdown.
46
+ sourceLinkBase?: string;
47
+ // Resolver that builds the source-line URL emitted on each symbol's "Source:" line.
48
+ // Returning a non-null string emits a markdown link; returning null emits plain text
49
+ // (no link). When the option itself is undefined, the renderer falls back to the
50
+ // legacy `sourceLinkBase` relative-path behavior. Production callers always supply this:
51
+ // for GitHub-hosted projects it returns a full https://github.com/.../blob/<branch>/...#L<line>
52
+ // URL (works in VitePress, on GitHub web, and in IDEs alike); for projects with no
53
+ // detectable repository URL it returns null so the page just shows plain text rather
54
+ // than a relative link that 404s in the browser-served VitePress site.
55
+ sourceLinkResolver?: (sourcePath: string, line: number) => string | null;
56
+ // Resolver for `{@link}` references — see `LinkResolver`. When omitted, links are left
57
+ // as their literal source text.
58
+ resolveLink?: LinkResolver;
59
+ }
60
+
61
+ const LINK_PATTERN = /\{@link\s+([^\s|}]+)(?:\s*[|]?\s*([^}]+))?\}/g;
62
+
63
+ function applyLinkResolution(text: string, resolver: LinkResolver | undefined): string {
64
+ if (!resolver) {
65
+ return text;
66
+ }
67
+ return text.replace(LINK_PATTERN, (_, target: string, displayRaw: string | undefined) => {
68
+ const display = displayRaw?.trim();
69
+ const resolution = resolver(target, display && display.length > 0 ? display : undefined);
70
+ const rendered = resolution.url ? `[${resolution.display}](${resolution.url})` : resolution.display;
71
+ return resolution.comment ? `${rendered}<!-- ${resolution.comment} -->` : rendered;
72
+ });
73
+ }
74
+
75
+ export function renderApiPage(ref: ApiFileReference, options: RenderOptions = {}): string {
76
+ const sourceLinkBase = options.sourceLinkBase ?? '../..';
77
+ const lines: string[] = [];
78
+
79
+ lines.push('---');
80
+ lines.push('lifecycle: generated');
81
+ lines.push('source-coverage: api-reference');
82
+ lines.push(`source-file: ${ref.sourcePath}`);
83
+ if (options.generatedAt) {
84
+ lines.push(`last-generated: ${options.generatedAt}`);
85
+ }
86
+ lines.push('---');
87
+ lines.push('');
88
+ lines.push(`# \`${ref.sourcePath}\``);
89
+ lines.push('');
90
+
91
+ if (ref.fileDocComment) {
92
+ lines.push(applyLinkResolution(ref.fileDocComment, options.resolveLink));
93
+ lines.push('');
94
+ }
95
+
96
+ if (ref.symbols.length === 0) {
97
+ lines.push('_No documented exports._');
98
+ lines.push('');
99
+ return lines.join('\n');
100
+ }
101
+
102
+ lines.push('## Exports');
103
+ lines.push('');
104
+ for (const symbol of ref.symbols) {
105
+ lines.push(`- [\`${symbol.name}\`](#${anchorFor(symbol.name)}) — ${kindLabel(symbol.kind)}`);
106
+ }
107
+ lines.push('');
108
+
109
+ for (const symbol of ref.symbols) {
110
+ lines.push('---');
111
+ lines.push('');
112
+ lines.push(...renderSymbol(symbol, ref.sourcePath, sourceLinkBase, options.sourceLinkResolver, options.resolveLink));
113
+ }
114
+
115
+ return lines.join('\n');
116
+ }
117
+
118
+ function renderSymbol(
119
+ symbol: ApiSymbol,
120
+ sourcePath: string,
121
+ sourceLinkBase: string,
122
+ sourceLinkResolver: ((sourcePath: string, line: number) => string | null) | undefined,
123
+ resolveLink: LinkResolver | undefined
124
+ ): string[] {
125
+ const lines: string[] = [];
126
+ lines.push(`### \`${symbol.name}\``);
127
+ lines.push('');
128
+
129
+ if (symbol.isDeprecated) {
130
+ const reason = (symbol.tags.find((tag) => tag.name === 'deprecated')?.text ?? '').trim();
131
+ const resolvedReason = applyLinkResolution(reason, resolveLink);
132
+ lines.push(`> ⚠️ **Deprecated:** ${resolvedReason || 'this symbol is deprecated.'}`);
133
+ lines.push('');
134
+ }
135
+
136
+ // Source-link URL strategy:
137
+ // 1. If a resolver was supplied, use its output. A non-null string becomes a markdown
138
+ // link; null becomes plain text (no link). The orchestrator builds full
139
+ // https://github.com/... URLs when it can detect the repository, otherwise returns
140
+ // null so the page never emits a 404-prone relative link.
141
+ // 2. If no resolver was supplied, fall back to the legacy relative-path link from
142
+ // `sourceLinkBase`. This works on GitHub web view of the raw markdown and in some
143
+ // IDEs; it does NOT work in VitePress's static site, which is why the resolver path
144
+ // is the production default.
145
+ let sourceLine: string;
146
+ if (sourceLinkResolver) {
147
+ const url = sourceLinkResolver(sourcePath, symbol.sourceLine);
148
+ sourceLine = url
149
+ ? `[${sourcePath}:${symbol.sourceLine}](${url})`
150
+ : `${sourcePath}:${symbol.sourceLine}`;
151
+ } else {
152
+ sourceLine = `[${sourcePath}:${symbol.sourceLine}](${sourceLinkBase}/${sourcePath}#L${symbol.sourceLine})`;
153
+ }
154
+ lines.push(`**Kind:** ${kindLabel(symbol.kind)} · **Source:** ${sourceLine}`);
155
+ lines.push('');
156
+ lines.push('```ts');
157
+ lines.push(symbol.signature);
158
+ lines.push('```');
159
+ lines.push('');
160
+
161
+ if (symbol.docComment) {
162
+ lines.push(applyLinkResolution(symbol.docComment, resolveLink));
163
+ lines.push('');
164
+ }
165
+
166
+ const paramTags = symbol.tags.filter((tag) => tag.name === 'param');
167
+ if (paramTags.length > 0) {
168
+ lines.push('#### Parameters');
169
+ lines.push('');
170
+ lines.push('| Name | Description |');
171
+ lines.push('|---|---|');
172
+ for (const tag of paramTags) {
173
+ const name = tag.paramName ?? '';
174
+ const description = escapeTableCell(applyLinkResolution(tag.text, resolveLink));
175
+ lines.push(`| \`${name}\` | ${description} |`);
176
+ }
177
+ lines.push('');
178
+ }
179
+
180
+ const returnsTag = symbol.tags.find((tag) => tag.name === 'returns' || tag.name === 'return');
181
+ if (returnsTag) {
182
+ lines.push('#### Returns');
183
+ lines.push('');
184
+ lines.push(applyLinkResolution(returnsTag.text, resolveLink) || '_(no description)_');
185
+ lines.push('');
186
+ }
187
+
188
+ const throwsTags = symbol.tags.filter((tag) => tag.name === 'throws' || tag.name === 'throw');
189
+ if (throwsTags.length > 0) {
190
+ lines.push('#### Throws');
191
+ lines.push('');
192
+ for (const tag of throwsTags) {
193
+ lines.push(`- ${applyLinkResolution(tag.text, resolveLink)}`);
194
+ }
195
+ lines.push('');
196
+ }
197
+
198
+ // @example bodies are code; do not run them through the link resolver.
199
+ const exampleTags = symbol.tags.filter((tag) => tag.name === 'example');
200
+ for (const tag of exampleTags) {
201
+ lines.push('#### Example');
202
+ lines.push('');
203
+ lines.push('```ts');
204
+ lines.push(tag.text);
205
+ lines.push('```');
206
+ lines.push('');
207
+ }
208
+
209
+ const seeTags = symbol.tags.filter((tag) => tag.name === 'see');
210
+ if (seeTags.length > 0) {
211
+ lines.push('#### See');
212
+ lines.push('');
213
+ for (const tag of seeTags) {
214
+ lines.push(`- ${applyLinkResolution(tag.text, resolveLink)}`);
215
+ }
216
+ lines.push('');
217
+ }
218
+
219
+ const sinceTag = symbol.tags.find((tag) => tag.name === 'since');
220
+ if (sinceTag) {
221
+ lines.push(`**Since:** ${applyLinkResolution(sinceTag.text, resolveLink)}`);
222
+ lines.push('');
223
+ }
224
+
225
+ const reservedTags = new Set(['param', 'returns', 'return', 'throws', 'throw', 'example', 'see', 'since', 'deprecated', 'internal']);
226
+ const unknownTags = symbol.tags.filter((tag) => !reservedTags.has(tag.name));
227
+ if (unknownTags.length > 0) {
228
+ lines.push('#### Tags');
229
+ lines.push('');
230
+ for (const tag of unknownTags) {
231
+ const resolved = applyLinkResolution(tag.text, resolveLink);
232
+ const text = resolved ? `: ${resolved}` : '';
233
+ lines.push(`- **@${tag.name}**${text}`);
234
+ }
235
+ lines.push('');
236
+ }
237
+
238
+ return lines;
239
+ }
240
+
241
+ function kindLabel(kind: ApiSymbolKind): string {
242
+ switch (kind) {
243
+ case 'function':
244
+ return 'function';
245
+ case 'class':
246
+ return 'class';
247
+ case 'interface':
248
+ return 'interface';
249
+ case 'type-alias':
250
+ return 'type alias';
251
+ case 'enum':
252
+ return 'enum';
253
+ case 'variable':
254
+ return 'variable';
255
+ }
256
+ }
257
+
258
+ export function anchorFor(name: string): string {
259
+ return name
260
+ .toLowerCase()
261
+ .replace(/[^a-z0-9]+/g, '-')
262
+ .replace(/^-+|-+$/g, '');
263
+ }
264
+
265
+ function escapeTableCell(value: string): string {
266
+ return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
267
+ }