@ctxr/skill-llm-wiki 1.0.1
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/CHANGELOG.md +134 -0
- package/LICENSE +21 -0
- package/README.md +484 -0
- package/SKILL.md +252 -0
- package/guide/basics/concepts.md +74 -0
- package/guide/basics/index.md +45 -0
- package/guide/basics/schema.md +140 -0
- package/guide/cli.md +256 -0
- package/guide/correctness/index.md +45 -0
- package/guide/correctness/invariants.md +89 -0
- package/guide/correctness/safety.md +96 -0
- package/guide/history/diff.md +110 -0
- package/guide/history/hidden-git.md +130 -0
- package/guide/history/index.md +52 -0
- package/guide/history/remote-sync.md +113 -0
- package/guide/index.md +134 -0
- package/guide/isolation/coexistence.md +134 -0
- package/guide/isolation/index.md +44 -0
- package/guide/isolation/scale.md +251 -0
- package/guide/layout/in-place-mode.md +97 -0
- package/guide/layout/index.md +53 -0
- package/guide/layout/layout-contract.md +131 -0
- package/guide/layout/layout-modes.md +115 -0
- package/guide/operations/index.md +76 -0
- package/guide/operations/ingest/build.md +75 -0
- package/guide/operations/ingest/extend.md +61 -0
- package/guide/operations/ingest/index.md +54 -0
- package/guide/operations/ingest/join.md +65 -0
- package/guide/operations/maintain/fix.md +66 -0
- package/guide/operations/maintain/index.md +47 -0
- package/guide/operations/maintain/rebuild.md +86 -0
- package/guide/operations/validate.md +48 -0
- package/guide/substrate/index.md +47 -0
- package/guide/substrate/operators.md +96 -0
- package/guide/substrate/tiered-ai.md +363 -0
- package/guide/ux/index.md +44 -0
- package/guide/ux/preflight.md +150 -0
- package/guide/ux/user-intent.md +135 -0
- package/package.json +55 -0
- package/scripts/cli.mjs +893 -0
- package/scripts/commands/remote.mjs +93 -0
- package/scripts/commands/review.mjs +253 -0
- package/scripts/commands/sync.mjs +84 -0
- package/scripts/lib/chunk.mjs +421 -0
- package/scripts/lib/cluster-detect.mjs +516 -0
- package/scripts/lib/decision-log.mjs +343 -0
- package/scripts/lib/draft.mjs +158 -0
- package/scripts/lib/embeddings.mjs +366 -0
- package/scripts/lib/frontmatter.mjs +497 -0
- package/scripts/lib/git-commands.mjs +155 -0
- package/scripts/lib/git.mjs +486 -0
- package/scripts/lib/gitignore.mjs +62 -0
- package/scripts/lib/history.mjs +331 -0
- package/scripts/lib/indices.mjs +510 -0
- package/scripts/lib/ingest.mjs +258 -0
- package/scripts/lib/intent.mjs +713 -0
- package/scripts/lib/interactive.mjs +99 -0
- package/scripts/lib/migrate.mjs +126 -0
- package/scripts/lib/nest-applier.mjs +260 -0
- package/scripts/lib/operators.mjs +1365 -0
- package/scripts/lib/orchestrator.mjs +718 -0
- package/scripts/lib/paths.mjs +197 -0
- package/scripts/lib/preflight.mjs +213 -0
- package/scripts/lib/provenance.mjs +672 -0
- package/scripts/lib/quality-metric.mjs +269 -0
- package/scripts/lib/query-fixture.mjs +71 -0
- package/scripts/lib/rollback.mjs +95 -0
- package/scripts/lib/shape-check.mjs +172 -0
- package/scripts/lib/similarity-cache.mjs +126 -0
- package/scripts/lib/similarity.mjs +230 -0
- package/scripts/lib/snapshot.mjs +54 -0
- package/scripts/lib/source-frontmatter.mjs +85 -0
- package/scripts/lib/tier2-protocol.mjs +470 -0
- package/scripts/lib/tiered.mjs +453 -0
- package/scripts/lib/validate.mjs +362 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
// index.md generation and parsing.
|
|
2
|
+
//
|
|
3
|
+
// For every directory in a wiki that contains entries, a single `index.md`
|
|
4
|
+
// holds:
|
|
5
|
+
// - frontmatter with machine routing metadata (derived + authored fields)
|
|
6
|
+
// - body with auto-generated navigation + preserved authored orientation
|
|
7
|
+
//
|
|
8
|
+
// The hook rebuilds indices by: reading the existing index.md to preserve
|
|
9
|
+
// authored fields, aggregating children's frontmatter to recompute derived
|
|
10
|
+
// fields, rendering a deterministic body, writing back atomically.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
14
|
+
import { parseFrontmatter, renderFrontmatter } from "./frontmatter.mjs";
|
|
15
|
+
import { WIKI_GENERATOR_MARKER } from "./paths.mjs";
|
|
16
|
+
import { readFrontmatterStreaming } from "./chunk.mjs";
|
|
17
|
+
|
|
18
|
+
const AUTO_BEGIN = "<!-- BEGIN AUTO-GENERATED NAVIGATION -->";
|
|
19
|
+
const AUTO_END = "<!-- END AUTO-GENERATED NAVIGATION -->";
|
|
20
|
+
const AUTHORED_BEGIN = "<!-- BEGIN AUTHORED ORIENTATION -->";
|
|
21
|
+
const AUTHORED_END = "<!-- END AUTHORED ORIENTATION -->";
|
|
22
|
+
|
|
23
|
+
// Fields the user or init routine authored that must survive rebuilds.
|
|
24
|
+
const AUTHORED_FIELDS = [
|
|
25
|
+
"id",
|
|
26
|
+
"type",
|
|
27
|
+
"depth_role",
|
|
28
|
+
"focus",
|
|
29
|
+
"parents",
|
|
30
|
+
"activation_defaults",
|
|
31
|
+
"orientation",
|
|
32
|
+
"rebuild_needed",
|
|
33
|
+
"rebuild_reasons",
|
|
34
|
+
"rebuild_command",
|
|
35
|
+
"sources",
|
|
36
|
+
"source_wikis",
|
|
37
|
+
"tags",
|
|
38
|
+
"domains",
|
|
39
|
+
"generator",
|
|
40
|
+
// Hosted-mode markers — set on the root index when the wiki is governed
|
|
41
|
+
// by a layout contract. Must survive rebuilds so `isWikiRoot` and the
|
|
42
|
+
// hosted-mode operation paths keep recognising the target after every
|
|
43
|
+
// regeneration.
|
|
44
|
+
"mode",
|
|
45
|
+
"layout_contract_path",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function readIndex(dirPath) {
|
|
49
|
+
const p = join(dirPath, "index.md");
|
|
50
|
+
if (!existsSync(p)) return null;
|
|
51
|
+
const raw = readFileSync(p, "utf8");
|
|
52
|
+
return parseFrontmatter(raw, p);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Walk a directory and return a list of child entries (leaves) and child
|
|
56
|
+
// index directories (subcategories). Leaves are any .md file that is not
|
|
57
|
+
// the directory's own index.md and has frontmatter.
|
|
58
|
+
//
|
|
59
|
+
// Scale note: this function reads ONLY each leaf's frontmatter bytes via
|
|
60
|
+
// `readFrontmatterStreaming`. It never pulls the body into memory, so a
|
|
61
|
+
// directory with 10,000 × 50 KB leaves costs ~40 MB of frontmatter (at
|
|
62
|
+
// the 4 KB-per-leaf typical case) instead of 500 MB of full files. This
|
|
63
|
+
// is what makes `rebuildAllIndices` scalable at Phase 5 targets.
|
|
64
|
+
export function listChildren(dirPath) {
|
|
65
|
+
const out = { leaves: [], subdirs: [] };
|
|
66
|
+
if (!existsSync(dirPath)) return out;
|
|
67
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
68
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
if (e.name.startsWith(".")) continue;
|
|
71
|
+
const full = join(dirPath, e.name);
|
|
72
|
+
if (e.isDirectory()) {
|
|
73
|
+
if (existsSync(join(full, "index.md"))) out.subdirs.push(full);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!e.isFile()) continue;
|
|
77
|
+
if (!e.name.endsWith(".md")) continue;
|
|
78
|
+
if (e.name === "index.md") continue;
|
|
79
|
+
try {
|
|
80
|
+
const captured = readFrontmatterStreaming(full);
|
|
81
|
+
if (captured === null) continue; // no frontmatter — skip silently
|
|
82
|
+
const { data } = parseFrontmatter(captured.frontmatterText, full);
|
|
83
|
+
if (data && typeof data === "object" && data.id) {
|
|
84
|
+
out.leaves.push({ path: full, data });
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Skip malformed — `runShapeCheck` / `rebuildIndex` both tolerate
|
|
88
|
+
// leaves whose frontmatter fails to parse. The strict validator
|
|
89
|
+
// catches them separately.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Rebuild the index.md for a single directory. Idempotent. Never modifies
|
|
96
|
+
// children. Preserves authored content in the existing index.md.
|
|
97
|
+
//
|
|
98
|
+
// depth is computed from the directory's position relative to the wiki
|
|
99
|
+
// root. If `preloadedChildren` is provided it is used instead of calling
|
|
100
|
+
// `listChildren` again — `rebuildAllIndices` takes advantage of this to
|
|
101
|
+
// avoid reading every leaf's frontmatter twice per rebuild (once during
|
|
102
|
+
// the walk that discovers directories, once during per-directory index
|
|
103
|
+
// regeneration). At 10k leaves the savings are meaningful.
|
|
104
|
+
//
|
|
105
|
+
// `options.indexInput`, when provided, carries the AUTHORED-index
|
|
106
|
+
// hints (shared_covers / orientation / focus) that the ingest phase
|
|
107
|
+
// recovered from a source file named `index.md` or carrying
|
|
108
|
+
// `type: index`. Those fields are forwarded into the synthesised
|
|
109
|
+
// target index verbatim — they take priority over the heuristic
|
|
110
|
+
// fallbacks, so a hand-tuned guide's routing metadata survives a
|
|
111
|
+
// rebuild cleanly.
|
|
112
|
+
//
|
|
113
|
+
// NOTE on `activation_defaults`: this field is no longer
|
|
114
|
+
// auto-aggregated upward from members (that was the old literal-
|
|
115
|
+
// routing substrate). The field is still recognised in
|
|
116
|
+
// AUTHORED_FIELDS so a hand-authored source index that carries an
|
|
117
|
+
// `activation_defaults` block round-trips without data loss, but
|
|
118
|
+
// the rebuild pass does NOT synthesise one from child signals.
|
|
119
|
+
// Routing is semantic — see SKILL.md "Routing into guide.wiki/".
|
|
120
|
+
export function rebuildIndex(
|
|
121
|
+
dirPath,
|
|
122
|
+
wikiRoot,
|
|
123
|
+
preloadedChildren = null,
|
|
124
|
+
options = {},
|
|
125
|
+
) {
|
|
126
|
+
const { indexInput = null } = options;
|
|
127
|
+
const p = join(dirPath, "index.md");
|
|
128
|
+
const existing = existsSync(p) ? parseFrontmatter(readFileSync(p, "utf8"), p) : null;
|
|
129
|
+
const { leaves, subdirs } = preloadedChildren ?? listChildren(dirPath);
|
|
130
|
+
|
|
131
|
+
const depth = computeDepth(dirPath, wikiRoot);
|
|
132
|
+
const isRoot = dirPath === wikiRoot;
|
|
133
|
+
|
|
134
|
+
// Start with existing authored fields (survive rebuild).
|
|
135
|
+
const data = {};
|
|
136
|
+
if (existing?.data) {
|
|
137
|
+
for (const k of AUTHORED_FIELDS) {
|
|
138
|
+
if (existing.data[k] !== undefined) data[k] = existing.data[k];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Forward hints from an authored source `index.md` (if the build
|
|
143
|
+
// pipeline stashed one for this directory). These take priority
|
|
144
|
+
// over any stub values planted by `bootstrapIndexStubs`, EXCEPT
|
|
145
|
+
// for identity fields whose correct value is structurally
|
|
146
|
+
// determined by the target-tree position (`id`, `type`,
|
|
147
|
+
// `depth_role`, `depth`, `parents`). Those get re-derived below.
|
|
148
|
+
const authoredIndex = indexInput?.authored_frontmatter || null;
|
|
149
|
+
const structuralFields = new Set([
|
|
150
|
+
"id",
|
|
151
|
+
"type",
|
|
152
|
+
"depth_role",
|
|
153
|
+
"depth",
|
|
154
|
+
"parents",
|
|
155
|
+
"generator",
|
|
156
|
+
"mode",
|
|
157
|
+
"layout_contract_path",
|
|
158
|
+
// Rebuild-status fields are managed by the orchestrator / rebuild
|
|
159
|
+
// path. Forwarding them from a source index would leak absolute
|
|
160
|
+
// paths into the target and defeat build determinism.
|
|
161
|
+
"rebuild_needed",
|
|
162
|
+
"rebuild_reasons",
|
|
163
|
+
"rebuild_command",
|
|
164
|
+
]);
|
|
165
|
+
if (authoredIndex) {
|
|
166
|
+
for (const k of AUTHORED_FIELDS) {
|
|
167
|
+
if (structuralFields.has(k)) continue;
|
|
168
|
+
if (authoredIndex[k] !== undefined) data[k] = authoredIndex[k];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Ensure required identity fields.
|
|
173
|
+
data.id = data.id ?? (isRoot ? basename(wikiRoot) : basename(dirPath));
|
|
174
|
+
data.type = "index";
|
|
175
|
+
// Depth-role mapping per schema: root is "category", everything deeper is
|
|
176
|
+
// "subcategory". (Early drafts mislabeled depth-1 as "category"; fixed.)
|
|
177
|
+
data.depth_role = depth === 0 ? "category" : "subcategory";
|
|
178
|
+
if (isRoot) data.depth_role = "category";
|
|
179
|
+
data.depth = depth;
|
|
180
|
+
|
|
181
|
+
if (!data.focus) {
|
|
182
|
+
data.focus = `subtree under ${data.id}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!data.parents) {
|
|
186
|
+
if (isRoot) {
|
|
187
|
+
data.parents = [];
|
|
188
|
+
} else {
|
|
189
|
+
data.parents = [relative(dirPath, dirname(dirPath)) + "/index.md"];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Derived: entries (aggregate child frontmatter).
|
|
194
|
+
//
|
|
195
|
+
// Each entry carries the minimum a semantic router needs to decide
|
|
196
|
+
// whether to descend into or load the child: `id`, `file`, `type`,
|
|
197
|
+
// `focus`, and any authored `tags`. Claude reads the parent's
|
|
198
|
+
// `entries[]`, matches on `focus` (and the parent's authored
|
|
199
|
+
// `shared_covers`), and loads only the matches. It does NOT match
|
|
200
|
+
// on literal keyword/tag lists lifted from the child — that was
|
|
201
|
+
// the old deterministic-router substrate and is gone. Per-leaf
|
|
202
|
+
// `activation` blocks are still preserved IN the leaf file as
|
|
203
|
+
// optional semantic hints the router may consult AFTER opening
|
|
204
|
+
// the leaf; they are not copied up into the parent entries[]
|
|
205
|
+
// record.
|
|
206
|
+
const entries = [];
|
|
207
|
+
for (const leaf of leaves) {
|
|
208
|
+
const record = {
|
|
209
|
+
id: leaf.data.id,
|
|
210
|
+
file: relative(dirPath, leaf.path),
|
|
211
|
+
type: leaf.data.type ?? "primary",
|
|
212
|
+
focus: leaf.data.focus ?? "",
|
|
213
|
+
};
|
|
214
|
+
if (leaf.data.tags) record.tags = leaf.data.tags;
|
|
215
|
+
if (leaf.data.overlay_targets) record.overlay_targets = leaf.data.overlay_targets;
|
|
216
|
+
entries.push(record);
|
|
217
|
+
}
|
|
218
|
+
for (const sub of subdirs) {
|
|
219
|
+
const subIndex = readIndex(sub);
|
|
220
|
+
if (!subIndex) continue;
|
|
221
|
+
const record = {
|
|
222
|
+
id: subIndex.data.id,
|
|
223
|
+
file: relative(dirPath, join(sub, "index.md")),
|
|
224
|
+
type: "index",
|
|
225
|
+
focus: subIndex.data.focus ?? "",
|
|
226
|
+
};
|
|
227
|
+
if (subIndex.data.tags) record.tags = subIndex.data.tags;
|
|
228
|
+
entries.push(record);
|
|
229
|
+
}
|
|
230
|
+
data.entries = entries;
|
|
231
|
+
|
|
232
|
+
// Semantic-routing substrate: `activation_defaults` is NOT
|
|
233
|
+
// auto-aggregated anymore. Claude decides descent from `focus`
|
|
234
|
+
// and `shared_covers` semantically. If the user hand-authored an
|
|
235
|
+
// `activation_defaults` block (forwarded via AUTHORED_FIELDS or
|
|
236
|
+
// via indexInput), it survives here as a free-form authored hint
|
|
237
|
+
// but we no longer synthesise or merge one from child signals.
|
|
238
|
+
// See the doc comment on `rebuildIndex` above.
|
|
239
|
+
|
|
240
|
+
// Derived: children (subdirectory index pointers)
|
|
241
|
+
data.children = subdirs.map((s) => relative(dirPath, join(s, "index.md")));
|
|
242
|
+
|
|
243
|
+
// Derived: shared_covers — intersection of leaf covers when present.
|
|
244
|
+
// Also unioned with any authored shared_covers the user put in the
|
|
245
|
+
// existing index.md AND any shared_covers forwarded from an
|
|
246
|
+
// authored source index input. (Subcategory intersections are
|
|
247
|
+
// handled when their own indices rebuild.)
|
|
248
|
+
const computedShared = intersectCovers(leaves.map((l) => l.data.covers ?? []));
|
|
249
|
+
const authoredShared = existing?.data?.shared_covers ?? [];
|
|
250
|
+
const sourceShared =
|
|
251
|
+
authoredIndex && Array.isArray(authoredIndex.shared_covers)
|
|
252
|
+
? authoredIndex.shared_covers
|
|
253
|
+
: [];
|
|
254
|
+
data.shared_covers = uniqueJoin(
|
|
255
|
+
uniqueJoin(computedShared, authoredShared),
|
|
256
|
+
sourceShared,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// Root gets the rebuild-surfacing fields and the generator marker.
|
|
260
|
+
// The marker is what the hook uses to positively identify this folder
|
|
261
|
+
// as a skill-llm-wiki-managed wiki (see paths.mjs::isWikiRoot). Without
|
|
262
|
+
// the marker, the hook treats the folder as unrelated and stays silent.
|
|
263
|
+
if (isRoot) {
|
|
264
|
+
if (data.rebuild_needed === undefined) data.rebuild_needed = false;
|
|
265
|
+
if (!data.rebuild_reasons) data.rebuild_reasons = [];
|
|
266
|
+
// The rebuild_command field uses a placeholder path instead of
|
|
267
|
+
// the absolute wikiRoot so that byte-identical wiki content
|
|
268
|
+
// produces a byte-identical tracked file across machines and
|
|
269
|
+
// install locations. The user substitutes the placeholder with
|
|
270
|
+
// their actual wiki path when they run the command. This is the
|
|
271
|
+
// determinism fix from the Phase 8 sweep finding that two
|
|
272
|
+
// identical builds into different tmp dirs were producing
|
|
273
|
+
// different HEAD tree SHAs.
|
|
274
|
+
if (!data.rebuild_command) {
|
|
275
|
+
data.rebuild_command = "skill-llm-wiki rebuild <wiki> --plan";
|
|
276
|
+
}
|
|
277
|
+
data.generator = WIKI_GENERATOR_MARKER;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Pull an authored orientation block out of the source index body,
|
|
281
|
+
// if one was forwarded. The source may carry either literal
|
|
282
|
+
// `<!-- BEGIN AUTHORED ORIENTATION -->` markers (e.g. when re-
|
|
283
|
+
// building an already-built wiki) or a plain prose preface. We
|
|
284
|
+
// only lift the marker-delimited block here — the plain-prose case
|
|
285
|
+
// is covered by the `orientation:` YAML field, which we already
|
|
286
|
+
// forwarded into `data` via AUTHORED_FIELDS.
|
|
287
|
+
let sourceAuthoredOrientation = null;
|
|
288
|
+
if (indexInput?.body) {
|
|
289
|
+
sourceAuthoredOrientation = extractAuthoredBlock(indexInput.body);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Deterministic key order
|
|
293
|
+
const ordered = orderKeys(data, isRoot);
|
|
294
|
+
const body = renderBody(
|
|
295
|
+
ordered,
|
|
296
|
+
existing,
|
|
297
|
+
sourceAuthoredOrientation,
|
|
298
|
+
);
|
|
299
|
+
atomicWriteFile(p, renderFrontmatter(ordered, body));
|
|
300
|
+
return { path: p, entries: entries.length, children: subdirs.length };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function rebuildAllIndices(wikiRoot, options = {}) {
|
|
304
|
+
// Rebuild bottom-up so parent `shared_covers[]` computations see fresh
|
|
305
|
+
// child frontmatter. The wiki root is ALWAYS included even when it
|
|
306
|
+
// has no leaves of its own, so `isWikiRoot` can find the generator
|
|
307
|
+
// marker in its regenerated frontmatter.
|
|
308
|
+
//
|
|
309
|
+
// Scale: each directory's `listChildren` result is cached during the
|
|
310
|
+
// walk and threaded into `rebuildIndex` so every leaf's frontmatter is
|
|
311
|
+
// read exactly once per rebuild. The naive implementation walked twice
|
|
312
|
+
// (once to collect directories, once during per-directory aggregation),
|
|
313
|
+
// which doubled I/O for no reason.
|
|
314
|
+
//
|
|
315
|
+
// `options.indexInputs`: optional map { dirRelPath → authoredIndex }
|
|
316
|
+
// produced by the orchestrator's ingest phase when the source tree
|
|
317
|
+
// carried authored `index.md` files. Each entry forwards its
|
|
318
|
+
// frontmatter (orientation / shared_covers / activation_defaults /
|
|
319
|
+
// focus / tags / domains …) into the corresponding target index.
|
|
320
|
+
// Keys are POSIX-normalised relative paths from the wiki root
|
|
321
|
+
// (`""` for the root, `"operations"` for `operations/index.md`).
|
|
322
|
+
const { indexInputs = {} } = options;
|
|
323
|
+
const cache = new Map(); // dirPath → { leaves, subdirs }
|
|
324
|
+
const rootChildren = listChildren(wikiRoot);
|
|
325
|
+
cache.set(wikiRoot, rootChildren);
|
|
326
|
+
const dirs = [wikiRoot];
|
|
327
|
+
collectDirs(wikiRoot, wikiRoot, dirs, cache);
|
|
328
|
+
// Sort by depth descending so deepest directories rebuild first.
|
|
329
|
+
dirs.sort((a, b) => depthOf(b, wikiRoot) - depthOf(a, wikiRoot));
|
|
330
|
+
const out = [];
|
|
331
|
+
for (const d of dirs) {
|
|
332
|
+
const rel = d === wikiRoot ? "" : relative(wikiRoot, d).split("\\").join("/");
|
|
333
|
+
const indexInput = indexInputs[rel] || null;
|
|
334
|
+
out.push(
|
|
335
|
+
rebuildIndex(d, wikiRoot, cache.get(d) ?? null, { indexInput }),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function collectDirs(dirPath, wikiRoot, acc, cache) {
|
|
342
|
+
if (!existsSync(dirPath)) return;
|
|
343
|
+
try {
|
|
344
|
+
// Reuse the cached result when the caller (rebuildAllIndices)
|
|
345
|
+
// has already paid for it; otherwise compute and stash it so
|
|
346
|
+
// the rebuild pass can reuse.
|
|
347
|
+
let children = cache.get(dirPath);
|
|
348
|
+
if (!children) {
|
|
349
|
+
children = listChildren(dirPath);
|
|
350
|
+
cache.set(dirPath, children);
|
|
351
|
+
}
|
|
352
|
+
const { leaves, subdirs } = children;
|
|
353
|
+
// Include every non-root directory that carries at least one leaf
|
|
354
|
+
// or indexed subdir. The wiki root was already added by the
|
|
355
|
+
// caller; we skip adding it again to avoid duplicates.
|
|
356
|
+
if (dirPath !== wikiRoot && (leaves.length > 0 || subdirs.length > 0)) {
|
|
357
|
+
acc.push(dirPath);
|
|
358
|
+
}
|
|
359
|
+
for (const s of subdirs) collectDirs(s, wikiRoot, acc, cache);
|
|
360
|
+
} catch {
|
|
361
|
+
/* skip */
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function depthOf(dirPath, wikiRoot) {
|
|
366
|
+
if (dirPath === wikiRoot) return 0;
|
|
367
|
+
return relative(wikiRoot, dirPath).split("/").filter(Boolean).length;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function computeDepth(dirPath, wikiRoot) {
|
|
371
|
+
return depthOf(dirPath, wikiRoot);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function intersectCovers(lists) {
|
|
375
|
+
if (lists.length === 0) return [];
|
|
376
|
+
if (lists.length === 1) return [];
|
|
377
|
+
const out = [];
|
|
378
|
+
for (const item of lists[0]) {
|
|
379
|
+
if (lists.every((l) => l.includes(item))) out.push(item);
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function uniqueJoin(a, b) {
|
|
385
|
+
const seen = new Set();
|
|
386
|
+
const out = [];
|
|
387
|
+
for (const item of [...a, ...b]) {
|
|
388
|
+
if (!seen.has(item)) {
|
|
389
|
+
seen.add(item);
|
|
390
|
+
out.push(item);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function orderKeys(data, isRoot) {
|
|
397
|
+
// Canonical ordering for deterministic output.
|
|
398
|
+
const baseOrder = [
|
|
399
|
+
"id",
|
|
400
|
+
"type",
|
|
401
|
+
"depth_role",
|
|
402
|
+
"depth",
|
|
403
|
+
"focus",
|
|
404
|
+
"parents",
|
|
405
|
+
"tags",
|
|
406
|
+
"domains",
|
|
407
|
+
"activation_defaults",
|
|
408
|
+
"shared_covers",
|
|
409
|
+
"sources",
|
|
410
|
+
"source_wikis",
|
|
411
|
+
"orientation",
|
|
412
|
+
"generator",
|
|
413
|
+
"mode",
|
|
414
|
+
"layout_contract_path",
|
|
415
|
+
"rebuild_needed",
|
|
416
|
+
"rebuild_reasons",
|
|
417
|
+
"rebuild_command",
|
|
418
|
+
"entries",
|
|
419
|
+
"children",
|
|
420
|
+
];
|
|
421
|
+
const out = {};
|
|
422
|
+
for (const k of baseOrder) {
|
|
423
|
+
if (data[k] !== undefined) out[k] = data[k];
|
|
424
|
+
}
|
|
425
|
+
// Any extra keys appended at the end preserve author additions.
|
|
426
|
+
for (const k of Object.keys(data)) {
|
|
427
|
+
if (!(k in out)) out[k] = data[k];
|
|
428
|
+
}
|
|
429
|
+
if (!isRoot) {
|
|
430
|
+
delete out.rebuild_needed;
|
|
431
|
+
delete out.rebuild_reasons;
|
|
432
|
+
delete out.rebuild_command;
|
|
433
|
+
}
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function renderBody(data, existing, sourceAuthoredOrientation) {
|
|
438
|
+
const lines = [];
|
|
439
|
+
lines.push("");
|
|
440
|
+
lines.push(AUTO_BEGIN);
|
|
441
|
+
lines.push("");
|
|
442
|
+
lines.push(`# ${titleize(data.id)}`);
|
|
443
|
+
lines.push("");
|
|
444
|
+
if (data.focus) {
|
|
445
|
+
lines.push(`**Focus:** ${data.focus}`);
|
|
446
|
+
lines.push("");
|
|
447
|
+
}
|
|
448
|
+
if (data.shared_covers && data.shared_covers.length > 0) {
|
|
449
|
+
lines.push("**Shared across all children:**");
|
|
450
|
+
lines.push("");
|
|
451
|
+
for (const c of data.shared_covers) lines.push(`- ${c}`);
|
|
452
|
+
lines.push("");
|
|
453
|
+
}
|
|
454
|
+
if (data.entries && data.entries.length > 0) {
|
|
455
|
+
lines.push("## Children");
|
|
456
|
+
lines.push("");
|
|
457
|
+
lines.push("| File | Type | Focus |");
|
|
458
|
+
lines.push("|------|------|-------|");
|
|
459
|
+
for (const e of data.entries) {
|
|
460
|
+
const typeTag = e.type === "index" ? "📁 index" : e.type === "overlay" ? "🔗 overlay" : "📄 primary";
|
|
461
|
+
lines.push(`| [${e.file}](${e.file}) | ${typeTag} | ${e.focus || ""} |`);
|
|
462
|
+
}
|
|
463
|
+
lines.push("");
|
|
464
|
+
} else {
|
|
465
|
+
lines.push("_No children yet._");
|
|
466
|
+
lines.push("");
|
|
467
|
+
}
|
|
468
|
+
lines.push(AUTO_END);
|
|
469
|
+
lines.push("");
|
|
470
|
+
|
|
471
|
+
// Preserve authored orientation block. Priority:
|
|
472
|
+
// 1. existing target index.md body (`<!-- BEGIN AUTHORED ORIENTATION -->`)
|
|
473
|
+
// 2. authored source index body block (forwarded via indexInput)
|
|
474
|
+
// 3. YAML `orientation:` field from the rebuilt frontmatter
|
|
475
|
+
const authored = extractAuthoredBlock(existing?.body ?? "");
|
|
476
|
+
const sourceAuthored = sourceAuthoredOrientation || null;
|
|
477
|
+
lines.push(AUTHORED_BEGIN);
|
|
478
|
+
if (authored) {
|
|
479
|
+
lines.push(authored);
|
|
480
|
+
} else if (sourceAuthored) {
|
|
481
|
+
lines.push(sourceAuthored);
|
|
482
|
+
} else if (data.orientation) {
|
|
483
|
+
lines.push(data.orientation);
|
|
484
|
+
}
|
|
485
|
+
lines.push(AUTHORED_END);
|
|
486
|
+
lines.push("");
|
|
487
|
+
|
|
488
|
+
return lines.join("\n");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function extractAuthoredBlock(body) {
|
|
492
|
+
const start = body.indexOf(AUTHORED_BEGIN);
|
|
493
|
+
const end = body.indexOf(AUTHORED_END);
|
|
494
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
495
|
+
return body.slice(start + AUTHORED_BEGIN.length, end).trim();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function titleize(id) {
|
|
499
|
+
return id
|
|
500
|
+
.split("-")
|
|
501
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
502
|
+
.join(" ");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function atomicWriteFile(targetPath, content) {
|
|
506
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
507
|
+
const tmp = targetPath + ".tmp";
|
|
508
|
+
writeFileSync(tmp, content, "utf8");
|
|
509
|
+
renameSync(tmp, targetPath);
|
|
510
|
+
}
|