@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,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator for API reference generation.
|
|
3
|
+
*
|
|
4
|
+
* Top-level entry point invoked by the CLI (`dendrite-wiki docs:api`), the MCP tool
|
|
5
|
+
* (`wiki_generate_api_reference`), and the wiki refresh pipeline (`refreshGeneratedWikiDocs`
|
|
6
|
+
* in `./generated-docs.ts`). Picks a `LanguageExtractor` via registry-ordered `detect()`,
|
|
7
|
+
* walks the project's sources, runs a two-pass extract-then-render so cross-file
|
|
8
|
+
* `{@link}` resolution sees every symbol before any page renders, writes one markdown
|
|
9
|
+
* page per source file under `docs/wiki/api/`, and tracks ownership via a manifest at
|
|
10
|
+
* `docs/public/api-reference-manifest.json`.
|
|
11
|
+
*
|
|
12
|
+
* Determinism rules are load-bearing:
|
|
13
|
+
* - Per-page markdown contains no clock-derived fields. Idempotent runs produce zero diffs.
|
|
14
|
+
* - The manifest's top-level `generatedAt` is the only timestamp the orchestrator stamps.
|
|
15
|
+
* - Orphan cleanup only ever deletes slugs present in the *previous* manifest under the
|
|
16
|
+
* `api/` prefix; we never delete a page that was not previously claimed by this generator.
|
|
17
|
+
*
|
|
18
|
+
* Phases A1–A7 of the API reference roadmap progressively built this surface.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { promises as fs } from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import type { LanguageExtractor } from './api-extractor/language-extractor.js';
|
|
25
|
+
import { pythonExtractor } from './api-extractor/python-extractor.js';
|
|
26
|
+
import { anchorFor, renderApiPage, type LinkResolution, type LinkResolver } from './api-extractor/render.js';
|
|
27
|
+
import { treeSitterExtractor } from './api-extractor/tree-sitter-extractor.js';
|
|
28
|
+
import type { ApiFileReference } from './api-extractor/types.js';
|
|
29
|
+
import { typeScriptExtractor } from './api-extractor/typescript-extractor.js';
|
|
30
|
+
import { type WalkOptions } from './api-extractor/walk.js';
|
|
31
|
+
|
|
32
|
+
// Built-in language extractors, registration-ordered. The first whose `detect()` returns
|
|
33
|
+
// true claims the project. Python is registered ahead of TypeScript so a project that
|
|
34
|
+
// mixes a `pyproject.toml` with a `package.json` (e.g., a Python tool with Node-side
|
|
35
|
+
// scripting) gets its Python source documented; pure-TS projects with no Python signals
|
|
36
|
+
// still flow cleanly through to typeScriptExtractor. The tree-sitter extractor handles
|
|
37
|
+
// the long-tail languages (Rust today; Go/Java/Ruby/C/C++/PHP next) and is registered
|
|
38
|
+
// FIRST so a Rust+Node hybrid lands on Rust output rather than the TS extractor's
|
|
39
|
+
// high-recall claim. Pure-TS projects fall through to typeScriptExtractor as before.
|
|
40
|
+
const builtInLanguageExtractors: readonly LanguageExtractor[] = [
|
|
41
|
+
treeSitterExtractor,
|
|
42
|
+
pythonExtractor,
|
|
43
|
+
typeScriptExtractor
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export type { LanguageExtractor } from './api-extractor/language-extractor.js';
|
|
47
|
+
export { pythonExtractor } from './api-extractor/python-extractor.js';
|
|
48
|
+
export { treeSitterExtractor } from './api-extractor/tree-sitter-extractor.js';
|
|
49
|
+
export { typeScriptExtractor } from './api-extractor/typescript-extractor.js';
|
|
50
|
+
|
|
51
|
+
const API_PAGES_ROOT = path.join('docs', 'wiki', 'api');
|
|
52
|
+
const MANIFEST_RELATIVE_PATH = path.join('docs', 'public', 'api-reference-manifest.json');
|
|
53
|
+
const MANIFEST_SCHEMA_VERSION = 1;
|
|
54
|
+
const MANIFEST_OWNED_PREFIX = 'api/';
|
|
55
|
+
|
|
56
|
+
export interface ApiReferenceWarning {
|
|
57
|
+
kind: 'low-coverage' | 'extraction-error' | 'unresolved-link' | 'ambiguous-link';
|
|
58
|
+
message: string;
|
|
59
|
+
sourceFile?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ApiReferenceSourceSkip {
|
|
63
|
+
path: string;
|
|
64
|
+
reason: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ApiReferenceManifestEntry {
|
|
68
|
+
slug: string;
|
|
69
|
+
sourceFile: string;
|
|
70
|
+
symbolCount: number;
|
|
71
|
+
contentHash: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ApiReferenceManifest {
|
|
75
|
+
schemaVersion: number;
|
|
76
|
+
generatedAt: string;
|
|
77
|
+
pages: ApiReferenceManifestEntry[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ApiReferenceResult {
|
|
81
|
+
pagesWritten: number;
|
|
82
|
+
pagesChanged: string[];
|
|
83
|
+
pagesDeleted: string[];
|
|
84
|
+
warnings: ApiReferenceWarning[];
|
|
85
|
+
sourcesScanned: number;
|
|
86
|
+
sourcesSkipped: ApiReferenceSourceSkip[];
|
|
87
|
+
manifest: ApiReferenceManifest;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface RefreshOptions {
|
|
91
|
+
rootDir?: string;
|
|
92
|
+
walkOptions?: WalkOptions;
|
|
93
|
+
dryRun?: boolean;
|
|
94
|
+
// Fixed timestamp for deterministic test runs. If omitted, `new Date().toISOString()` is used.
|
|
95
|
+
now?: string;
|
|
96
|
+
// Override the built-in language extractor registry. Mostly useful for tests; production
|
|
97
|
+
// callers leave this undefined and pick up the default `[typeScriptExtractor]` list.
|
|
98
|
+
extractors?: readonly LanguageExtractor[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function refreshApiReference(options: RefreshOptions = {}): Promise<ApiReferenceResult> {
|
|
102
|
+
const rootDir = options.rootDir ? path.resolve(options.rootDir) : process.cwd();
|
|
103
|
+
const dryRun = options.dryRun ?? false;
|
|
104
|
+
const generatedAt = options.now ?? new Date().toISOString();
|
|
105
|
+
|
|
106
|
+
// Pluggability dispatch: pick the first registered extractor whose detect() matches the
|
|
107
|
+
// project. When none match (e.g., a non-TS project with no recognizable signals), return
|
|
108
|
+
// an empty result — the same behavior as the previous "walk found 0 sources" path.
|
|
109
|
+
const extractors = options.extractors ?? builtInLanguageExtractors;
|
|
110
|
+
let activeExtractor: LanguageExtractor | undefined;
|
|
111
|
+
for (const candidate of extractors) {
|
|
112
|
+
if (await candidate.detect(rootDir)) {
|
|
113
|
+
activeExtractor = candidate;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const previousManifest = await readManifest(rootDir);
|
|
119
|
+
const previousBySlug = new Map(previousManifest.pages.map((entry) => [entry.slug, entry]));
|
|
120
|
+
|
|
121
|
+
if (!activeExtractor) {
|
|
122
|
+
// No extractor claimed this project — nothing to scan. Still produce a valid manifest
|
|
123
|
+
// (an empty one) and run the orphan-cleanup pass against the previous manifest so a
|
|
124
|
+
// project that switches stacks doesn't strand stale pages.
|
|
125
|
+
const orphanSlugs = previousManifest.pages.map((entry) => entry.slug);
|
|
126
|
+
const newManifest: ApiReferenceManifest = {
|
|
127
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
128
|
+
generatedAt,
|
|
129
|
+
pages: []
|
|
130
|
+
};
|
|
131
|
+
if (!dryRun && orphanSlugs.length > 0) {
|
|
132
|
+
for (const slug of orphanSlugs) {
|
|
133
|
+
const orphanPath = resolveSafeOrphanPath(rootDir, slug);
|
|
134
|
+
if (!orphanPath) continue;
|
|
135
|
+
await fs.rm(orphanPath, { force: true });
|
|
136
|
+
}
|
|
137
|
+
await writeManifest(rootDir, newManifest);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
pagesWritten: 0,
|
|
141
|
+
pagesChanged: [],
|
|
142
|
+
pagesDeleted: orphanSlugs,
|
|
143
|
+
warnings: [],
|
|
144
|
+
sourcesScanned: 0,
|
|
145
|
+
sourcesSkipped: [],
|
|
146
|
+
manifest: newManifest
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const sources = await activeExtractor.walk(rootDir, options.walkOptions);
|
|
151
|
+
// Build a per-project source-link resolver. When the project has a detectable GitHub
|
|
152
|
+
// repository URL in package.json, source links go to https://github.com/... so they
|
|
153
|
+
// work in VitePress, on GitHub web, and in IDEs alike. When no repository URL is
|
|
154
|
+
// detectable, the resolver returns null → the renderer emits plain text (no link)
|
|
155
|
+
// instead of a relative path that 404s in the browser-served VitePress site.
|
|
156
|
+
const sourceLinkResolver = await buildSourceLinkResolver(rootDir);
|
|
157
|
+
|
|
158
|
+
const warnings: ApiReferenceWarning[] = [];
|
|
159
|
+
const sourcesSkipped: ApiReferenceSourceSkip[] = [];
|
|
160
|
+
|
|
161
|
+
// Pass 1: extract every source file. We collect successful refs into `pendingRefs` so the
|
|
162
|
+
// link index (built next) sees the full symbol universe before we render anything.
|
|
163
|
+
interface PendingRef {
|
|
164
|
+
ref: ApiFileReference;
|
|
165
|
+
slug: string;
|
|
166
|
+
pageRelativePath: string;
|
|
167
|
+
sourceLinkBase: string;
|
|
168
|
+
}
|
|
169
|
+
const pendingRefs: PendingRef[] = [];
|
|
170
|
+
|
|
171
|
+
for (const source of sources) {
|
|
172
|
+
let ref: ApiFileReference;
|
|
173
|
+
try {
|
|
174
|
+
ref = await activeExtractor.extract(source, { rootDir });
|
|
175
|
+
} catch (error) {
|
|
176
|
+
warnings.push({
|
|
177
|
+
kind: 'extraction-error',
|
|
178
|
+
message: error instanceof Error ? error.message : String(error),
|
|
179
|
+
sourceFile: source
|
|
180
|
+
});
|
|
181
|
+
sourcesSkipped.push({ path: source, reason: 'extraction-error' });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (ref.symbols.length === 0) {
|
|
186
|
+
sourcesSkipped.push({ path: source, reason: 'no-exports' });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const slug = ref.moduleSlug;
|
|
191
|
+
if (!slug.startsWith(MANIFEST_OWNED_PREFIX)) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`refreshApiReference: derived slug "${slug}" does not start with required prefix "${MANIFEST_OWNED_PREFIX}" — refusing to claim`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const pageRelativePath = `${path.posix.join(API_PAGES_ROOT.replace(/\\/g, '/'), slug.slice(MANIFEST_OWNED_PREFIX.length))}.md`;
|
|
198
|
+
const sourceLinkBase = computeSourceLinkBase(pageRelativePath);
|
|
199
|
+
|
|
200
|
+
if (countDocumented(ref) === 0) {
|
|
201
|
+
warnings.push({
|
|
202
|
+
kind: 'low-coverage',
|
|
203
|
+
message: `${source} has ${ref.symbols.length} export(s) but 0 with doc comments`,
|
|
204
|
+
sourceFile: source
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
pendingRefs.push({ ref, slug, pageRelativePath, sourceLinkBase });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Pass 2: build the cross-file link index and render with a resolver per page. The
|
|
212
|
+
// resolver pushes link warnings back into the shared `warnings` list so unresolved or
|
|
213
|
+
// ambiguous references surface in the result.
|
|
214
|
+
const linkIndex = buildLinkIndex(pendingRefs.map((entry) => ({ slug: entry.slug, ref: entry.ref })));
|
|
215
|
+
|
|
216
|
+
const renderedPages: { slug: string; absolutePath: string; body: string; entry: ApiReferenceManifestEntry }[] = [];
|
|
217
|
+
|
|
218
|
+
for (const pending of pendingRefs) {
|
|
219
|
+
const resolveLink: LinkResolver = (target, displayText) =>
|
|
220
|
+
resolveCrossReference({
|
|
221
|
+
target,
|
|
222
|
+
displayText,
|
|
223
|
+
currentSlug: pending.slug,
|
|
224
|
+
currentSourceFile: pending.ref.sourcePath,
|
|
225
|
+
linkIndex,
|
|
226
|
+
warnings
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const body = renderApiPage(pending.ref, {
|
|
230
|
+
sourceLinkBase: pending.sourceLinkBase,
|
|
231
|
+
sourceLinkResolver,
|
|
232
|
+
resolveLink
|
|
233
|
+
});
|
|
234
|
+
const finalBody = ensureTrailingNewline(body);
|
|
235
|
+
const contentHash = sha256(finalBody);
|
|
236
|
+
|
|
237
|
+
renderedPages.push({
|
|
238
|
+
slug: pending.slug,
|
|
239
|
+
absolutePath: path.resolve(rootDir, pending.pageRelativePath),
|
|
240
|
+
body: finalBody,
|
|
241
|
+
entry: {
|
|
242
|
+
slug: pending.slug,
|
|
243
|
+
sourceFile: pending.ref.sourcePath,
|
|
244
|
+
symbolCount: pending.ref.symbols.length,
|
|
245
|
+
contentHash
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
renderedPages.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
251
|
+
|
|
252
|
+
const newSlugs = new Set(renderedPages.map((page) => page.slug));
|
|
253
|
+
const orphanSlugs = previousManifest.pages
|
|
254
|
+
.map((entry) => entry.slug)
|
|
255
|
+
.filter((slug) => !newSlugs.has(slug));
|
|
256
|
+
|
|
257
|
+
for (const slug of orphanSlugs) {
|
|
258
|
+
// Defense-in-depth: validate every orphan slug now, before any I/O. If a corrupted
|
|
259
|
+
// manifest entry ever escaped the api/ tree (e.g., `api/../../etc/passwd`), this throw
|
|
260
|
+
// surfaces it loudly — and short-circuits before we touch the filesystem.
|
|
261
|
+
if (resolveSafeOrphanPath(rootDir, slug) === null) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`refreshApiReference: previous manifest contained unsafe slug "${slug}" — refusing to act on it`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const pagesChanged: string[] = [];
|
|
269
|
+
for (const page of renderedPages) {
|
|
270
|
+
const previous = previousBySlug.get(page.slug);
|
|
271
|
+
if (!previous || previous.contentHash !== page.entry.contentHash) {
|
|
272
|
+
pagesChanged.push(page.slug);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const pagesDeleted: string[] = [...orphanSlugs];
|
|
277
|
+
|
|
278
|
+
const newManifest: ApiReferenceManifest = {
|
|
279
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
280
|
+
generatedAt,
|
|
281
|
+
pages: renderedPages.map((page) => page.entry)
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
if (!dryRun) {
|
|
285
|
+
for (const page of renderedPages) {
|
|
286
|
+
await writeIfChanged(page.absolutePath, page.body);
|
|
287
|
+
}
|
|
288
|
+
for (const slug of orphanSlugs) {
|
|
289
|
+
// resolveSafeOrphanPath was already validated above; non-null is guaranteed.
|
|
290
|
+
const orphanPath = resolveSafeOrphanPath(rootDir, slug);
|
|
291
|
+
if (orphanPath) {
|
|
292
|
+
await fs.rm(orphanPath, { force: true });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
await writeManifest(rootDir, newManifest);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
pagesWritten: renderedPages.length,
|
|
300
|
+
pagesChanged,
|
|
301
|
+
pagesDeleted,
|
|
302
|
+
warnings,
|
|
303
|
+
sourcesScanned: sources.length,
|
|
304
|
+
sourcesSkipped,
|
|
305
|
+
manifest: newManifest
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Builds a function that turns a (sourcePath, line) pair into a source-link URL the
|
|
311
|
+
* rendered API page can hyperlink to. Reads the project's `package.json` and inspects
|
|
312
|
+
* the `repository` field (handles both string and object forms, and the `git+` /
|
|
313
|
+
* `.git` decorations npm conventionally adds). When it finds a github.com URL, returns
|
|
314
|
+
* a resolver that emits full `https://github.com/<owner>/<repo>/blob/main/<path>#L<line>`
|
|
315
|
+
* URLs — those work in every viewing context (VitePress static site, GitHub web view,
|
|
316
|
+
* IDE markdown preview). When no GitHub URL is detected, returns a resolver that always
|
|
317
|
+
* returns null, signaling the renderer to emit plain-text source references rather than
|
|
318
|
+
* relative-path links that 404 in browser-served sites.
|
|
319
|
+
*
|
|
320
|
+
* Branch resolution: hardcoded to `main` for simplicity. Future enhancement could read
|
|
321
|
+
* `git rev-parse --abbrev-ref HEAD` for the actual default branch, or honor a
|
|
322
|
+
* `DENDRITE_API_REFERENCE_BRANCH` env var.
|
|
323
|
+
*
|
|
324
|
+
* Other git hosts (GitLab, Bitbucket, codeberg.org, sr.ht, self-hosted Gitea) are not
|
|
325
|
+
* yet detected; their projects fall through to plain-text source references. Adding
|
|
326
|
+
* support is a small follow-up — each host has a stable `<host>/<owner>/<repo>/blob/<branch>/<path>`
|
|
327
|
+
* URL pattern (or close variant) that can be detected the same way.
|
|
328
|
+
*/
|
|
329
|
+
async function buildSourceLinkResolver(
|
|
330
|
+
rootDir: string
|
|
331
|
+
): Promise<((sourcePath: string, line: number) => string | null) | undefined> {
|
|
332
|
+
let pkgRaw: string;
|
|
333
|
+
try {
|
|
334
|
+
pkgRaw = await fs.readFile(path.join(rootDir, 'package.json'), 'utf8');
|
|
335
|
+
} catch {
|
|
336
|
+
return () => null;
|
|
337
|
+
}
|
|
338
|
+
let pkg: { repository?: string | { url?: string } };
|
|
339
|
+
try {
|
|
340
|
+
pkg = JSON.parse(pkgRaw);
|
|
341
|
+
} catch {
|
|
342
|
+
return () => null;
|
|
343
|
+
}
|
|
344
|
+
const repoField = pkg.repository;
|
|
345
|
+
const repoUrl = typeof repoField === 'string' ? repoField : repoField?.url;
|
|
346
|
+
if (!repoUrl) return () => null;
|
|
347
|
+
// Strip the `git+` prefix npm adds and the `.git` suffix git remotes use, then look
|
|
348
|
+
// for github.com followed by `/<owner>/<repo>`. SSH-form `git@github.com:owner/repo`
|
|
349
|
+
// and HTTPS `https://github.com/owner/repo` both match.
|
|
350
|
+
const match = repoUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
|
351
|
+
if (!match) return () => null;
|
|
352
|
+
const owner = match[1];
|
|
353
|
+
const repo = match[2];
|
|
354
|
+
const branch = process.env.DENDRITE_API_REFERENCE_BRANCH ?? 'main';
|
|
355
|
+
return (sourcePath: string, line: number) =>
|
|
356
|
+
`https://github.com/${owner}/${repo}/blob/${branch}/${sourcePath}#L${line}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Resolves the absolute on-disk path for an orphan slug, returning null when the slug is
|
|
361
|
+
* not owned by this generator OR when the resolved path escapes the API tree. Centralizes
|
|
362
|
+
* the path-traversal guard so both code paths in `refreshApiReference` use the same check.
|
|
363
|
+
*
|
|
364
|
+
* Why this matters: a corrupt manifest entry like `{"slug": "api/../../etc/passwd"}`
|
|
365
|
+
* passes a naive `slug.startsWith("api/")` check, but `path.posix.join("docs/wiki/api",
|
|
366
|
+
* "../../etc/passwd")` resolves outside the API tree. `fs.rm({ force: true })` would
|
|
367
|
+
* happily delete it. This guard short-circuits before any filesystem I/O.
|
|
368
|
+
*/
|
|
369
|
+
function resolveSafeOrphanPath(rootDir: string, slug: string): string | null {
|
|
370
|
+
if (!slug.startsWith(MANIFEST_OWNED_PREFIX)) return null;
|
|
371
|
+
// Reject slugs carrying characters that have meaning to the filesystem layer regardless
|
|
372
|
+
// of platform: `\0` (POSIX path terminator), and `:` (Windows drive-letter separator,
|
|
373
|
+
// which would otherwise cause `path.resolve` to switch drives). Slugs from our own
|
|
374
|
+
// derivation never contain these; rejecting them here closes a malformed-manifest
|
|
375
|
+
// attack surface up front.
|
|
376
|
+
if (slug.includes('\0') || slug.includes(':')) return null;
|
|
377
|
+
const apiRootAbs = path.resolve(rootDir, API_PAGES_ROOT);
|
|
378
|
+
const candidate = path.resolve(apiRootAbs, `${slug.slice(MANIFEST_OWNED_PREFIX.length)}.md`);
|
|
379
|
+
const relativeFromApi = path.relative(apiRootAbs, candidate);
|
|
380
|
+
// Outside the API tree iff:
|
|
381
|
+
// - the path is absolute (e.g., a Windows drive-letter switch slipped past slug
|
|
382
|
+
// validation, or POSIX absolute path)
|
|
383
|
+
// - the FIRST path segment is exactly `..` (escapes upward by a directory).
|
|
384
|
+
// We compare segments rather than using `startsWith('..')` to avoid false-rejecting a
|
|
385
|
+
// file legitimately named e.g. `..config` inside the API tree.
|
|
386
|
+
if (path.isAbsolute(relativeFromApi)) return null;
|
|
387
|
+
const firstSegment = relativeFromApi.split(/[\\/]/)[0];
|
|
388
|
+
if (firstSegment === '..') return null;
|
|
389
|
+
return candidate;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function readManifest(rootDir: string): Promise<ApiReferenceManifest> {
|
|
393
|
+
const manifestPath = path.resolve(rootDir, MANIFEST_RELATIVE_PATH);
|
|
394
|
+
try {
|
|
395
|
+
const raw = await fs.readFile(manifestPath, 'utf8');
|
|
396
|
+
const parsed = JSON.parse(raw) as ApiReferenceManifest;
|
|
397
|
+
if (parsed.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
|
|
398
|
+
// Unknown schema — treat as empty so we regenerate cleanly. Human can resolve any
|
|
399
|
+
// resulting orphan churn via the next git diff.
|
|
400
|
+
return emptyManifest();
|
|
401
|
+
}
|
|
402
|
+
return parsed;
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
405
|
+
return emptyManifest();
|
|
406
|
+
}
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function writeManifest(rootDir: string, manifest: ApiReferenceManifest): Promise<void> {
|
|
412
|
+
const manifestPath = path.resolve(rootDir, MANIFEST_RELATIVE_PATH);
|
|
413
|
+
const sortedManifest: ApiReferenceManifest = {
|
|
414
|
+
...manifest,
|
|
415
|
+
pages: [...manifest.pages].sort((a, b) => a.slug.localeCompare(b.slug))
|
|
416
|
+
};
|
|
417
|
+
const body = `${JSON.stringify(sortedManifest, null, 2)}\n`;
|
|
418
|
+
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
|
419
|
+
await fs.writeFile(manifestPath, body, 'utf8');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function emptyManifest(): ApiReferenceManifest {
|
|
423
|
+
return { schemaVersion: MANIFEST_SCHEMA_VERSION, generatedAt: '', pages: [] };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function writeIfChanged(absolutePath: string, body: string): Promise<void> {
|
|
427
|
+
let existing: string | undefined;
|
|
428
|
+
try {
|
|
429
|
+
existing = await fs.readFile(absolutePath, 'utf8');
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (existing === body) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
439
|
+
await fs.writeFile(absolutePath, body, 'utf8');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function sha256(body: string): string {
|
|
443
|
+
return createHash('sha256').update(body).digest('hex');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function ensureTrailingNewline(body: string): string {
|
|
447
|
+
return body.endsWith('\n') ? body : `${body}\n`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function countDocumented(ref: { symbols: { docComment: string | null }[] }): number {
|
|
451
|
+
return ref.symbols.filter((symbol) => symbol.docComment !== null && symbol.docComment.trim().length > 0).length;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function computeSourceLinkBase(pageRelativePath: string): string {
|
|
455
|
+
// Page lives at e.g. `docs/wiki/api/wiki/i18n.md`. To reach `src/wiki/i18n.ts` from the page,
|
|
456
|
+
// we go up by the page's directory depth from the project root.
|
|
457
|
+
const directoryDepth = pageRelativePath.replace(/\\/g, '/').split('/').length - 1;
|
|
458
|
+
return Array.from({ length: directoryDepth }, () => '..').join('/');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
interface LinkIndexEntry {
|
|
462
|
+
slug: string;
|
|
463
|
+
anchor: string;
|
|
464
|
+
sourceFile: string;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
type LinkIndex = Map<string, LinkIndexEntry[]>;
|
|
468
|
+
|
|
469
|
+
function buildLinkIndex(entries: { slug: string; ref: ApiFileReference }[]): LinkIndex {
|
|
470
|
+
const index: LinkIndex = new Map();
|
|
471
|
+
for (const { slug, ref } of entries) {
|
|
472
|
+
for (const symbol of ref.symbols) {
|
|
473
|
+
const anchor = anchorFor(symbol.name);
|
|
474
|
+
const bucket = index.get(symbol.name);
|
|
475
|
+
const entry: LinkIndexEntry = { slug, anchor, sourceFile: ref.sourcePath };
|
|
476
|
+
if (bucket) {
|
|
477
|
+
bucket.push(entry);
|
|
478
|
+
} else {
|
|
479
|
+
index.set(symbol.name, [entry]);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return index;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
interface ResolveCrossReferenceArgs {
|
|
487
|
+
target: string;
|
|
488
|
+
displayText: string | undefined;
|
|
489
|
+
currentSlug: string;
|
|
490
|
+
currentSourceFile: string;
|
|
491
|
+
linkIndex: LinkIndex;
|
|
492
|
+
warnings: ApiReferenceWarning[];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function resolveCrossReference(args: ResolveCrossReferenceArgs): LinkResolution {
|
|
496
|
+
// Allow `Foo.bar` qualified forms by resolving against the head and keeping the full
|
|
497
|
+
// expression as the default display label (so the dotted name remains visible to readers).
|
|
498
|
+
const headTarget = args.target.split('.')[0];
|
|
499
|
+
const display = args.displayText ?? args.target;
|
|
500
|
+
|
|
501
|
+
const matches = args.linkIndex.get(headTarget) ?? [];
|
|
502
|
+
if (matches.length === 0) {
|
|
503
|
+
args.warnings.push({
|
|
504
|
+
kind: 'unresolved-link',
|
|
505
|
+
message: `cannot resolve {@link ${args.target}} in ${args.currentSourceFile}`,
|
|
506
|
+
sourceFile: args.currentSourceFile
|
|
507
|
+
});
|
|
508
|
+
return { url: null, display };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let chosen: LinkIndexEntry | undefined;
|
|
512
|
+
if (matches.length === 1) {
|
|
513
|
+
chosen = matches[0];
|
|
514
|
+
} else {
|
|
515
|
+
// Disambiguate by shared module path prefix: prefer a match whose slug starts with the
|
|
516
|
+
// current page's slug-directory.
|
|
517
|
+
const currentDir = args.currentSlug.includes('/')
|
|
518
|
+
? args.currentSlug.slice(0, args.currentSlug.lastIndexOf('/'))
|
|
519
|
+
: '';
|
|
520
|
+
if (currentDir) {
|
|
521
|
+
const sameModule = matches.filter((entry) => entry.slug.startsWith(`${currentDir}/`) || entry.slug === currentDir);
|
|
522
|
+
if (sameModule.length === 1) {
|
|
523
|
+
chosen = sameModule[0];
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (!chosen) {
|
|
527
|
+
args.warnings.push({
|
|
528
|
+
kind: 'ambiguous-link',
|
|
529
|
+
message: `{@link ${args.target}} in ${args.currentSourceFile} is ambiguous: ${matches.map((entry) => entry.slug).join(', ')}`,
|
|
530
|
+
sourceFile: args.currentSourceFile
|
|
531
|
+
});
|
|
532
|
+
return { url: null, display, comment: `ambiguous link: ${args.target}` };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const url = chosen.slug === args.currentSlug
|
|
537
|
+
? `#${chosen.anchor}`
|
|
538
|
+
: `${relativePagePath(args.currentSlug, chosen.slug)}#${chosen.anchor}`;
|
|
539
|
+
return { url, display };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function relativePagePath(currentSlug: string, targetSlug: string): string {
|
|
543
|
+
const currentDir = currentSlug.includes('/') ? currentSlug.slice(0, currentSlug.lastIndexOf('/')) : '';
|
|
544
|
+
const targetPath = `${targetSlug}.md`;
|
|
545
|
+
const rel = path.posix.relative(currentDir, targetPath);
|
|
546
|
+
// path.posix.relative returns 'foo.md' for sibling pages. We want './foo.md' so the link
|
|
547
|
+
// is unambiguous in markdown renderers that distinguish relative-from-here vs. anchor-only.
|
|
548
|
+
if (!rel.startsWith('.')) {
|
|
549
|
+
return `./${rel}`;
|
|
550
|
+
}
|
|
551
|
+
return rel;
|
|
552
|
+
}
|