@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.
- package/README.md +79 -0
- package/dist/api-extractor/extract.js +269 -0
- package/dist/api-extractor/language-extractor.js +15 -0
- package/dist/api-extractor/python-extractor.js +358 -0
- package/dist/api-extractor/render.js +195 -0
- package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
- package/dist/api-extractor/types.js +11 -0
- package/dist/api-extractor/typescript-extractor.js +50 -0
- package/dist/api-extractor/walk.js +178 -0
- package/dist/api-reference.js +438 -0
- package/dist/benchmark-events.js +129 -0
- package/dist/benchmark.js +270 -0
- package/dist/binder-export.js +381 -0
- package/dist/canonical-target.js +168 -0
- package/dist/chart-insert.js +377 -0
- package/dist/chart-prompts.js +414 -0
- package/dist/context-cache.js +98 -0
- package/dist/contradicts-shipped-memory.js +232 -0
- package/dist/diff-context.js +142 -0
- package/dist/doctor.js +220 -0
- package/dist/generated-docs.js +219 -0
- package/dist/i18n.js +71 -0
- package/dist/index.js +49 -0
- package/dist/librarian.js +255 -0
- package/dist/maintenance-actions.js +244 -0
- package/dist/maintenance-inbox.js +842 -0
- package/dist/maintenance-runner.js +62 -0
- package/dist/page-drift.js +225 -0
- package/dist/page-inbox.js +168 -0
- package/dist/report-export.js +339 -0
- package/dist/review-bridge.js +1386 -0
- package/dist/search-index.js +199 -0
- package/dist/store.js +1617 -0
- package/dist/telemetry-defaults.js +44 -0
- package/dist/telemetry-report.js +263 -0
- package/dist/telemetry.js +544 -0
- package/dist/wiki-synthesis.js +901 -0
- package/package.json +35 -0
- package/src/api-extractor/extract.ts +333 -0
- package/src/api-extractor/language-extractor.ts +37 -0
- package/src/api-extractor/python-extractor.ts +380 -0
- package/src/api-extractor/render.ts +267 -0
- package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
- package/src/api-extractor/types.ts +41 -0
- package/src/api-extractor/typescript-extractor.ts +56 -0
- package/src/api-extractor/walk.ts +209 -0
- package/src/api-reference.ts +552 -0
- package/src/benchmark-events.ts +216 -0
- package/src/benchmark.ts +376 -0
- package/src/binder-export.ts +437 -0
- package/src/canonical-target.ts +192 -0
- package/src/chart-insert.ts +478 -0
- package/src/chart-prompts.ts +417 -0
- package/src/context-cache.ts +129 -0
- package/src/contradicts-shipped-memory.ts +311 -0
- package/src/diff-context.ts +187 -0
- package/src/doctor.ts +260 -0
- package/src/generated-docs.ts +316 -0
- package/src/i18n.ts +106 -0
- package/src/index.ts +59 -0
- package/src/librarian.ts +331 -0
- package/src/maintenance-actions.ts +314 -0
- package/src/maintenance-inbox.ts +1132 -0
- package/src/maintenance-runner.ts +85 -0
- package/src/page-drift.ts +292 -0
- package/src/page-inbox.ts +254 -0
- package/src/report-export.ts +392 -0
- package/src/review-bridge.ts +1729 -0
- package/src/search-index.ts +266 -0
- package/src/store.ts +2171 -0
- package/src/telemetry-defaults.ts +50 -0
- package/src/telemetry-report.ts +365 -0
- package/src/telemetry.ts +757 -0
- package/src/wiki-synthesis.ts +1307 -0
|
@@ -0,0 +1,358 @@
|
|
|
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
|
+
import { spawn } from 'node:child_process';
|
|
37
|
+
import { promises as fs } from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import { walkProjectSources } from './walk.js';
|
|
40
|
+
// Embedded Python helper script. Sent to the interpreter via stdin so command-line length
|
|
41
|
+
// limits never become a concern (Windows cmd.exe caps total command line at ~8KB). The
|
|
42
|
+
// script reads one source path from argv and emits the ApiFileReference JSON to stdout.
|
|
43
|
+
const PYTHON_HELPER_SCRIPT = `import ast
|
|
44
|
+
import json
|
|
45
|
+
import sys
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_public(name):
|
|
49
|
+
return bool(name) and not name.startswith('_')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_decorator_names(node):
|
|
53
|
+
names = []
|
|
54
|
+
decorators = getattr(node, 'decorator_list', []) or []
|
|
55
|
+
for dec in decorators:
|
|
56
|
+
if isinstance(dec, ast.Name):
|
|
57
|
+
names.append(dec.id)
|
|
58
|
+
elif isinstance(dec, ast.Attribute):
|
|
59
|
+
names.append(dec.attr)
|
|
60
|
+
elif isinstance(dec, ast.Call):
|
|
61
|
+
if isinstance(dec.func, ast.Name):
|
|
62
|
+
names.append(dec.func.id)
|
|
63
|
+
elif isinstance(dec.func, ast.Attribute):
|
|
64
|
+
names.append(dec.func.attr)
|
|
65
|
+
return names
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_deprecated(decorator_names):
|
|
69
|
+
return any(name.lower() == 'deprecated' for name in decorator_names)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_function_signature(node):
|
|
73
|
+
is_async = isinstance(node, ast.AsyncFunctionDef)
|
|
74
|
+
args = ast.unparse(node.args) if node.args else ''
|
|
75
|
+
returns = ''
|
|
76
|
+
if node.returns is not None:
|
|
77
|
+
returns = ' -> ' + ast.unparse(node.returns)
|
|
78
|
+
prefix = 'async def' if is_async else 'def'
|
|
79
|
+
return prefix + ' ' + node.name + '(' + args + ')' + returns
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def render_class_signature(node):
|
|
83
|
+
parts = []
|
|
84
|
+
for base in node.bases:
|
|
85
|
+
parts.append(ast.unparse(base))
|
|
86
|
+
for kw in node.keywords:
|
|
87
|
+
if kw.arg:
|
|
88
|
+
parts.append(kw.arg + '=' + ast.unparse(kw.value))
|
|
89
|
+
else:
|
|
90
|
+
parts.append('**' + ast.unparse(kw.value))
|
|
91
|
+
head = 'class ' + node.name
|
|
92
|
+
if parts:
|
|
93
|
+
head = head + '(' + ', '.join(parts) + ')'
|
|
94
|
+
return head
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
ENUM_BASE_NAMES = {'Enum', 'IntEnum', 'StrEnum', 'Flag', 'IntFlag'}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_enum_class(node):
|
|
101
|
+
for base in node.bases:
|
|
102
|
+
if isinstance(base, ast.Name) and base.id in ENUM_BASE_NAMES:
|
|
103
|
+
return True
|
|
104
|
+
if isinstance(base, ast.Attribute) and base.attr in ENUM_BASE_NAMES:
|
|
105
|
+
return True
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_assign_signature(node):
|
|
110
|
+
try:
|
|
111
|
+
return ast.unparse(node)
|
|
112
|
+
except Exception:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_type_alias(node, target_name):
|
|
117
|
+
# PEP 613 TypeAlias annotation
|
|
118
|
+
if isinstance(node, ast.AnnAssign):
|
|
119
|
+
ann = node.annotation
|
|
120
|
+
if isinstance(ann, ast.Name) and ann.id == 'TypeAlias':
|
|
121
|
+
return True
|
|
122
|
+
if isinstance(ann, ast.Attribute) and ann.attr == 'TypeAlias':
|
|
123
|
+
return True
|
|
124
|
+
# PEP 695 type statement (Python 3.12+)
|
|
125
|
+
if hasattr(ast, 'TypeAlias') and isinstance(node, getattr(ast, 'TypeAlias')):
|
|
126
|
+
return True
|
|
127
|
+
# Heuristic fallback: PascalCase target name on a value-only assignment. We require
|
|
128
|
+
# at least one lowercase letter so SCREAMING_CASE constants (DEFAULT_NAME, MAX_RETRIES,
|
|
129
|
+
# etc.) stay classified as variables instead of being treated as type aliases.
|
|
130
|
+
if isinstance(node, ast.Assign) and target_name and target_name[0].isupper():
|
|
131
|
+
has_lowercase = any(c.islower() for c in target_name)
|
|
132
|
+
return has_lowercase
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def collect_assign_target(node):
|
|
137
|
+
if isinstance(node, ast.Assign):
|
|
138
|
+
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
|
139
|
+
return node.targets[0].id
|
|
140
|
+
return None
|
|
141
|
+
if isinstance(node, ast.AnnAssign):
|
|
142
|
+
if isinstance(node.target, ast.Name):
|
|
143
|
+
return node.target.id
|
|
144
|
+
return None
|
|
145
|
+
if hasattr(ast, 'TypeAlias') and isinstance(node, getattr(ast, 'TypeAlias')):
|
|
146
|
+
return node.name.id
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_file(path):
|
|
151
|
+
with open(path, 'r', encoding='utf-8') as handle:
|
|
152
|
+
source = handle.read()
|
|
153
|
+
tree = ast.parse(source, filename=path)
|
|
154
|
+
file_doc = ast.get_docstring(tree)
|
|
155
|
+
|
|
156
|
+
symbols = []
|
|
157
|
+
for node in tree.body:
|
|
158
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
159
|
+
if not is_public(node.name):
|
|
160
|
+
continue
|
|
161
|
+
decorator_names = get_decorator_names(node)
|
|
162
|
+
symbols.append({
|
|
163
|
+
'name': node.name,
|
|
164
|
+
'kind': 'function',
|
|
165
|
+
'signature': render_function_signature(node),
|
|
166
|
+
'docComment': ast.get_docstring(node),
|
|
167
|
+
'tags': [],
|
|
168
|
+
'sourceLine': node.lineno,
|
|
169
|
+
'isDeprecated': is_deprecated(decorator_names),
|
|
170
|
+
})
|
|
171
|
+
elif isinstance(node, ast.ClassDef):
|
|
172
|
+
if not is_public(node.name):
|
|
173
|
+
continue
|
|
174
|
+
decorator_names = get_decorator_names(node)
|
|
175
|
+
kind = 'enum' if is_enum_class(node) else 'class'
|
|
176
|
+
symbols.append({
|
|
177
|
+
'name': node.name,
|
|
178
|
+
'kind': kind,
|
|
179
|
+
'signature': render_class_signature(node),
|
|
180
|
+
'docComment': ast.get_docstring(node),
|
|
181
|
+
'tags': [],
|
|
182
|
+
'sourceLine': node.lineno,
|
|
183
|
+
'isDeprecated': is_deprecated(decorator_names),
|
|
184
|
+
})
|
|
185
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign)) or (
|
|
186
|
+
hasattr(ast, 'TypeAlias') and isinstance(node, getattr(ast, 'TypeAlias'))
|
|
187
|
+
):
|
|
188
|
+
target_name = collect_assign_target(node)
|
|
189
|
+
if not target_name or not is_public(target_name):
|
|
190
|
+
continue
|
|
191
|
+
sig = render_assign_signature(node)
|
|
192
|
+
if not sig:
|
|
193
|
+
continue
|
|
194
|
+
kind = 'type-alias' if is_type_alias(node, target_name) else 'variable'
|
|
195
|
+
symbols.append({
|
|
196
|
+
'name': target_name,
|
|
197
|
+
'kind': kind,
|
|
198
|
+
'signature': sig,
|
|
199
|
+
'docComment': None,
|
|
200
|
+
'tags': [],
|
|
201
|
+
'sourceLine': node.lineno,
|
|
202
|
+
'isDeprecated': False,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
symbols.sort(key=lambda s: s['sourceLine'])
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
'sourcePath': '',
|
|
209
|
+
'moduleSlug': '',
|
|
210
|
+
'symbols': symbols,
|
|
211
|
+
'fileDocComment': file_doc,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == '__main__':
|
|
216
|
+
if len(sys.argv) < 2:
|
|
217
|
+
print('usage: helper.py <source-path>', file=sys.stderr)
|
|
218
|
+
sys.exit(2)
|
|
219
|
+
source_path = sys.argv[1]
|
|
220
|
+
result = parse_file(source_path)
|
|
221
|
+
json.dump(result, sys.stdout, ensure_ascii=False)
|
|
222
|
+
`;
|
|
223
|
+
const PYTHON_DEFAULT_INCLUDE = ['**/*.py'];
|
|
224
|
+
const PYTHON_DEFAULT_EXCLUDE = [
|
|
225
|
+
'**/tests/**',
|
|
226
|
+
'**/test_*.py',
|
|
227
|
+
'**/*_test.py',
|
|
228
|
+
'**/__pycache__/**',
|
|
229
|
+
'**/.venv/**',
|
|
230
|
+
'**/venv/**',
|
|
231
|
+
'**/env/**',
|
|
232
|
+
'**/build/**',
|
|
233
|
+
'**/dist/**',
|
|
234
|
+
'**/site-packages/**',
|
|
235
|
+
'**/node_modules/**'
|
|
236
|
+
];
|
|
237
|
+
async function runCommand(command, args, stdin) {
|
|
238
|
+
return new Promise((resolve, reject) => {
|
|
239
|
+
const child = spawn(command, args, { shell: false });
|
|
240
|
+
let stdout = '';
|
|
241
|
+
let stderr = '';
|
|
242
|
+
child.stdout.on('data', (chunk) => {
|
|
243
|
+
stdout += chunk.toString('utf8');
|
|
244
|
+
});
|
|
245
|
+
child.stderr.on('data', (chunk) => {
|
|
246
|
+
stderr += chunk.toString('utf8');
|
|
247
|
+
});
|
|
248
|
+
child.on('error', reject);
|
|
249
|
+
child.on('close', (code) => resolve({ stdout, stderr, exitCode: code ?? -1 }));
|
|
250
|
+
// Suppress EPIPE on the stdin stream — fired synchronously when the child process
|
|
251
|
+
// dies before reading (interpreter crash on import, version mismatch, etc.). Without
|
|
252
|
+
// this handler the error becomes an uncaught exception. The child's exit code +
|
|
253
|
+
// stderr surface the underlying problem through the regular `close` path.
|
|
254
|
+
child.stdin.on('error', () => { });
|
|
255
|
+
if (stdin) {
|
|
256
|
+
child.stdin.write(stdin);
|
|
257
|
+
}
|
|
258
|
+
child.stdin.end();
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
async function exists(filePath) {
|
|
262
|
+
try {
|
|
263
|
+
await fs.access(filePath);
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function findPythonInterpreter() {
|
|
271
|
+
const candidates = process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python'];
|
|
272
|
+
for (const candidate of candidates) {
|
|
273
|
+
try {
|
|
274
|
+
const result = await runCommand(candidate, ['--version']);
|
|
275
|
+
if (result.exitCode !== 0)
|
|
276
|
+
continue;
|
|
277
|
+
// `python --version` writes to stdout on 3.4+, sometimes to stderr on older releases.
|
|
278
|
+
const versionText = `${result.stdout}\n${result.stderr}`;
|
|
279
|
+
const match = versionText.match(/Python (\d+)\.(\d+)/);
|
|
280
|
+
if (!match)
|
|
281
|
+
continue;
|
|
282
|
+
const major = Number(match[1]);
|
|
283
|
+
const minor = Number(match[2]);
|
|
284
|
+
// ast.unparse requires Python 3.9+. Below that the helper script can't run.
|
|
285
|
+
if (major > 3 || (major === 3 && minor >= 9)) {
|
|
286
|
+
return candidate;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Spawn failed (interpreter not found or not executable); try the next candidate.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
// Memoize the interpreter lookup so we don't pay --version overhead on every extract().
|
|
296
|
+
let cachedInterpreter;
|
|
297
|
+
async function getPython() {
|
|
298
|
+
if (cachedInterpreter !== undefined) {
|
|
299
|
+
return cachedInterpreter;
|
|
300
|
+
}
|
|
301
|
+
cachedInterpreter = await findPythonInterpreter();
|
|
302
|
+
return cachedInterpreter;
|
|
303
|
+
}
|
|
304
|
+
// Test-only escape hatch: clear the memoized interpreter so tests can simulate "Python
|
|
305
|
+
// disappears mid-run" or run multiple isolated detection scenarios.
|
|
306
|
+
export function resetPythonInterpreterCache() {
|
|
307
|
+
cachedInterpreter = undefined;
|
|
308
|
+
}
|
|
309
|
+
export const pythonExtractor = {
|
|
310
|
+
id: 'python',
|
|
311
|
+
async detect(rootDir) {
|
|
312
|
+
// Only claim a project that BOTH (a) shows a Python project signal in its root and
|
|
313
|
+
// (b) has a usable Python 3.9+ interpreter on PATH. If Python isn't installed, the
|
|
314
|
+
// orchestrator falls through to the next extractor (e.g., the TS one) or returns an
|
|
315
|
+
// empty result — we never throw at detect-time.
|
|
316
|
+
const signals = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt'];
|
|
317
|
+
let hasSignal = false;
|
|
318
|
+
for (const signal of signals) {
|
|
319
|
+
if (await exists(path.join(rootDir, signal))) {
|
|
320
|
+
hasSignal = true;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (!hasSignal) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
return (await getPython()) !== null;
|
|
328
|
+
},
|
|
329
|
+
async walk(rootDir, options) {
|
|
330
|
+
return walkProjectSources(rootDir, {
|
|
331
|
+
include: options?.include ?? PYTHON_DEFAULT_INCLUDE,
|
|
332
|
+
exclude: options?.exclude ?? PYTHON_DEFAULT_EXCLUDE,
|
|
333
|
+
// The @internal-tag walker convention is JSDoc-specific; Python's privacy convention
|
|
334
|
+
// is the leading-underscore name. The extract step honors that already.
|
|
335
|
+
respectInternalConvention: false
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
async extract(sourcePath, options) {
|
|
339
|
+
const python = await getPython();
|
|
340
|
+
if (!python) {
|
|
341
|
+
throw new Error('pythonExtractor.extract: Python 3.9+ is required but no usable interpreter was found on PATH');
|
|
342
|
+
}
|
|
343
|
+
const rootDir = options?.rootDir ?? process.cwd();
|
|
344
|
+
const absolute = path.isAbsolute(sourcePath) ? sourcePath : path.resolve(rootDir, sourcePath);
|
|
345
|
+
const relative = path.relative(rootDir, absolute).replace(/\\/g, '/');
|
|
346
|
+
const result = await runCommand(python, ['-', absolute], PYTHON_HELPER_SCRIPT);
|
|
347
|
+
if (result.exitCode !== 0) {
|
|
348
|
+
throw new Error(`pythonExtractor.extract: helper failed for ${relative} (exit ${result.exitCode}): ${result.stderr.trim()}`);
|
|
349
|
+
}
|
|
350
|
+
const parsed = JSON.parse(result.stdout);
|
|
351
|
+
parsed.sourcePath = relative;
|
|
352
|
+
// Mirror typeScriptExtractor's slug shape: `api/<dir>/<basename>` with the language
|
|
353
|
+
// extension stripped. Python files outside a top-level `src/` keep their full path.
|
|
354
|
+
const stripped = relative.replace(/^src\//, '').replace(/\.py$/i, '');
|
|
355
|
+
parsed.moduleSlug = `api/${stripped}`;
|
|
356
|
+
return parsed;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
const LINK_PATTERN = /\{@link\s+([^\s|}]+)(?:\s*[|]?\s*([^}]+))?\}/g;
|
|
18
|
+
function applyLinkResolution(text, resolver) {
|
|
19
|
+
if (!resolver) {
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
return text.replace(LINK_PATTERN, (_, target, displayRaw) => {
|
|
23
|
+
const display = displayRaw?.trim();
|
|
24
|
+
const resolution = resolver(target, display && display.length > 0 ? display : undefined);
|
|
25
|
+
const rendered = resolution.url ? `[${resolution.display}](${resolution.url})` : resolution.display;
|
|
26
|
+
return resolution.comment ? `${rendered}<!-- ${resolution.comment} -->` : rendered;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function renderApiPage(ref, options = {}) {
|
|
30
|
+
const sourceLinkBase = options.sourceLinkBase ?? '../..';
|
|
31
|
+
const lines = [];
|
|
32
|
+
lines.push('---');
|
|
33
|
+
lines.push('lifecycle: generated');
|
|
34
|
+
lines.push('source-coverage: api-reference');
|
|
35
|
+
lines.push(`source-file: ${ref.sourcePath}`);
|
|
36
|
+
if (options.generatedAt) {
|
|
37
|
+
lines.push(`last-generated: ${options.generatedAt}`);
|
|
38
|
+
}
|
|
39
|
+
lines.push('---');
|
|
40
|
+
lines.push('');
|
|
41
|
+
lines.push(`# \`${ref.sourcePath}\``);
|
|
42
|
+
lines.push('');
|
|
43
|
+
if (ref.fileDocComment) {
|
|
44
|
+
lines.push(applyLinkResolution(ref.fileDocComment, options.resolveLink));
|
|
45
|
+
lines.push('');
|
|
46
|
+
}
|
|
47
|
+
if (ref.symbols.length === 0) {
|
|
48
|
+
lines.push('_No documented exports._');
|
|
49
|
+
lines.push('');
|
|
50
|
+
return lines.join('\n');
|
|
51
|
+
}
|
|
52
|
+
lines.push('## Exports');
|
|
53
|
+
lines.push('');
|
|
54
|
+
for (const symbol of ref.symbols) {
|
|
55
|
+
lines.push(`- [\`${symbol.name}\`](#${anchorFor(symbol.name)}) — ${kindLabel(symbol.kind)}`);
|
|
56
|
+
}
|
|
57
|
+
lines.push('');
|
|
58
|
+
for (const symbol of ref.symbols) {
|
|
59
|
+
lines.push('---');
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push(...renderSymbol(symbol, ref.sourcePath, sourceLinkBase, options.sourceLinkResolver, options.resolveLink));
|
|
62
|
+
}
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
function renderSymbol(symbol, sourcePath, sourceLinkBase, sourceLinkResolver, resolveLink) {
|
|
66
|
+
const lines = [];
|
|
67
|
+
lines.push(`### \`${symbol.name}\``);
|
|
68
|
+
lines.push('');
|
|
69
|
+
if (symbol.isDeprecated) {
|
|
70
|
+
const reason = (symbol.tags.find((tag) => tag.name === 'deprecated')?.text ?? '').trim();
|
|
71
|
+
const resolvedReason = applyLinkResolution(reason, resolveLink);
|
|
72
|
+
lines.push(`> ⚠️ **Deprecated:** ${resolvedReason || 'this symbol is deprecated.'}`);
|
|
73
|
+
lines.push('');
|
|
74
|
+
}
|
|
75
|
+
// Source-link URL strategy:
|
|
76
|
+
// 1. If a resolver was supplied, use its output. A non-null string becomes a markdown
|
|
77
|
+
// link; null becomes plain text (no link). The orchestrator builds full
|
|
78
|
+
// https://github.com/... URLs when it can detect the repository, otherwise returns
|
|
79
|
+
// null so the page never emits a 404-prone relative link.
|
|
80
|
+
// 2. If no resolver was supplied, fall back to the legacy relative-path link from
|
|
81
|
+
// `sourceLinkBase`. This works on GitHub web view of the raw markdown and in some
|
|
82
|
+
// IDEs; it does NOT work in VitePress's static site, which is why the resolver path
|
|
83
|
+
// is the production default.
|
|
84
|
+
let sourceLine;
|
|
85
|
+
if (sourceLinkResolver) {
|
|
86
|
+
const url = sourceLinkResolver(sourcePath, symbol.sourceLine);
|
|
87
|
+
sourceLine = url
|
|
88
|
+
? `[${sourcePath}:${symbol.sourceLine}](${url})`
|
|
89
|
+
: `${sourcePath}:${symbol.sourceLine}`;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
sourceLine = `[${sourcePath}:${symbol.sourceLine}](${sourceLinkBase}/${sourcePath}#L${symbol.sourceLine})`;
|
|
93
|
+
}
|
|
94
|
+
lines.push(`**Kind:** ${kindLabel(symbol.kind)} · **Source:** ${sourceLine}`);
|
|
95
|
+
lines.push('');
|
|
96
|
+
lines.push('```ts');
|
|
97
|
+
lines.push(symbol.signature);
|
|
98
|
+
lines.push('```');
|
|
99
|
+
lines.push('');
|
|
100
|
+
if (symbol.docComment) {
|
|
101
|
+
lines.push(applyLinkResolution(symbol.docComment, resolveLink));
|
|
102
|
+
lines.push('');
|
|
103
|
+
}
|
|
104
|
+
const paramTags = symbol.tags.filter((tag) => tag.name === 'param');
|
|
105
|
+
if (paramTags.length > 0) {
|
|
106
|
+
lines.push('#### Parameters');
|
|
107
|
+
lines.push('');
|
|
108
|
+
lines.push('| Name | Description |');
|
|
109
|
+
lines.push('|---|---|');
|
|
110
|
+
for (const tag of paramTags) {
|
|
111
|
+
const name = tag.paramName ?? '';
|
|
112
|
+
const description = escapeTableCell(applyLinkResolution(tag.text, resolveLink));
|
|
113
|
+
lines.push(`| \`${name}\` | ${description} |`);
|
|
114
|
+
}
|
|
115
|
+
lines.push('');
|
|
116
|
+
}
|
|
117
|
+
const returnsTag = symbol.tags.find((tag) => tag.name === 'returns' || tag.name === 'return');
|
|
118
|
+
if (returnsTag) {
|
|
119
|
+
lines.push('#### Returns');
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push(applyLinkResolution(returnsTag.text, resolveLink) || '_(no description)_');
|
|
122
|
+
lines.push('');
|
|
123
|
+
}
|
|
124
|
+
const throwsTags = symbol.tags.filter((tag) => tag.name === 'throws' || tag.name === 'throw');
|
|
125
|
+
if (throwsTags.length > 0) {
|
|
126
|
+
lines.push('#### Throws');
|
|
127
|
+
lines.push('');
|
|
128
|
+
for (const tag of throwsTags) {
|
|
129
|
+
lines.push(`- ${applyLinkResolution(tag.text, resolveLink)}`);
|
|
130
|
+
}
|
|
131
|
+
lines.push('');
|
|
132
|
+
}
|
|
133
|
+
// @example bodies are code; do not run them through the link resolver.
|
|
134
|
+
const exampleTags = symbol.tags.filter((tag) => tag.name === 'example');
|
|
135
|
+
for (const tag of exampleTags) {
|
|
136
|
+
lines.push('#### Example');
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push('```ts');
|
|
139
|
+
lines.push(tag.text);
|
|
140
|
+
lines.push('```');
|
|
141
|
+
lines.push('');
|
|
142
|
+
}
|
|
143
|
+
const seeTags = symbol.tags.filter((tag) => tag.name === 'see');
|
|
144
|
+
if (seeTags.length > 0) {
|
|
145
|
+
lines.push('#### See');
|
|
146
|
+
lines.push('');
|
|
147
|
+
for (const tag of seeTags) {
|
|
148
|
+
lines.push(`- ${applyLinkResolution(tag.text, resolveLink)}`);
|
|
149
|
+
}
|
|
150
|
+
lines.push('');
|
|
151
|
+
}
|
|
152
|
+
const sinceTag = symbol.tags.find((tag) => tag.name === 'since');
|
|
153
|
+
if (sinceTag) {
|
|
154
|
+
lines.push(`**Since:** ${applyLinkResolution(sinceTag.text, resolveLink)}`);
|
|
155
|
+
lines.push('');
|
|
156
|
+
}
|
|
157
|
+
const reservedTags = new Set(['param', 'returns', 'return', 'throws', 'throw', 'example', 'see', 'since', 'deprecated', 'internal']);
|
|
158
|
+
const unknownTags = symbol.tags.filter((tag) => !reservedTags.has(tag.name));
|
|
159
|
+
if (unknownTags.length > 0) {
|
|
160
|
+
lines.push('#### Tags');
|
|
161
|
+
lines.push('');
|
|
162
|
+
for (const tag of unknownTags) {
|
|
163
|
+
const resolved = applyLinkResolution(tag.text, resolveLink);
|
|
164
|
+
const text = resolved ? `: ${resolved}` : '';
|
|
165
|
+
lines.push(`- **@${tag.name}**${text}`);
|
|
166
|
+
}
|
|
167
|
+
lines.push('');
|
|
168
|
+
}
|
|
169
|
+
return lines;
|
|
170
|
+
}
|
|
171
|
+
function kindLabel(kind) {
|
|
172
|
+
switch (kind) {
|
|
173
|
+
case 'function':
|
|
174
|
+
return 'function';
|
|
175
|
+
case 'class':
|
|
176
|
+
return 'class';
|
|
177
|
+
case 'interface':
|
|
178
|
+
return 'interface';
|
|
179
|
+
case 'type-alias':
|
|
180
|
+
return 'type alias';
|
|
181
|
+
case 'enum':
|
|
182
|
+
return 'enum';
|
|
183
|
+
case 'variable':
|
|
184
|
+
return 'variable';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export function anchorFor(name) {
|
|
188
|
+
return name
|
|
189
|
+
.toLowerCase()
|
|
190
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
191
|
+
.replace(/^-+|-+$/g, '');
|
|
192
|
+
}
|
|
193
|
+
function escapeTableCell(value) {
|
|
194
|
+
return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
|
|
195
|
+
}
|