@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,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the API reference extractor.
|
|
3
|
+
*
|
|
4
|
+
* Defines `ApiSymbol`, `ApiDocTag`, and `ApiFileReference` — the structured shape every
|
|
5
|
+
* `LanguageExtractor` must produce. The renderer in `./render.ts` and the orchestrator in
|
|
6
|
+
* `../api-reference.ts` consume this shape; the TypeScript implementation in
|
|
7
|
+
* `./extract.ts` and `./typescript-extractor.ts` produces it. Future Python/Rust/Go
|
|
8
|
+
* extractors will produce the same shape, which is what makes language pluggability work
|
|
9
|
+
* without orchestrator changes. Phase A1 of the API reference roadmap defines the contract.
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in TypeScript `LanguageExtractor`.
|
|
3
|
+
*
|
|
4
|
+
* Thin adapter that wraps `extractApiFileReference` (from `./extract.ts`) and
|
|
5
|
+
* `walkProjectSources` (from `./walk.ts`) behind the language-agnostic interface, so the
|
|
6
|
+
* orchestrator's dispatch loop is uniform across languages. `detect()` is intentionally
|
|
7
|
+
* high-recall: returns true on `tsconfig.json`, `package.json`, OR a bare `src/` directory
|
|
8
|
+
* — any of those is a strong "this is a Node/TypeScript project" signal. When future
|
|
9
|
+
* extractors are added, registration order in `../api-reference.ts` decides which one
|
|
10
|
+
* claims a project where multiple `detect()` would match.
|
|
11
|
+
*/
|
|
12
|
+
import { promises as fs } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { extractApiFileReference } from './extract.js';
|
|
15
|
+
import { walkProjectSources } from './walk.js';
|
|
16
|
+
async function exists(filePath) {
|
|
17
|
+
try {
|
|
18
|
+
await fs.access(filePath);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export const typeScriptExtractor = {
|
|
26
|
+
id: 'typescript',
|
|
27
|
+
async detect(rootDir) {
|
|
28
|
+
// High-recall detection: any of the conventional Node/TS signals counts. For projects
|
|
29
|
+
// that have just `src/` with .ts files (e.g., test fixtures) this still resolves true.
|
|
30
|
+
// When a second language extractor is added, its detect() runs in registration order,
|
|
31
|
+
// so the orchestrator can prefer (say) Python over TypeScript by registering python
|
|
32
|
+
// first.
|
|
33
|
+
if (await exists(path.join(rootDir, 'tsconfig.json'))) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (await exists(path.join(rootDir, 'package.json'))) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (await exists(path.join(rootDir, 'src'))) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
},
|
|
44
|
+
async walk(rootDir, options) {
|
|
45
|
+
return walkProjectSources(rootDir, options);
|
|
46
|
+
},
|
|
47
|
+
async extract(sourcePath, options) {
|
|
48
|
+
return extractApiFileReference(sourcePath, { rootDir: options?.rootDir });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk a project directory and return source paths the API reference generator should
|
|
3
|
+
* extract — project-relative, forward slashes, alphabetically sorted.
|
|
4
|
+
*
|
|
5
|
+
* Pure Node 20+ — no glob library. The matcher is a small custom converter from glob to
|
|
6
|
+
* regex covering double-star, single-star, single-char, and literal segments. That covers
|
|
7
|
+
* the patterns the generator's defaults pass in: source globs under the root source tree
|
|
8
|
+
* plus workspace package source trees, test-file exclusions, internal-convention directory
|
|
9
|
+
* exclusions, and `node_modules` pruning.
|
|
10
|
+
*
|
|
11
|
+
* A second filter respects file-level `@internal` JSDoc on the source itself: when
|
|
12
|
+
* `respectInternalConvention` is true (default), each candidate's first 2KB is read and
|
|
13
|
+
* the file is skipped if a top-of-file JSDoc block contains an `@internal` tag. That
|
|
14
|
+
* mirrors how individual symbols are filtered in `./extract.ts` and lets a whole module
|
|
15
|
+
* opt out of the API reference without moving it into an `internal/` directory.
|
|
16
|
+
*/
|
|
17
|
+
import { promises as fs } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
// TypeScript's modern file extensions: `.ts` is the canonical, `.tsx` covers JSX/React,
|
|
20
|
+
// `.cts`/`.mts` are the ESM-compat node variants. The slug regex in extract.ts already
|
|
21
|
+
// strips all four; the walker now lists them explicitly because brace expansion isn't
|
|
22
|
+
// supported in the matcher (see `toMatcher`).
|
|
23
|
+
const DEFAULT_INCLUDE = [
|
|
24
|
+
'src/**/*.ts',
|
|
25
|
+
'src/**/*.tsx',
|
|
26
|
+
'src/**/*.cts',
|
|
27
|
+
'src/**/*.mts',
|
|
28
|
+
'packages/*/src/**/*.ts',
|
|
29
|
+
'packages/*/src/**/*.tsx',
|
|
30
|
+
'packages/*/src/**/*.cts',
|
|
31
|
+
'packages/*/src/**/*.mts'
|
|
32
|
+
];
|
|
33
|
+
const DEFAULT_EXCLUDE = [
|
|
34
|
+
'**/*.test.ts',
|
|
35
|
+
'**/*.test.tsx',
|
|
36
|
+
'**/*.d.ts',
|
|
37
|
+
'**/internal/**',
|
|
38
|
+
'**/_internal/**',
|
|
39
|
+
'**/node_modules/**'
|
|
40
|
+
];
|
|
41
|
+
export async function walkProjectSources(rootDir, options = {}) {
|
|
42
|
+
const include = (options.include ?? DEFAULT_INCLUDE).map(toMatcher);
|
|
43
|
+
const exclude = (options.exclude ?? DEFAULT_EXCLUDE).map(toMatcher);
|
|
44
|
+
const respectInternal = options.respectInternalConvention ?? true;
|
|
45
|
+
// Distinguish `undefined` (no limit, unbounded walk) from `0` (caller explicitly asked
|
|
46
|
+
// for zero results, perhaps via arithmetic that drained their budget). Treating 0 as
|
|
47
|
+
// Infinity would silently turn a probe with depleted budget into a full tree walk.
|
|
48
|
+
const limit = typeof options.limit === 'number' && options.limit >= 0 ? options.limit : Infinity;
|
|
49
|
+
const matches = [];
|
|
50
|
+
await walk(rootDir, '', matches, include, exclude, limit);
|
|
51
|
+
matches.sort();
|
|
52
|
+
if (respectInternal) {
|
|
53
|
+
const filtered = [];
|
|
54
|
+
for (const match of matches) {
|
|
55
|
+
if (await fileTopJSDocHasInternalTag(path.resolve(rootDir, match))) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
filtered.push(match);
|
|
59
|
+
}
|
|
60
|
+
return filtered;
|
|
61
|
+
}
|
|
62
|
+
return matches;
|
|
63
|
+
}
|
|
64
|
+
async function walk(rootDir, relativeDir, out, include, exclude, limit) {
|
|
65
|
+
if (out.length >= limit)
|
|
66
|
+
return;
|
|
67
|
+
const absoluteDir = path.resolve(rootDir, relativeDir);
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = await fs.readdir(absoluteDir, { withFileTypes: true });
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code === 'ENOENT') {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (out.length >= limit)
|
|
80
|
+
return;
|
|
81
|
+
const childRel = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
// Pre-prune common heavy directories before recursing.
|
|
84
|
+
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist') {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
await walk(rootDir, childRel, out, include, exclude, limit);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!entry.isFile()) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (exclude.some((re) => re.test(childRel))) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!include.some((re) => re.test(childRel))) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
out.push(childRel);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function fileTopJSDocHasInternalTag(absolutePath) {
|
|
103
|
+
// Cheap scan of the leading bytes — read just enough to cover any plausible top-of-file
|
|
104
|
+
// doc comment without slurping the whole source.
|
|
105
|
+
let head;
|
|
106
|
+
try {
|
|
107
|
+
const handle = await fs.open(absolutePath, 'r');
|
|
108
|
+
try {
|
|
109
|
+
const buffer = Buffer.alloc(2048);
|
|
110
|
+
const result = await handle.read(buffer, 0, buffer.length, 0);
|
|
111
|
+
head = buffer.slice(0, result.bytesRead).toString('utf8');
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await handle.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
// Look for a top-of-file JSDoc block, then check whether it carries an @internal tag.
|
|
121
|
+
// The match anchors on tag-position — start of line, optionally after the JSDoc `*`
|
|
122
|
+
// prefix — so prose mentions of "@internal" inside descriptions don't accidentally
|
|
123
|
+
// self-filter the file (the docstring on this very file mentions @internal in prose).
|
|
124
|
+
const match = head.match(/^\s*\/\*\*([\s\S]*?)\*\//);
|
|
125
|
+
if (!match) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return /^[ \t]*\*?[ \t]*@internal\b/m.test(match[1]);
|
|
129
|
+
}
|
|
130
|
+
function toMatcher(pattern) {
|
|
131
|
+
// Brace expansion (`{a,b}`) and POSIX character classes (`[abc]`) are not supported.
|
|
132
|
+
// Silently escaping the metacharacters as literals would produce regexes that match
|
|
133
|
+
// patterns like `{ts,tsx}` literally — silent miscompiles. Refuse with a clear error
|
|
134
|
+
// and tell the caller to pass each variant as a separate include glob.
|
|
135
|
+
if (/[{}]/.test(pattern)) {
|
|
136
|
+
throw new Error(`walkProjectSources: brace expansion is not supported in glob "${pattern}". ` +
|
|
137
|
+
`Pass each variant as a separate include pattern (e.g., 'src/**/*.ts' AND 'src/**/*.tsx' instead of 'src/**/*.{ts,tsx}').`);
|
|
138
|
+
}
|
|
139
|
+
if (/\[(?!\])/.test(pattern)) {
|
|
140
|
+
throw new Error(`walkProjectSources: character classes are not supported in glob "${pattern}". ` +
|
|
141
|
+
`Use literal patterns instead.`);
|
|
142
|
+
}
|
|
143
|
+
// Escape regex specials, then translate glob tokens. Order matters: handle `**` before `*`.
|
|
144
|
+
let escaped = '';
|
|
145
|
+
let i = 0;
|
|
146
|
+
while (i < pattern.length) {
|
|
147
|
+
const char = pattern[i];
|
|
148
|
+
if (char === '*' && pattern[i + 1] === '*') {
|
|
149
|
+
// `**/` matches zero or more path segments. `**` alone matches anything.
|
|
150
|
+
if (pattern[i + 2] === '/') {
|
|
151
|
+
escaped += '(?:.*/)?';
|
|
152
|
+
i += 3;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
escaped += '.*';
|
|
156
|
+
i += 2;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (char === '*') {
|
|
160
|
+
escaped += '[^/]*';
|
|
161
|
+
i += 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (char === '?') {
|
|
165
|
+
escaped += '[^/]';
|
|
166
|
+
i += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (/[.+^${}()|[\]\\]/.test(char)) {
|
|
170
|
+
escaped += `\\${char}`;
|
|
171
|
+
i += 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
escaped += char;
|
|
175
|
+
i += 1;
|
|
176
|
+
}
|
|
177
|
+
return new RegExp(`^${escaped}$`);
|
|
178
|
+
}
|
|
@@ -0,0 +1,438 @@
|
|
|
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
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { promises as fs } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { pythonExtractor } from './api-extractor/python-extractor.js';
|
|
24
|
+
import { anchorFor, renderApiPage } from './api-extractor/render.js';
|
|
25
|
+
import { treeSitterExtractor } from './api-extractor/tree-sitter-extractor.js';
|
|
26
|
+
import { typeScriptExtractor } from './api-extractor/typescript-extractor.js';
|
|
27
|
+
// Built-in language extractors, registration-ordered. The first whose `detect()` returns
|
|
28
|
+
// true claims the project. Python is registered ahead of TypeScript so a project that
|
|
29
|
+
// mixes a `pyproject.toml` with a `package.json` (e.g., a Python tool with Node-side
|
|
30
|
+
// scripting) gets its Python source documented; pure-TS projects with no Python signals
|
|
31
|
+
// still flow cleanly through to typeScriptExtractor. The tree-sitter extractor handles
|
|
32
|
+
// the long-tail languages (Rust today; Go/Java/Ruby/C/C++/PHP next) and is registered
|
|
33
|
+
// FIRST so a Rust+Node hybrid lands on Rust output rather than the TS extractor's
|
|
34
|
+
// high-recall claim. Pure-TS projects fall through to typeScriptExtractor as before.
|
|
35
|
+
const builtInLanguageExtractors = [
|
|
36
|
+
treeSitterExtractor,
|
|
37
|
+
pythonExtractor,
|
|
38
|
+
typeScriptExtractor
|
|
39
|
+
];
|
|
40
|
+
export { pythonExtractor } from './api-extractor/python-extractor.js';
|
|
41
|
+
export { treeSitterExtractor } from './api-extractor/tree-sitter-extractor.js';
|
|
42
|
+
export { typeScriptExtractor } from './api-extractor/typescript-extractor.js';
|
|
43
|
+
const API_PAGES_ROOT = path.join('docs', 'wiki', 'api');
|
|
44
|
+
const MANIFEST_RELATIVE_PATH = path.join('docs', 'public', 'api-reference-manifest.json');
|
|
45
|
+
const MANIFEST_SCHEMA_VERSION = 1;
|
|
46
|
+
const MANIFEST_OWNED_PREFIX = 'api/';
|
|
47
|
+
export async function refreshApiReference(options = {}) {
|
|
48
|
+
const rootDir = options.rootDir ? path.resolve(options.rootDir) : process.cwd();
|
|
49
|
+
const dryRun = options.dryRun ?? false;
|
|
50
|
+
const generatedAt = options.now ?? new Date().toISOString();
|
|
51
|
+
// Pluggability dispatch: pick the first registered extractor whose detect() matches the
|
|
52
|
+
// project. When none match (e.g., a non-TS project with no recognizable signals), return
|
|
53
|
+
// an empty result — the same behavior as the previous "walk found 0 sources" path.
|
|
54
|
+
const extractors = options.extractors ?? builtInLanguageExtractors;
|
|
55
|
+
let activeExtractor;
|
|
56
|
+
for (const candidate of extractors) {
|
|
57
|
+
if (await candidate.detect(rootDir)) {
|
|
58
|
+
activeExtractor = candidate;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const previousManifest = await readManifest(rootDir);
|
|
63
|
+
const previousBySlug = new Map(previousManifest.pages.map((entry) => [entry.slug, entry]));
|
|
64
|
+
if (!activeExtractor) {
|
|
65
|
+
// No extractor claimed this project — nothing to scan. Still produce a valid manifest
|
|
66
|
+
// (an empty one) and run the orphan-cleanup pass against the previous manifest so a
|
|
67
|
+
// project that switches stacks doesn't strand stale pages.
|
|
68
|
+
const orphanSlugs = previousManifest.pages.map((entry) => entry.slug);
|
|
69
|
+
const newManifest = {
|
|
70
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
71
|
+
generatedAt,
|
|
72
|
+
pages: []
|
|
73
|
+
};
|
|
74
|
+
if (!dryRun && orphanSlugs.length > 0) {
|
|
75
|
+
for (const slug of orphanSlugs) {
|
|
76
|
+
const orphanPath = resolveSafeOrphanPath(rootDir, slug);
|
|
77
|
+
if (!orphanPath)
|
|
78
|
+
continue;
|
|
79
|
+
await fs.rm(orphanPath, { force: true });
|
|
80
|
+
}
|
|
81
|
+
await writeManifest(rootDir, newManifest);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
pagesWritten: 0,
|
|
85
|
+
pagesChanged: [],
|
|
86
|
+
pagesDeleted: orphanSlugs,
|
|
87
|
+
warnings: [],
|
|
88
|
+
sourcesScanned: 0,
|
|
89
|
+
sourcesSkipped: [],
|
|
90
|
+
manifest: newManifest
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const sources = await activeExtractor.walk(rootDir, options.walkOptions);
|
|
94
|
+
// Build a per-project source-link resolver. When the project has a detectable GitHub
|
|
95
|
+
// repository URL in package.json, source links go to https://github.com/... so they
|
|
96
|
+
// work in VitePress, on GitHub web, and in IDEs alike. When no repository URL is
|
|
97
|
+
// detectable, the resolver returns null → the renderer emits plain text (no link)
|
|
98
|
+
// instead of a relative path that 404s in the browser-served VitePress site.
|
|
99
|
+
const sourceLinkResolver = await buildSourceLinkResolver(rootDir);
|
|
100
|
+
const warnings = [];
|
|
101
|
+
const sourcesSkipped = [];
|
|
102
|
+
const pendingRefs = [];
|
|
103
|
+
for (const source of sources) {
|
|
104
|
+
let ref;
|
|
105
|
+
try {
|
|
106
|
+
ref = await activeExtractor.extract(source, { rootDir });
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
warnings.push({
|
|
110
|
+
kind: 'extraction-error',
|
|
111
|
+
message: error instanceof Error ? error.message : String(error),
|
|
112
|
+
sourceFile: source
|
|
113
|
+
});
|
|
114
|
+
sourcesSkipped.push({ path: source, reason: 'extraction-error' });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (ref.symbols.length === 0) {
|
|
118
|
+
sourcesSkipped.push({ path: source, reason: 'no-exports' });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const slug = ref.moduleSlug;
|
|
122
|
+
if (!slug.startsWith(MANIFEST_OWNED_PREFIX)) {
|
|
123
|
+
throw new Error(`refreshApiReference: derived slug "${slug}" does not start with required prefix "${MANIFEST_OWNED_PREFIX}" — refusing to claim`);
|
|
124
|
+
}
|
|
125
|
+
const pageRelativePath = `${path.posix.join(API_PAGES_ROOT.replace(/\\/g, '/'), slug.slice(MANIFEST_OWNED_PREFIX.length))}.md`;
|
|
126
|
+
const sourceLinkBase = computeSourceLinkBase(pageRelativePath);
|
|
127
|
+
if (countDocumented(ref) === 0) {
|
|
128
|
+
warnings.push({
|
|
129
|
+
kind: 'low-coverage',
|
|
130
|
+
message: `${source} has ${ref.symbols.length} export(s) but 0 with doc comments`,
|
|
131
|
+
sourceFile: source
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
pendingRefs.push({ ref, slug, pageRelativePath, sourceLinkBase });
|
|
135
|
+
}
|
|
136
|
+
// Pass 2: build the cross-file link index and render with a resolver per page. The
|
|
137
|
+
// resolver pushes link warnings back into the shared `warnings` list so unresolved or
|
|
138
|
+
// ambiguous references surface in the result.
|
|
139
|
+
const linkIndex = buildLinkIndex(pendingRefs.map((entry) => ({ slug: entry.slug, ref: entry.ref })));
|
|
140
|
+
const renderedPages = [];
|
|
141
|
+
for (const pending of pendingRefs) {
|
|
142
|
+
const resolveLink = (target, displayText) => resolveCrossReference({
|
|
143
|
+
target,
|
|
144
|
+
displayText,
|
|
145
|
+
currentSlug: pending.slug,
|
|
146
|
+
currentSourceFile: pending.ref.sourcePath,
|
|
147
|
+
linkIndex,
|
|
148
|
+
warnings
|
|
149
|
+
});
|
|
150
|
+
const body = renderApiPage(pending.ref, {
|
|
151
|
+
sourceLinkBase: pending.sourceLinkBase,
|
|
152
|
+
sourceLinkResolver,
|
|
153
|
+
resolveLink
|
|
154
|
+
});
|
|
155
|
+
const finalBody = ensureTrailingNewline(body);
|
|
156
|
+
const contentHash = sha256(finalBody);
|
|
157
|
+
renderedPages.push({
|
|
158
|
+
slug: pending.slug,
|
|
159
|
+
absolutePath: path.resolve(rootDir, pending.pageRelativePath),
|
|
160
|
+
body: finalBody,
|
|
161
|
+
entry: {
|
|
162
|
+
slug: pending.slug,
|
|
163
|
+
sourceFile: pending.ref.sourcePath,
|
|
164
|
+
symbolCount: pending.ref.symbols.length,
|
|
165
|
+
contentHash
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
renderedPages.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
170
|
+
const newSlugs = new Set(renderedPages.map((page) => page.slug));
|
|
171
|
+
const orphanSlugs = previousManifest.pages
|
|
172
|
+
.map((entry) => entry.slug)
|
|
173
|
+
.filter((slug) => !newSlugs.has(slug));
|
|
174
|
+
for (const slug of orphanSlugs) {
|
|
175
|
+
// Defense-in-depth: validate every orphan slug now, before any I/O. If a corrupted
|
|
176
|
+
// manifest entry ever escaped the api/ tree (e.g., `api/../../etc/passwd`), this throw
|
|
177
|
+
// surfaces it loudly — and short-circuits before we touch the filesystem.
|
|
178
|
+
if (resolveSafeOrphanPath(rootDir, slug) === null) {
|
|
179
|
+
throw new Error(`refreshApiReference: previous manifest contained unsafe slug "${slug}" — refusing to act on it`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const pagesChanged = [];
|
|
183
|
+
for (const page of renderedPages) {
|
|
184
|
+
const previous = previousBySlug.get(page.slug);
|
|
185
|
+
if (!previous || previous.contentHash !== page.entry.contentHash) {
|
|
186
|
+
pagesChanged.push(page.slug);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const pagesDeleted = [...orphanSlugs];
|
|
190
|
+
const newManifest = {
|
|
191
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
192
|
+
generatedAt,
|
|
193
|
+
pages: renderedPages.map((page) => page.entry)
|
|
194
|
+
};
|
|
195
|
+
if (!dryRun) {
|
|
196
|
+
for (const page of renderedPages) {
|
|
197
|
+
await writeIfChanged(page.absolutePath, page.body);
|
|
198
|
+
}
|
|
199
|
+
for (const slug of orphanSlugs) {
|
|
200
|
+
// resolveSafeOrphanPath was already validated above; non-null is guaranteed.
|
|
201
|
+
const orphanPath = resolveSafeOrphanPath(rootDir, slug);
|
|
202
|
+
if (orphanPath) {
|
|
203
|
+
await fs.rm(orphanPath, { force: true });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
await writeManifest(rootDir, newManifest);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
pagesWritten: renderedPages.length,
|
|
210
|
+
pagesChanged,
|
|
211
|
+
pagesDeleted,
|
|
212
|
+
warnings,
|
|
213
|
+
sourcesScanned: sources.length,
|
|
214
|
+
sourcesSkipped,
|
|
215
|
+
manifest: newManifest
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Builds a function that turns a (sourcePath, line) pair into a source-link URL the
|
|
220
|
+
* rendered API page can hyperlink to. Reads the project's `package.json` and inspects
|
|
221
|
+
* the `repository` field (handles both string and object forms, and the `git+` /
|
|
222
|
+
* `.git` decorations npm conventionally adds). When it finds a github.com URL, returns
|
|
223
|
+
* a resolver that emits full `https://github.com/<owner>/<repo>/blob/main/<path>#L<line>`
|
|
224
|
+
* URLs — those work in every viewing context (VitePress static site, GitHub web view,
|
|
225
|
+
* IDE markdown preview). When no GitHub URL is detected, returns a resolver that always
|
|
226
|
+
* returns null, signaling the renderer to emit plain-text source references rather than
|
|
227
|
+
* relative-path links that 404 in browser-served sites.
|
|
228
|
+
*
|
|
229
|
+
* Branch resolution: hardcoded to `main` for simplicity. Future enhancement could read
|
|
230
|
+
* `git rev-parse --abbrev-ref HEAD` for the actual default branch, or honor a
|
|
231
|
+
* `DENDRITE_API_REFERENCE_BRANCH` env var.
|
|
232
|
+
*
|
|
233
|
+
* Other git hosts (GitLab, Bitbucket, codeberg.org, sr.ht, self-hosted Gitea) are not
|
|
234
|
+
* yet detected; their projects fall through to plain-text source references. Adding
|
|
235
|
+
* support is a small follow-up — each host has a stable `<host>/<owner>/<repo>/blob/<branch>/<path>`
|
|
236
|
+
* URL pattern (or close variant) that can be detected the same way.
|
|
237
|
+
*/
|
|
238
|
+
async function buildSourceLinkResolver(rootDir) {
|
|
239
|
+
let pkgRaw;
|
|
240
|
+
try {
|
|
241
|
+
pkgRaw = await fs.readFile(path.join(rootDir, 'package.json'), 'utf8');
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return () => null;
|
|
245
|
+
}
|
|
246
|
+
let pkg;
|
|
247
|
+
try {
|
|
248
|
+
pkg = JSON.parse(pkgRaw);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return () => null;
|
|
252
|
+
}
|
|
253
|
+
const repoField = pkg.repository;
|
|
254
|
+
const repoUrl = typeof repoField === 'string' ? repoField : repoField?.url;
|
|
255
|
+
if (!repoUrl)
|
|
256
|
+
return () => null;
|
|
257
|
+
// Strip the `git+` prefix npm adds and the `.git` suffix git remotes use, then look
|
|
258
|
+
// for github.com followed by `/<owner>/<repo>`. SSH-form `git@github.com:owner/repo`
|
|
259
|
+
// and HTTPS `https://github.com/owner/repo` both match.
|
|
260
|
+
const match = repoUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
|
261
|
+
if (!match)
|
|
262
|
+
return () => null;
|
|
263
|
+
const owner = match[1];
|
|
264
|
+
const repo = match[2];
|
|
265
|
+
const branch = process.env.DENDRITE_API_REFERENCE_BRANCH ?? 'main';
|
|
266
|
+
return (sourcePath, line) => `https://github.com/${owner}/${repo}/blob/${branch}/${sourcePath}#L${line}`;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Resolves the absolute on-disk path for an orphan slug, returning null when the slug is
|
|
270
|
+
* not owned by this generator OR when the resolved path escapes the API tree. Centralizes
|
|
271
|
+
* the path-traversal guard so both code paths in `refreshApiReference` use the same check.
|
|
272
|
+
*
|
|
273
|
+
* Why this matters: a corrupt manifest entry like `{"slug": "api/../../etc/passwd"}`
|
|
274
|
+
* passes a naive `slug.startsWith("api/")` check, but `path.posix.join("docs/wiki/api",
|
|
275
|
+
* "../../etc/passwd")` resolves outside the API tree. `fs.rm({ force: true })` would
|
|
276
|
+
* happily delete it. This guard short-circuits before any filesystem I/O.
|
|
277
|
+
*/
|
|
278
|
+
function resolveSafeOrphanPath(rootDir, slug) {
|
|
279
|
+
if (!slug.startsWith(MANIFEST_OWNED_PREFIX))
|
|
280
|
+
return null;
|
|
281
|
+
// Reject slugs carrying characters that have meaning to the filesystem layer regardless
|
|
282
|
+
// of platform: `\0` (POSIX path terminator), and `:` (Windows drive-letter separator,
|
|
283
|
+
// which would otherwise cause `path.resolve` to switch drives). Slugs from our own
|
|
284
|
+
// derivation never contain these; rejecting them here closes a malformed-manifest
|
|
285
|
+
// attack surface up front.
|
|
286
|
+
if (slug.includes('\0') || slug.includes(':'))
|
|
287
|
+
return null;
|
|
288
|
+
const apiRootAbs = path.resolve(rootDir, API_PAGES_ROOT);
|
|
289
|
+
const candidate = path.resolve(apiRootAbs, `${slug.slice(MANIFEST_OWNED_PREFIX.length)}.md`);
|
|
290
|
+
const relativeFromApi = path.relative(apiRootAbs, candidate);
|
|
291
|
+
// Outside the API tree iff:
|
|
292
|
+
// - the path is absolute (e.g., a Windows drive-letter switch slipped past slug
|
|
293
|
+
// validation, or POSIX absolute path)
|
|
294
|
+
// - the FIRST path segment is exactly `..` (escapes upward by a directory).
|
|
295
|
+
// We compare segments rather than using `startsWith('..')` to avoid false-rejecting a
|
|
296
|
+
// file legitimately named e.g. `..config` inside the API tree.
|
|
297
|
+
if (path.isAbsolute(relativeFromApi))
|
|
298
|
+
return null;
|
|
299
|
+
const firstSegment = relativeFromApi.split(/[\\/]/)[0];
|
|
300
|
+
if (firstSegment === '..')
|
|
301
|
+
return null;
|
|
302
|
+
return candidate;
|
|
303
|
+
}
|
|
304
|
+
async function readManifest(rootDir) {
|
|
305
|
+
const manifestPath = path.resolve(rootDir, MANIFEST_RELATIVE_PATH);
|
|
306
|
+
try {
|
|
307
|
+
const raw = await fs.readFile(manifestPath, 'utf8');
|
|
308
|
+
const parsed = JSON.parse(raw);
|
|
309
|
+
if (parsed.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
|
|
310
|
+
// Unknown schema — treat as empty so we regenerate cleanly. Human can resolve any
|
|
311
|
+
// resulting orphan churn via the next git diff.
|
|
312
|
+
return emptyManifest();
|
|
313
|
+
}
|
|
314
|
+
return parsed;
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (error.code === 'ENOENT') {
|
|
318
|
+
return emptyManifest();
|
|
319
|
+
}
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function writeManifest(rootDir, manifest) {
|
|
324
|
+
const manifestPath = path.resolve(rootDir, MANIFEST_RELATIVE_PATH);
|
|
325
|
+
const sortedManifest = {
|
|
326
|
+
...manifest,
|
|
327
|
+
pages: [...manifest.pages].sort((a, b) => a.slug.localeCompare(b.slug))
|
|
328
|
+
};
|
|
329
|
+
const body = `${JSON.stringify(sortedManifest, null, 2)}\n`;
|
|
330
|
+
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
|
331
|
+
await fs.writeFile(manifestPath, body, 'utf8');
|
|
332
|
+
}
|
|
333
|
+
function emptyManifest() {
|
|
334
|
+
return { schemaVersion: MANIFEST_SCHEMA_VERSION, generatedAt: '', pages: [] };
|
|
335
|
+
}
|
|
336
|
+
async function writeIfChanged(absolutePath, body) {
|
|
337
|
+
let existing;
|
|
338
|
+
try {
|
|
339
|
+
existing = await fs.readFile(absolutePath, 'utf8');
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (error.code !== 'ENOENT') {
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (existing === body) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
350
|
+
await fs.writeFile(absolutePath, body, 'utf8');
|
|
351
|
+
}
|
|
352
|
+
function sha256(body) {
|
|
353
|
+
return createHash('sha256').update(body).digest('hex');
|
|
354
|
+
}
|
|
355
|
+
function ensureTrailingNewline(body) {
|
|
356
|
+
return body.endsWith('\n') ? body : `${body}\n`;
|
|
357
|
+
}
|
|
358
|
+
function countDocumented(ref) {
|
|
359
|
+
return ref.symbols.filter((symbol) => symbol.docComment !== null && symbol.docComment.trim().length > 0).length;
|
|
360
|
+
}
|
|
361
|
+
function computeSourceLinkBase(pageRelativePath) {
|
|
362
|
+
// Page lives at e.g. `docs/wiki/api/wiki/i18n.md`. To reach `src/wiki/i18n.ts` from the page,
|
|
363
|
+
// we go up by the page's directory depth from the project root.
|
|
364
|
+
const directoryDepth = pageRelativePath.replace(/\\/g, '/').split('/').length - 1;
|
|
365
|
+
return Array.from({ length: directoryDepth }, () => '..').join('/');
|
|
366
|
+
}
|
|
367
|
+
function buildLinkIndex(entries) {
|
|
368
|
+
const index = new Map();
|
|
369
|
+
for (const { slug, ref } of entries) {
|
|
370
|
+
for (const symbol of ref.symbols) {
|
|
371
|
+
const anchor = anchorFor(symbol.name);
|
|
372
|
+
const bucket = index.get(symbol.name);
|
|
373
|
+
const entry = { slug, anchor, sourceFile: ref.sourcePath };
|
|
374
|
+
if (bucket) {
|
|
375
|
+
bucket.push(entry);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
index.set(symbol.name, [entry]);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return index;
|
|
383
|
+
}
|
|
384
|
+
function resolveCrossReference(args) {
|
|
385
|
+
// Allow `Foo.bar` qualified forms by resolving against the head and keeping the full
|
|
386
|
+
// expression as the default display label (so the dotted name remains visible to readers).
|
|
387
|
+
const headTarget = args.target.split('.')[0];
|
|
388
|
+
const display = args.displayText ?? args.target;
|
|
389
|
+
const matches = args.linkIndex.get(headTarget) ?? [];
|
|
390
|
+
if (matches.length === 0) {
|
|
391
|
+
args.warnings.push({
|
|
392
|
+
kind: 'unresolved-link',
|
|
393
|
+
message: `cannot resolve {@link ${args.target}} in ${args.currentSourceFile}`,
|
|
394
|
+
sourceFile: args.currentSourceFile
|
|
395
|
+
});
|
|
396
|
+
return { url: null, display };
|
|
397
|
+
}
|
|
398
|
+
let chosen;
|
|
399
|
+
if (matches.length === 1) {
|
|
400
|
+
chosen = matches[0];
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
// Disambiguate by shared module path prefix: prefer a match whose slug starts with the
|
|
404
|
+
// current page's slug-directory.
|
|
405
|
+
const currentDir = args.currentSlug.includes('/')
|
|
406
|
+
? args.currentSlug.slice(0, args.currentSlug.lastIndexOf('/'))
|
|
407
|
+
: '';
|
|
408
|
+
if (currentDir) {
|
|
409
|
+
const sameModule = matches.filter((entry) => entry.slug.startsWith(`${currentDir}/`) || entry.slug === currentDir);
|
|
410
|
+
if (sameModule.length === 1) {
|
|
411
|
+
chosen = sameModule[0];
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (!chosen) {
|
|
415
|
+
args.warnings.push({
|
|
416
|
+
kind: 'ambiguous-link',
|
|
417
|
+
message: `{@link ${args.target}} in ${args.currentSourceFile} is ambiguous: ${matches.map((entry) => entry.slug).join(', ')}`,
|
|
418
|
+
sourceFile: args.currentSourceFile
|
|
419
|
+
});
|
|
420
|
+
return { url: null, display, comment: `ambiguous link: ${args.target}` };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const url = chosen.slug === args.currentSlug
|
|
424
|
+
? `#${chosen.anchor}`
|
|
425
|
+
: `${relativePagePath(args.currentSlug, chosen.slug)}#${chosen.anchor}`;
|
|
426
|
+
return { url, display };
|
|
427
|
+
}
|
|
428
|
+
function relativePagePath(currentSlug, targetSlug) {
|
|
429
|
+
const currentDir = currentSlug.includes('/') ? currentSlug.slice(0, currentSlug.lastIndexOf('/')) : '';
|
|
430
|
+
const targetPath = `${targetSlug}.md`;
|
|
431
|
+
const rel = path.posix.relative(currentDir, targetPath);
|
|
432
|
+
// path.posix.relative returns 'foo.md' for sibling pages. We want './foo.md' so the link
|
|
433
|
+
// is unambiguous in markdown renderers that distinguish relative-from-here vs. anchor-only.
|
|
434
|
+
if (!rel.startsWith('.')) {
|
|
435
|
+
return `./${rel}`;
|
|
436
|
+
}
|
|
437
|
+
return rel;
|
|
438
|
+
}
|