diffwiki-core 0.14.0 → 0.15.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/dist/index.js CHANGED
@@ -1,10 +1,14 @@
1
1
  import {
2
2
  rankDocs,
3
3
  tokenize
4
- } from "./chunk-FTUF6IXS.js";
4
+ } from "./chunk-TK2373AM.js";
5
5
  import {
6
- runProcess
7
- } from "./chunk-EEETAETA.js";
6
+ parseArticle,
7
+ parseFrontmatter,
8
+ runProcess,
9
+ serializeArticle,
10
+ stripFrontmatter
11
+ } from "./chunk-6MW6KSS4.js";
8
12
  import {
9
13
  ArticleNotFoundError,
10
14
  CollectionAlreadyAddedError,
@@ -43,28 +47,72 @@ import {
43
47
  removeCollection,
44
48
  resolveHome,
45
49
  resolveLogLevel,
50
+ templatesDir,
46
51
  unregisterCollection,
47
52
  upsertPlugin,
48
53
  writePluginRegistry,
49
54
  writeRegistry
50
- } from "./chunk-7V2DIQNL.js";
55
+ } from "./chunk-TXM4OGBA.js";
51
56
 
52
- // src/config.ts
57
+ // src/templates.ts
53
58
  import fs from "fs/promises";
59
+ import path from "path";
60
+ async function seedTemplates(srcDir) {
61
+ const dir = templatesDir();
62
+ await fs.mkdir(dir, { recursive: true });
63
+ const files = (await fs.readdir(srcDir)).filter((f) => f.endsWith(".md")).sort();
64
+ const seeded = [];
65
+ const skipped = [];
66
+ for (const file of files) {
67
+ const dest = path.join(dir, file);
68
+ try {
69
+ await fs.access(dest);
70
+ skipped.push(file);
71
+ continue;
72
+ } catch {
73
+ }
74
+ await fs.copyFile(path.join(srcDir, file), dest);
75
+ seeded.push(file);
76
+ }
77
+ return { seeded, skipped, dir };
78
+ }
79
+ async function listTemplateShapes(srcDir) {
80
+ const files = await fs.readdir(srcDir);
81
+ return files.filter((f) => f.endsWith("-template.md")).map((f) => f.replace(/-template\.md$/, "")).sort();
82
+ }
83
+ async function listTemplates(srcDir) {
84
+ const files = (await fs.readdir(srcDir)).filter((f) => f.endsWith("-template.md")).sort();
85
+ const out = [];
86
+ for (const file of files) {
87
+ let data = {};
88
+ try {
89
+ ({ data } = parseFrontmatter(await fs.readFile(path.join(srcDir, file), "utf8")));
90
+ } catch {
91
+ }
92
+ const shape = typeof data.shape === "string" && data.shape ? data.shape : file.replace(/-template\.md$/, "");
93
+ const purpose = typeof data.purpose === "string" ? data.purpose : "";
94
+ const budget = Array.isArray(data.budget) && data.budget.length === 2 ? [Number(data.budget[0]), Number(data.budget[1])] : void 0;
95
+ out.push({ shape, purpose, budget, file });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ // src/config.ts
101
+ import fs2 from "fs/promises";
54
102
  function isMissing(err) {
55
103
  return err?.code === "ENOENT";
56
104
  }
57
105
  async function readConfig() {
58
106
  try {
59
- return JSON.parse(await fs.readFile(configPath(), "utf8"));
107
+ return JSON.parse(await fs2.readFile(configPath(), "utf8"));
60
108
  } catch (err) {
61
109
  if (isMissing(err)) return {};
62
110
  throw err;
63
111
  }
64
112
  }
65
113
  async function writeConfig(config) {
66
- await fs.mkdir(resolveHome(), { recursive: true });
67
- await fs.writeFile(configPath(), `${JSON.stringify(config, null, 2)}
114
+ await fs2.mkdir(resolveHome(), { recursive: true });
115
+ await fs2.writeFile(configPath(), `${JSON.stringify(config, null, 2)}
68
116
  `, "utf8");
69
117
  }
70
118
  async function setConfigValue(key, value) {
@@ -106,77 +154,12 @@ function slugifyPath(input) {
106
154
  return segs.length > 0 ? segs.join("/") : "untitled";
107
155
  }
108
156
 
109
- // src/frontmatter.ts
110
- import matter from "gray-matter";
111
- import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
112
- var YAML_ENGINE = {
113
- parse: (input) => {
114
- const data = parseYaml(input);
115
- return data && typeof data === "object" ? data : {};
116
- },
117
- stringify: (data) => stringifyYaml(data)
118
- };
119
- var MATTER_OPTS = { engines: { yaml: YAML_ENGINE } };
120
- function coerceAudience(value) {
121
- return value === "private" || value === "system" ? value : "public";
122
- }
123
- function coerceStatus(value) {
124
- return value === "draft" || value === "deprecated" ? value : "published";
125
- }
126
- function parseFallback(raw) {
127
- const fenceRe = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*\n/;
128
- const fenceMatch = fenceRe.exec(raw);
129
- let title = "";
130
- let audience = "public";
131
- let status = "published";
132
- let body;
133
- const clean = (s) => s.trim().replace(/^["']|["']$/g, "");
134
- if (fenceMatch) {
135
- const inner = fenceMatch[1];
136
- const titleMatch = /^title:\s*(.+)$/m.exec(inner);
137
- if (titleMatch) {
138
- title = clean(titleMatch[1]);
139
- }
140
- const audienceMatch = /^audience:\s*(.+)$/m.exec(inner);
141
- if (audienceMatch) {
142
- audience = coerceAudience(clean(audienceMatch[1]));
143
- }
144
- const statusMatch = /^status:\s*(.+)$/m.exec(inner);
145
- if (statusMatch) {
146
- status = coerceStatus(clean(statusMatch[1]));
147
- }
148
- body = raw.slice(fenceMatch[0].length);
149
- } else {
150
- body = raw;
151
- }
152
- return { title, tags: [], audience, status, body: body.replace(/^\n+/, "") };
153
- }
154
- function parseArticle(raw) {
155
- try {
156
- const { data, content } = matter(raw, MATTER_OPTS);
157
- const title = typeof data.title === "string" ? data.title : "";
158
- const tags = Array.isArray(data.tags) ? data.tags.map((t) => String(t)) : [];
159
- const created = typeof data.created === "string" ? data.created : void 0;
160
- const audience = coerceAudience(data.audience);
161
- const status = coerceStatus(data.status);
162
- return { title, tags, created, audience, status, body: content.replace(/^\n+/, "") };
163
- } catch {
164
- return parseFallback(raw);
165
- }
166
- }
167
- function serializeArticle(article) {
168
- const front = { title: article.title, tags: article.tags };
169
- if (article.audience === "private" || article.audience === "system") front.audience = article.audience;
170
- if (article.status === "draft" || article.status === "deprecated") front.status = article.status;
171
- return matter.stringify(article.body, front, MATTER_OPTS);
172
- }
173
-
174
157
  // src/articles.ts
175
- import fs2 from "fs/promises";
176
- import path from "path";
158
+ import fs3 from "fs/promises";
159
+ import path2 from "path";
177
160
  async function fireHook(event, collection, relPath) {
178
161
  try {
179
- const { emitPluginEvent: emitPluginEvent2 } = await import("./events-AVD3G5AX.js");
162
+ const { emitPluginEvent: emitPluginEvent2 } = await import("./events-K3MKWACA.js");
180
163
  await emitPluginEvent2(event, collection, relPath);
181
164
  } catch {
182
165
  }
@@ -218,24 +201,24 @@ function ensureMdPath(file) {
218
201
  async function createArticle(opts) {
219
202
  const { collection, title } = parseAddTarget(opts.target);
220
203
  const { name, dir } = await resolveCollectionDir(collection);
221
- const filePath = path.join(dir, `${slugifyPath(title)}.md`);
204
+ const filePath = path2.join(dir, `${slugifyPath(title)}.md`);
222
205
  const body = opts.body ?? `# ${title}
223
206
  `;
224
- await fs2.mkdir(path.dirname(filePath), { recursive: true });
225
- await fs2.writeFile(
207
+ await fs3.mkdir(path2.dirname(filePath), { recursive: true });
208
+ await fs3.writeFile(
226
209
  filePath,
227
210
  serializeArticle({ title, tags: opts.tags, body, audience: opts.audience, status: opts.status }),
228
211
  "utf8"
229
212
  );
230
- void fireHook("article-created", name, path.relative(dir, filePath));
213
+ void fireHook("article-created", name, path2.relative(dir, filePath));
231
214
  return { title, tags: opts.tags, path: filePath, collection: name };
232
215
  }
233
216
  async function loadArticle(target) {
234
217
  const { collection, file } = parsePathTarget(target);
235
218
  const { dir } = await resolveCollectionDir(collection);
236
- const filePath = path.join(dir, ensureMdPath(file));
219
+ const filePath = path2.join(dir, ensureMdPath(file));
237
220
  try {
238
- return { filePath, dir, raw: await fs2.readFile(filePath, "utf8"), collection };
221
+ return { filePath, dir, raw: await fs3.readFile(filePath, "utf8"), collection };
239
222
  } catch (err) {
240
223
  if (err.code === "ENOENT") throw new ArticleNotFoundError(target);
241
224
  throw err;
@@ -244,7 +227,7 @@ async function loadArticle(target) {
244
227
  async function updateArticleBody(target, body) {
245
228
  const { filePath, dir, raw, collection } = await loadArticle(target);
246
229
  const parsed = parseArticle(raw);
247
- await fs2.writeFile(
230
+ await fs3.writeFile(
248
231
  filePath,
249
232
  serializeArticle({
250
233
  title: parsed.title,
@@ -255,14 +238,14 @@ async function updateArticleBody(target, body) {
255
238
  }),
256
239
  "utf8"
257
240
  );
258
- void fireHook("article-updated", collection, path.relative(dir, filePath));
241
+ void fireHook("article-updated", collection, path2.relative(dir, filePath));
259
242
  return { title: parsed.title, tags: parsed.tags, path: filePath, collection };
260
243
  }
261
244
  async function mutateTags(target, fn) {
262
245
  const { filePath, dir, raw, collection } = await loadArticle(target);
263
246
  const parsed = parseArticle(raw);
264
247
  const tags = fn(parsed.tags);
265
- await fs2.writeFile(
248
+ await fs3.writeFile(
266
249
  filePath,
267
250
  serializeArticle({
268
251
  title: parsed.title,
@@ -273,7 +256,7 @@ async function mutateTags(target, fn) {
273
256
  }),
274
257
  "utf8"
275
258
  );
276
- void fireHook("article-updated", collection, path.relative(dir, filePath));
259
+ void fireHook("article-updated", collection, path2.relative(dir, filePath));
277
260
  return { title: parsed.title, tags, path: filePath, collection };
278
261
  }
279
262
  function addTags(target, tags) {
@@ -291,31 +274,31 @@ async function setArticleMeta(target, meta) {
291
274
  const parsed = parseArticle(raw);
292
275
  const audience = meta.audience ?? parsed.audience;
293
276
  const status = meta.status ?? parsed.status;
294
- await fs2.writeFile(
277
+ await fs3.writeFile(
295
278
  filePath,
296
279
  serializeArticle({ title: parsed.title, tags: parsed.tags, audience, status, body: parsed.body }),
297
280
  "utf8"
298
281
  );
299
- void fireHook("article-updated", collection, path.relative(dir, filePath));
282
+ void fireHook("article-updated", collection, path2.relative(dir, filePath));
300
283
  return { title: parsed.title, tags: parsed.tags, path: filePath, collection };
301
284
  }
302
285
  async function removeArticle(target) {
303
286
  const { filePath, dir, collection } = await loadArticle(target);
304
- await fs2.rm(filePath);
305
- void fireHook("article-removed", collection, path.relative(dir, filePath));
287
+ await fs3.rm(filePath);
288
+ void fireHook("article-removed", collection, path2.relative(dir, filePath));
306
289
  return filePath;
307
290
  }
308
291
 
309
292
  // src/repo.ts
310
- import fs4 from "fs/promises";
311
- import path3 from "path";
293
+ import fs5 from "fs/promises";
294
+ import path4 from "path";
312
295
  import { execFile as execFile2 } from "child_process";
313
296
  import { promisify as promisify2 } from "util";
314
- import { parse as parseYaml2, stringify as stringifyYaml2 } from "yaml";
297
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
315
298
 
316
299
  // src/git.ts
317
- import fs3 from "fs/promises";
318
- import path2 from "path";
300
+ import fs4 from "fs/promises";
301
+ import path3 from "path";
319
302
  import { execFile } from "child_process";
320
303
  import { promisify } from "util";
321
304
  var execFileAsync = promisify(execFile);
@@ -340,8 +323,8 @@ async function isLinkedWorktree(cwd) {
340
323
  const gitDir = await git(cwd, ["rev-parse", "--absolute-git-dir"]);
341
324
  const commonDir = await git(cwd, ["rev-parse", "--git-common-dir"]);
342
325
  if (!gitDir || !commonDir) return false;
343
- const commonAbs = path2.isAbsolute(commonDir) ? commonDir : path2.resolve(cwd, commonDir);
344
- return path2.resolve(gitDir) !== path2.resolve(commonAbs);
326
+ const commonAbs = path3.isAbsolute(commonDir) ? commonDir : path3.resolve(cwd, commonDir);
327
+ return path3.resolve(gitDir) !== path3.resolve(commonAbs);
345
328
  }
346
329
  async function repoRemoteName(cwd) {
347
330
  const url = await git(cwd, ["remote", "get-url", "origin"]);
@@ -383,8 +366,8 @@ var timestampCache = /* @__PURE__ */ new Map();
383
366
  async function fileTimestamps(absPath) {
384
367
  const cached2 = timestampCache.get(absPath);
385
368
  if (cached2) return cached2;
386
- const dir = path2.dirname(absPath);
387
- const base = path2.basename(absPath);
369
+ const dir = path3.dirname(absPath);
370
+ const base = path3.basename(absPath);
388
371
  const [addLog, lastLog] = await Promise.all([
389
372
  git(dir, ["log", "--follow", "--diff-filter=A", "--format=%aI", "--", base]),
390
373
  git(dir, ["log", "-1", "--format=%aI", "--", base])
@@ -395,7 +378,7 @@ async function fileTimestamps(absPath) {
395
378
  let updated = gitUpdated;
396
379
  if (!created || !updated) {
397
380
  try {
398
- const st = await fs3.stat(absPath);
381
+ const st = await fs4.stat(absPath);
399
382
  if (!updated) updated = st.mtime.toISOString();
400
383
  if (!created) created = isEpochish(st.birthtime) ? st.mtime.toISOString() : st.birthtime.toISOString();
401
384
  } catch {
@@ -412,18 +395,18 @@ var REPO_CONFIG_FILE = "diffwiki.yaml";
412
395
  async function detectRepoName(cwd) {
413
396
  try {
414
397
  const { stdout } = await execFileAsync2("git", ["rev-parse", "--show-toplevel"], { cwd });
415
- return path3.basename(stdout.trim());
398
+ return path4.basename(stdout.trim());
416
399
  } catch {
417
- return path3.basename(path3.resolve(cwd));
400
+ return path4.basename(path4.resolve(cwd));
418
401
  }
419
402
  }
420
403
  async function readRepoConfig(cwd) {
421
404
  try {
422
- const raw = await fs4.readFile(path3.join(cwd, REPO_CONFIG_FILE), "utf8");
423
- const parsed = parseYaml2(raw) ?? {};
405
+ const raw = await fs5.readFile(path4.join(cwd, REPO_CONFIG_FILE), "utf8");
406
+ const parsed = parseYaml(raw) ?? {};
424
407
  if (!parsed.path) return void 0;
425
408
  return {
426
- collection: parsed.collection ?? path3.basename(path3.resolve(cwd)),
409
+ collection: parsed.collection ?? path4.basename(path4.resolve(cwd)),
427
410
  path: parsed.path
428
411
  };
429
412
  } catch (err) {
@@ -432,9 +415,9 @@ async function readRepoConfig(cwd) {
432
415
  }
433
416
  }
434
417
  async function initRepoWiki(opts) {
435
- const cwd = path3.resolve(opts.cwd);
418
+ const cwd = path4.resolve(opts.cwd);
436
419
  const wikiPath = opts.wikiPath ?? "wiki";
437
- const absWiki = path3.join(cwd, wikiPath);
420
+ const absWiki = path4.join(cwd, wikiPath);
438
421
  let name = opts.collection ?? await detectRepoName(cwd);
439
422
  const existing = await findCollection(name);
440
423
  if (existing && existing.path !== absWiki) {
@@ -449,9 +432,9 @@ async function initRepoWiki(opts) {
449
432
  n += 1;
450
433
  }
451
434
  }
452
- await fs4.mkdir(absWiki, { recursive: true });
435
+ await fs5.mkdir(absWiki, { recursive: true });
453
436
  const config = { collection: name, path: wikiPath };
454
- await fs4.writeFile(path3.join(cwd, REPO_CONFIG_FILE), stringifyYaml2(config), "utf8");
437
+ await fs5.writeFile(path4.join(cwd, REPO_CONFIG_FILE), stringifyYaml(config), "utf8");
455
438
  const entry = {
456
439
  name,
457
440
  type: "repo",
@@ -462,15 +445,15 @@ async function initRepoWiki(opts) {
462
445
  return entry;
463
446
  }
464
447
  async function addExternalCollection(opts) {
465
- const cwd = path3.resolve(opts.cwd);
448
+ const cwd = path4.resolve(opts.cwd);
466
449
  const root = await ensureGitRepo(cwd);
467
450
  if (await isLinkedWorktree(cwd)) throw new WorktreeNotAllowedError(root);
468
451
  const id = await repoId(root) ?? `path:${root}`;
469
452
  const already = await findCollectionById(id);
470
453
  if (already) throw new CollectionAlreadyAddedError(already.name);
471
454
  const docsRel = opts.docsDir ?? "docs";
472
- const contentDir = path3.join(root, docsRel);
473
- let name = await repoRemoteName(root) ?? path3.basename(root);
455
+ const contentDir = path4.join(root, docsRel);
456
+ let name = await repoRemoteName(root) ?? path4.basename(root);
474
457
  if (await findCollection(name)) {
475
458
  let n = 2;
476
459
  while (await findCollection(`${name}-${n}`)) n += 1;
@@ -489,14 +472,14 @@ async function addExternalCollection(opts) {
489
472
  return entry;
490
473
  }
491
474
  async function resolveCwdCollection(cwd) {
492
- const abs = path3.resolve(cwd);
475
+ const abs = path4.resolve(cwd);
493
476
  const root = await gitToplevel(abs);
494
477
  if (root) {
495
478
  const id = await repoId(root) ?? `path:${root}`;
496
479
  const byId = await findCollectionById(id);
497
480
  if (byId) return byId;
498
481
  }
499
- const within = (base) => abs === base || abs.startsWith(base + path3.sep);
482
+ const within = (base) => abs === base || abs.startsWith(base + path4.sep);
500
483
  const all = await listCollections();
501
484
  let best;
502
485
  let bestLen = -1;
@@ -520,10 +503,10 @@ async function collectionGitStatus(entry) {
520
503
  }
521
504
 
522
505
  // src/project.ts
523
- import fs5 from "fs/promises";
506
+ import fs6 from "fs/promises";
524
507
  import os from "os";
525
- import path4 from "path";
526
- import { parse as parseYaml3 } from "yaml";
508
+ import path5 from "path";
509
+ import { parse as parseYaml2, parseDocument } from "yaml";
527
510
  var PROJECT_CONFIG_FILE = "diffwiki.yaml";
528
511
  function normaliseSite(raw) {
529
512
  if (!raw || typeof raw !== "object") return void 0;
@@ -577,12 +560,12 @@ function normaliseContext(raw) {
577
560
  async function readProjectConfig(dir) {
578
561
  let raw;
579
562
  try {
580
- raw = await fs5.readFile(path4.join(dir, PROJECT_CONFIG_FILE), "utf8");
563
+ raw = await fs6.readFile(path5.join(dir, PROJECT_CONFIG_FILE), "utf8");
581
564
  } catch (err) {
582
565
  if (err.code === "ENOENT") return void 0;
583
566
  throw err;
584
567
  }
585
- const parsed = parseYaml3(raw) ?? {};
568
+ const parsed = parseYaml2(raw) ?? {};
586
569
  const collections = [];
587
570
  if (Array.isArray(parsed.collections)) {
588
571
  for (const c of parsed.collections) {
@@ -598,7 +581,7 @@ async function readProjectConfig(dir) {
598
581
  }
599
582
  collections.sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER));
600
583
  } else if (typeof parsed.path === "string") {
601
- const name = typeof parsed.collection === "string" ? parsed.collection : path4.basename(path4.resolve(dir));
584
+ const name = typeof parsed.collection === "string" ? parsed.collection : path5.basename(path5.resolve(dir));
602
585
  collections.push({ name, path: parsed.path });
603
586
  }
604
587
  const site = normaliseSite(parsed.site);
@@ -608,15 +591,28 @@ async function readProjectConfig(dir) {
608
591
  async function readContextCollections(dir) {
609
592
  return (await readProjectConfig(dir))?.context?.collections ?? [];
610
593
  }
594
+ async function setContextCollections(dir, collections) {
595
+ const file = path5.join(dir, PROJECT_CONFIG_FILE);
596
+ let raw = "";
597
+ try {
598
+ raw = await fs6.readFile(file, "utf8");
599
+ } catch (err) {
600
+ if (err.code !== "ENOENT") throw err;
601
+ }
602
+ const doc = parseDocument(raw);
603
+ doc.setIn(["context", "collections"], collections);
604
+ await fs6.writeFile(file, doc.toString(), "utf8");
605
+ return file;
606
+ }
611
607
  async function resolveProjectCollections(dir) {
612
608
  const config = await readProjectConfig(dir);
613
609
  if (!config) return [];
614
- const root = path4.resolve(dir);
610
+ const root = path5.resolve(dir);
615
611
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
616
612
  return config.collections.map((c) => ({
617
613
  name: c.name,
618
614
  type: "repo",
619
- path: path4.resolve(root, c.path),
615
+ path: path5.resolve(root, c.path),
620
616
  createdAt
621
617
  }));
622
618
  }
@@ -626,11 +622,11 @@ async function readSiteConfig(dir) {
626
622
  async function seedProjectHome(dir) {
627
623
  const entries = await resolveProjectCollections(dir);
628
624
  if (entries.length === 0) {
629
- throw new Error(`No collections to render from diffwiki.yaml in ${path4.resolve(dir)}`);
625
+ throw new Error(`No collections to render from diffwiki.yaml in ${path5.resolve(dir)}`);
630
626
  }
631
- const home = await fs5.mkdtemp(path4.join(os.tmpdir(), "diffwiki-project-"));
632
- await fs5.writeFile(
633
- path4.join(home, "registry.json"),
627
+ const home = await fs6.mkdtemp(path5.join(os.tmpdir(), "diffwiki-project-"));
628
+ await fs6.writeFile(
629
+ path5.join(home, "registry.json"),
634
630
  `${JSON.stringify({ version: 1, collections: entries }, null, 2)}
635
631
  `,
636
632
  "utf8"
@@ -639,8 +635,8 @@ async function seedProjectHome(dir) {
639
635
  }
640
636
 
641
637
  // src/browse.ts
642
- import fs6 from "fs/promises";
643
- import path5 from "path";
638
+ import fs7 from "fs/promises";
639
+ import path6 from "path";
644
640
  var INDEX_RE = /^(index|readme)\.mdx?$/i;
645
641
  function buildArticleTree(relPaths) {
646
642
  const root = {};
@@ -684,7 +680,7 @@ function buildArticleTree(relPaths) {
684
680
  async function collectRelPaths(dir, rel = "") {
685
681
  let dirents;
686
682
  try {
687
- dirents = await fs6.readdir(dir, { withFileTypes: true });
683
+ dirents = await fs7.readdir(dir, { withFileTypes: true });
688
684
  } catch {
689
685
  return [];
690
686
  }
@@ -693,7 +689,7 @@ async function collectRelPaths(dir, rel = "") {
693
689
  if (d.name.startsWith(".")) continue;
694
690
  const childRel = rel ? `${rel}/${d.name}` : d.name;
695
691
  if (d.isDirectory()) {
696
- out.push(...await collectRelPaths(path5.join(dir, d.name), childRel));
692
+ out.push(...await collectRelPaths(path6.join(dir, d.name), childRel));
697
693
  } else if (/\.(mdx?)$/.test(d.name)) {
698
694
  out.push(childRel);
699
695
  }
@@ -702,7 +698,7 @@ async function collectRelPaths(dir, rel = "") {
702
698
  }
703
699
  async function readdirSafe(dir) {
704
700
  try {
705
- return await fs6.readdir(dir);
701
+ return await fs7.readdir(dir);
706
702
  } catch {
707
703
  return [];
708
704
  }
@@ -720,7 +716,7 @@ var CHEAP_META_BYTES = 8192;
720
716
  async function cheapMeta(absPath) {
721
717
  let handle;
722
718
  try {
723
- handle = await fs6.open(absPath, "r");
719
+ handle = await fs7.open(absPath, "r");
724
720
  const buf = Buffer.alloc(CHEAP_META_BYTES);
725
721
  const { bytesRead } = await handle.read(buf, 0, CHEAP_META_BYTES, 0);
726
722
  const snippet = buf.subarray(0, bytesRead).toString("utf8");
@@ -754,10 +750,10 @@ async function enrichTitles(nodes, collDir) {
754
750
  let status = node.status;
755
751
  let order = node.order;
756
752
  if (node.slug) {
757
- const dir = path5.join(collDir, node.slug);
753
+ const dir = path6.join(collDir, node.slug);
758
754
  const idx = pickIndexFile(await readdirSafe(dir));
759
755
  if (idx) {
760
- const meta = await cheapMeta(path5.join(dir, idx));
756
+ const meta = await cheapMeta(path6.join(dir, idx));
761
757
  title = meta.title ?? title;
762
758
  audience = meta.audience ?? audience;
763
759
  status = meta.status ?? status;
@@ -767,7 +763,7 @@ async function enrichTitles(nodes, collDir) {
767
763
  return { ...node, title, audience, status, order, children };
768
764
  }
769
765
  for (const ext of [".mdx", ".md"]) {
770
- const meta = await cheapMeta(path5.join(collDir, `${node.slug}${ext}`));
766
+ const meta = await cheapMeta(path6.join(collDir, `${node.slug}${ext}`));
771
767
  if (meta.title || meta.audience || meta.status || meta.order != null) {
772
768
  return {
773
769
  ...node,
@@ -786,49 +782,49 @@ async function enrichTitles(nodes, collDir) {
786
782
  async function collectionMeta(collDir) {
787
783
  const idx = pickIndexFile(await readdirSafe(collDir));
788
784
  if (!idx) return {};
789
- const meta = await cheapMeta(path5.join(collDir, idx));
785
+ const meta = await cheapMeta(path6.join(collDir, idx));
790
786
  return { title: meta.title, audience: meta.audience, status: meta.status };
791
787
  }
792
788
  async function listArticleTree(coll) {
793
789
  const entry = await findCollection(coll);
794
790
  if (!entry) throw new CollectionNotFoundError(coll);
795
791
  const rels = await collectRelPaths(entry.path);
796
- return enrichTitles(buildArticleTree(rels), path5.resolve(entry.path));
792
+ return enrichTitles(buildArticleTree(rels), path6.resolve(entry.path));
797
793
  }
798
794
  async function readArticle(coll, slug) {
799
795
  const entry = await findCollection(coll);
800
796
  if (!entry) throw new CollectionNotFoundError(coll);
801
- const collDir = path5.resolve(entry.path);
797
+ const collDir = path6.resolve(entry.path);
802
798
  const normalized = slug.replace(/\\/g, "/");
803
799
  if (normalized.split("/").some((s) => s === "..")) {
804
800
  throw new InvalidTargetError(slug, "path traversal detected");
805
801
  }
806
802
  const safeSlug = normalized.split("/").filter((s) => s !== "" && s !== ".").join("/");
807
- const inJail = (p) => p === collDir || p.startsWith(collDir + path5.sep);
803
+ const inJail = (p) => p === collDir || p.startsWith(collDir + path6.sep);
808
804
  let abs;
809
805
  let isIndex = false;
810
806
  for (const rel of safeSlug ? [`${safeSlug}.mdx`, `${safeSlug}.md`] : []) {
811
- const candidate = path5.resolve(collDir, rel);
807
+ const candidate = path6.resolve(collDir, rel);
812
808
  if (!inJail(candidate)) throw new InvalidTargetError(slug, "path traversal detected");
813
809
  try {
814
- await fs6.access(candidate);
810
+ await fs7.access(candidate);
815
811
  abs = candidate;
816
812
  break;
817
813
  } catch {
818
814
  }
819
815
  }
820
816
  if (!abs) {
821
- const dir = path5.resolve(collDir, safeSlug);
817
+ const dir = path6.resolve(collDir, safeSlug);
822
818
  if (inJail(dir)) {
823
819
  const idx = pickIndexFile(await readdirSafe(dir));
824
820
  if (idx) {
825
- abs = path5.join(dir, idx);
821
+ abs = path6.join(dir, idx);
826
822
  isIndex = true;
827
823
  }
828
824
  }
829
825
  }
830
826
  if (!abs) throw new ArticleNotFoundError(`${coll}/${safeSlug}`);
831
- const parsed = parseArticle(await fs6.readFile(abs, "utf8"));
827
+ const parsed = parseArticle(await fs7.readFile(abs, "utf8"));
832
828
  const title = parsed.title?.trim() || (safeSlug ? safeSlug.split("/").pop() ?? safeSlug : coll);
833
829
  return {
834
830
  coll,
@@ -844,7 +840,7 @@ async function readArticle(coll, slug) {
844
840
  }
845
841
 
846
842
  // src/render.ts
847
- import path6 from "path";
843
+ import path7 from "path";
848
844
  import { unified } from "unified";
849
845
  import remarkParse from "remark-parse";
850
846
  import remarkGfm from "remark-gfm";
@@ -861,7 +857,7 @@ function isNonRelativeHref(href) {
861
857
  return /^[a-z][a-z0-9+.-]*:/i.test(href);
862
858
  }
863
859
  function rehypeRewriteLinks(ctx) {
864
- const slugDir = ctx.isIndex ? ctx.slug || "." : path6.posix.dirname(ctx.slug || ".");
860
+ const slugDir = ctx.isIndex ? ctx.slug || "." : path7.posix.dirname(ctx.slug || ".");
865
861
  return (tree) => {
866
862
  visit(tree, "element", (node) => {
867
863
  if (node.tagName !== "a") return;
@@ -871,8 +867,8 @@ function rehypeRewriteLinks(ctx) {
871
867
  const suffix = suffixMatch ? suffixMatch[0] : "";
872
868
  const pathPart = suffix ? raw.slice(0, raw.length - suffix.length) : raw;
873
869
  if (pathPart === "") return;
874
- const joined = path6.posix.join(slugDir === "." ? "" : slugDir, pathPart);
875
- const normalized = path6.posix.normalize(joined);
870
+ const joined = path7.posix.join(slugDir === "." ? "" : slugDir, pathPart);
871
+ const normalized = path7.posix.normalize(joined);
876
872
  const toRoute = (p) => p.replace(/\.(mdx?)$/i, "").replace(/(^|\/)(index|readme)$/i, "$1").replace(/\/+$/, "").replace(/^\/+/, "");
877
873
  if (normalized.startsWith("../")) {
878
874
  const sibling = normalized.slice(3);
@@ -1272,19 +1268,19 @@ function resolveCollectionSelection(all, sel = {}) {
1272
1268
  }
1273
1269
 
1274
1270
  // src/query.ts
1275
- import fs7 from "fs/promises";
1276
- import path7 from "path";
1271
+ import fs8 from "fs/promises";
1272
+ import path8 from "path";
1277
1273
  var log2 = getLogger("query");
1278
1274
  async function collectMarkdownFiles(dir) {
1279
1275
  let entries;
1280
1276
  try {
1281
- entries = await fs7.readdir(dir, { withFileTypes: true });
1277
+ entries = await fs8.readdir(dir, { withFileTypes: true });
1282
1278
  } catch {
1283
1279
  return [];
1284
1280
  }
1285
1281
  const files = [];
1286
1282
  for (const entry of entries) {
1287
- const full = path7.join(dir, entry.name);
1283
+ const full = path8.join(dir, entry.name);
1288
1284
  if (entry.isDirectory()) {
1289
1285
  files.push(...await collectMarkdownFiles(full));
1290
1286
  } else if (entry.isFile() && /\.mdx?$/.test(entry.name)) {
@@ -1311,12 +1307,12 @@ async function nativeSearch(term, collections, collection, hideSystem = false) {
1311
1307
  const docs = [];
1312
1308
  for (const entry of targets) {
1313
1309
  for (const full of await collectMarkdownFiles(entry.path)) {
1314
- const fallbackTitle = path7.basename(full).replace(/\.mdx?$/, "");
1310
+ const fallbackTitle = path8.basename(full).replace(/\.mdx?$/, "");
1315
1311
  let title = fallbackTitle;
1316
1312
  let tags = [];
1317
1313
  let body = "";
1318
1314
  try {
1319
- const parsed = parseArticle(await fs7.readFile(full, "utf8"));
1315
+ const parsed = parseArticle(await fs8.readFile(full, "utf8"));
1320
1316
  if (hideSystem && parsed.audience === "system") continue;
1321
1317
  if (parsed.title) title = parsed.title;
1322
1318
  tags = parsed.tags;
@@ -1374,7 +1370,7 @@ async function dropSystemHits(hits) {
1374
1370
  const kept = [];
1375
1371
  for (const hit of hits) {
1376
1372
  try {
1377
- if (parseArticle(await fs7.readFile(hit.path, "utf8")).audience === "system") continue;
1373
+ if (parseArticle(await fs8.readFile(hit.path, "utf8")).audience === "system") continue;
1378
1374
  } catch {
1379
1375
  }
1380
1376
  kept.push(hit);
@@ -1390,6 +1386,7 @@ async function search(term, opts) {
1390
1386
  const showSystem = opts?.showSystem ?? await showSystemEnabled();
1391
1387
  const hideSystem = !showSystem && wanted.length === 0;
1392
1388
  const searchable = hideSystem ? collections.filter((c) => c.audience !== "system") : collections;
1389
+ const systemCollections = new Set(collections.filter((c) => c.audience === "system").map((c) => c.name));
1393
1390
  const filterHits = (hits) => wanted.length > 0 ? hits.filter((h) => wanted.includes(h.collection)) : hits;
1394
1391
  const engine = opts?.engine ?? await resolveDefaultEngine();
1395
1392
  log2.debug(`resolved search engine "${engine}"`);
@@ -1407,7 +1404,7 @@ async function search(term, opts) {
1407
1404
  try {
1408
1405
  const providerOpts = { ...opts, collection: wanted.length === 1 ? wanted[0] : void 0 };
1409
1406
  const raw = filterHits(await provider.handleSearch(term, searchable, providerOpts));
1410
- const hits = hideSystem ? await dropSystemHits(raw) : raw;
1407
+ const hits = hideSystem ? await dropSystemHits(raw.filter((h) => !systemCollections.has(h.collection))) : raw;
1411
1408
  return { hits, engine, fellBackToNative: false };
1412
1409
  } catch (err) {
1413
1410
  const message = err instanceof Error ? err.message : String(err);
@@ -1421,13 +1418,13 @@ async function query(term, opts) {
1421
1418
  }
1422
1419
 
1423
1420
  // src/search.ts
1424
- import fs8 from "fs/promises";
1425
- import path8 from "path";
1421
+ import fs9 from "fs/promises";
1422
+ import path9 from "path";
1426
1423
  var isStatic2 = () => process.env.DIFFWIKI_STATIC === "1";
1427
1424
  async function readArticleParts(collDir, slug) {
1428
1425
  for (const ext of [".mdx", ".md"]) {
1429
1426
  try {
1430
- const parsed = parseArticle(await fs8.readFile(path8.join(collDir, `${slug}${ext}`), "utf8"));
1427
+ const parsed = parseArticle(await fs9.readFile(path9.join(collDir, `${slug}${ext}`), "utf8"));
1431
1428
  return { tags: parsed.tags, body: parsed.body };
1432
1429
  } catch {
1433
1430
  }
@@ -1463,7 +1460,7 @@ async function buildSearchIndex(collection) {
1463
1460
  for (const e of entries) {
1464
1461
  try {
1465
1462
  const collEntry = await findCollection(e.name);
1466
- const collDir = path8.resolve(collEntry?.path ?? e.path);
1463
+ const collDir = path9.resolve(collEntry?.path ?? e.path);
1467
1464
  if (collEntry?.audience === "system") continue;
1468
1465
  if (isStatic2()) {
1469
1466
  const cm = await collectionMeta(collDir);
@@ -1477,15 +1474,15 @@ async function buildSearchIndex(collection) {
1477
1474
  }
1478
1475
 
1479
1476
  // src/doctor.ts
1480
- import fs10 from "fs/promises";
1477
+ import fs11 from "fs/promises";
1481
1478
 
1482
1479
  // src/plugins/manager.ts
1483
- import fs9 from "fs/promises";
1480
+ import fs10 from "fs/promises";
1484
1481
  import os2 from "os";
1485
- import path9 from "path";
1482
+ import path10 from "path";
1486
1483
  async function readPackageJson(file) {
1487
1484
  try {
1488
- return JSON.parse(await fs9.readFile(file, "utf8"));
1485
+ return JSON.parse(await fs10.readFile(file, "utf8"));
1489
1486
  } catch {
1490
1487
  return null;
1491
1488
  }
@@ -1501,7 +1498,7 @@ function binOf(manifest) {
1501
1498
  async function pluginManifestDirs(nodeModules) {
1502
1499
  let entries;
1503
1500
  try {
1504
- entries = await fs9.readdir(nodeModules);
1501
+ entries = await fs10.readdir(nodeModules);
1505
1502
  } catch {
1506
1503
  return [];
1507
1504
  }
@@ -1511,44 +1508,44 @@ async function pluginManifestDirs(nodeModules) {
1511
1508
  if (entry.startsWith("@")) {
1512
1509
  let scoped;
1513
1510
  try {
1514
- scoped = await fs9.readdir(path9.join(nodeModules, entry));
1511
+ scoped = await fs10.readdir(path10.join(nodeModules, entry));
1515
1512
  } catch {
1516
1513
  continue;
1517
1514
  }
1518
1515
  for (const pkg of scoped) {
1519
1516
  if (pkg.startsWith(".")) continue;
1520
- dirs.push(path9.join(nodeModules, entry, pkg));
1517
+ dirs.push(path10.join(nodeModules, entry, pkg));
1521
1518
  }
1522
1519
  } else {
1523
- dirs.push(path9.join(nodeModules, entry));
1520
+ dirs.push(path10.join(nodeModules, entry));
1524
1521
  }
1525
1522
  }
1526
1523
  return dirs;
1527
1524
  }
1528
1525
  async function ensurePluginRoot() {
1529
1526
  const dir = pluginsDir();
1530
- await fs9.mkdir(dir, { recursive: true });
1531
- const pkgPath = path9.join(dir, "package.json");
1527
+ await fs10.mkdir(dir, { recursive: true });
1528
+ const pkgPath = path10.join(dir, "package.json");
1532
1529
  if (await readPackageJson(pkgPath) === null) {
1533
1530
  const pkg = { name: "diffwiki-plugins", private: true, version: "0.0.0" };
1534
- await fs9.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
1531
+ await fs10.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
1535
1532
  `, "utf8");
1536
1533
  }
1537
1534
  return dir;
1538
1535
  }
1539
1536
  async function asSearchPackage(pkgDir) {
1540
- const manifest = await readPackageJson(path9.join(pkgDir, "package.json"));
1537
+ const manifest = await readPackageJson(path10.join(pkgDir, "package.json"));
1541
1538
  if (!manifest?.name || manifest.diffwiki?.kind !== "search") return null;
1542
1539
  const bin = binOf(manifest);
1543
1540
  if (!bin) return null;
1544
- return { name: manifest.name, version: manifest.version, binAbs: path9.resolve(pkgDir, bin) };
1541
+ return { name: manifest.name, version: manifest.version, binAbs: path10.resolve(pkgDir, bin) };
1545
1542
  }
1546
1543
  async function packageNameFromSpec(spec) {
1547
1544
  const looksLikePath = spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("~") || spec.startsWith("file:");
1548
1545
  if (looksLikePath) {
1549
1546
  const raw = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
1550
- const abs = path9.resolve(raw.replace(/^~(?=$|\/)/, os2.homedir()));
1551
- return (await readPackageJson(path9.join(abs, "package.json")))?.name ?? null;
1547
+ const abs = path10.resolve(raw.replace(/^~(?=$|\/)/, os2.homedir()));
1548
+ return (await readPackageJson(path10.join(abs, "package.json")))?.name ?? null;
1552
1549
  }
1553
1550
  if (/^(git\+|git:|https?:|ssh:)/.test(spec)) return null;
1554
1551
  if (spec.startsWith("@")) {
@@ -1559,10 +1556,10 @@ async function packageNameFromSpec(spec) {
1559
1556
  return at <= 0 ? spec : spec.slice(0, at);
1560
1557
  }
1561
1558
  async function resolveSearchPackage(dir, name) {
1562
- return asSearchPackage(path9.join(dir, "node_modules", ...name.split("/")));
1559
+ return asSearchPackage(path10.join(dir, "node_modules", ...name.split("/")));
1563
1560
  }
1564
1561
  async function discoverSearchPackage(dir) {
1565
- const nodeModules = path9.join(dir, "node_modules");
1562
+ const nodeModules = path10.join(dir, "node_modules");
1566
1563
  for (const pkgDir of await pluginManifestDirs(nodeModules)) {
1567
1564
  const found = await asSearchPackage(pkgDir);
1568
1565
  if (found) return found;
@@ -1659,6 +1656,14 @@ async function registerPlugin(name, command) {
1659
1656
  await runSetup(command, name);
1660
1657
  return record;
1661
1658
  }
1659
+ async function repairPlugins() {
1660
+ const enabled = (await readPluginRegistry()).plugins.filter((p) => p.enabled && p.kind === "search");
1661
+ const out = [];
1662
+ for (const p of enabled) {
1663
+ out.push({ name: p.name, setup: await runSetup(p.command, p.name) });
1664
+ }
1665
+ return out;
1666
+ }
1662
1667
  async function removePlugin(name) {
1663
1668
  const record = (await readPluginRegistry()).plugins.find((p) => p.name === name);
1664
1669
  if (!record) throw new PluginError(`plugin "${name}" is not registered`);
@@ -1692,7 +1697,7 @@ async function listPlugins() {
1692
1697
  // src/doctor.ts
1693
1698
  async function exists(target) {
1694
1699
  try {
1695
- await fs10.access(target);
1700
+ await fs11.access(target);
1696
1701
  return true;
1697
1702
  } catch {
1698
1703
  return false;
@@ -1798,15 +1803,14 @@ async function appendPluginDiagnostics(out, config) {
1798
1803
  }
1799
1804
 
1800
1805
  // src/lint.ts
1801
- import fs11 from "fs/promises";
1802
- import path10 from "path";
1806
+ import fs12 from "fs/promises";
1807
+ import path11 from "path";
1803
1808
  var BUDGETS = {
1804
1809
  recipe: { min: 300, max: 1e3 },
1805
1810
  "recipe-steps": { min: 500, max: 1500 },
1806
1811
  guide: { min: 1500, max: 5e3 },
1807
- "system-design": { min: 2e3, max: 6e3 },
1808
- "component-design": { min: 1e3, max: 3e3 },
1809
- // Fallback for a bare `architecture` tag when system vs component isn't declared.
1812
+ reference: { min: 300, max: 2e3 },
1813
+ // Generic fallback budget for a bare `architecture` tag (no dedicated template).
1810
1814
  architecture: { min: 1e3, max: 6e3 }
1811
1815
  };
1812
1816
  function estimateTokens(body) {
@@ -1815,8 +1819,7 @@ function estimateTokens(body) {
1815
1819
  function inferShape(tags, body) {
1816
1820
  const t = new Set(tags.map((x) => x.toLowerCase()));
1817
1821
  if (t.has("guide")) return "guide";
1818
- if (t.has("component-design") || t.has("component")) return "component-design";
1819
- if (t.has("system-design") || t.has("system")) return "system-design";
1822
+ if (t.has("reference")) return "reference";
1820
1823
  if (t.has("architecture")) return "architecture";
1821
1824
  if (/^###\s+Step\b/im.test(body)) return "recipe-steps";
1822
1825
  return "recipe";
@@ -1887,13 +1890,13 @@ function lintArticle(article) {
1887
1890
  async function walkMarkdown(dir) {
1888
1891
  let entries;
1889
1892
  try {
1890
- entries = await fs11.readdir(dir, { withFileTypes: true });
1893
+ entries = await fs12.readdir(dir, { withFileTypes: true });
1891
1894
  } catch {
1892
1895
  return [];
1893
1896
  }
1894
1897
  const out = [];
1895
1898
  for (const e of entries) {
1896
- const full = path10.join(dir, e.name);
1899
+ const full = path11.join(dir, e.name);
1897
1900
  if (e.isDirectory()) out.push(...await walkMarkdown(full));
1898
1901
  else if (e.isFile() && /\.mdx?$/.test(e.name) && !/-template\.mdx?$/.test(e.name)) out.push(full);
1899
1902
  }
@@ -1923,11 +1926,11 @@ async function lintCollections(scope) {
1923
1926
  let warnCount = 0;
1924
1927
  for (const coll of collections) {
1925
1928
  for (const full of await walkMarkdown(coll.path)) {
1926
- const rel = path10.relative(coll.path, full);
1929
+ const rel = path11.relative(coll.path, full);
1927
1930
  const label = `${coll.name}/${rel}`;
1928
1931
  let findings = [];
1929
1932
  try {
1930
- const parsed = parseArticle(await fs11.readFile(full, "utf8"));
1933
+ const parsed = parseArticle(await fs12.readFile(full, "utf8"));
1931
1934
  findings = lintArticle(parsed);
1932
1935
  } catch (err) {
1933
1936
  findings = [{ level: "error", rule: "parse", message: `could not parse: ${err.message}` }];
@@ -2040,11 +2043,14 @@ export {
2040
2043
  listEnabledPlugins,
2041
2044
  listPlugins,
2042
2045
  listSearchModes,
2046
+ listTemplateShapes,
2047
+ listTemplates,
2043
2048
  loadSearchProvider,
2044
2049
  localGraph,
2045
2050
  nativeSearch,
2046
2051
  parseAddTarget,
2047
2052
  parseArticle,
2053
+ parseFrontmatter,
2048
2054
  parsePathTarget,
2049
2055
  pluginsDir,
2050
2056
  pluginsRegistryPath,
@@ -2066,6 +2072,7 @@ export {
2066
2072
  removePlugin,
2067
2073
  removeTags,
2068
2074
  renderArticle,
2075
+ repairPlugins,
2069
2076
  repoId,
2070
2077
  repoRemoteName,
2071
2078
  resolveCollectionSelection,
@@ -2079,13 +2086,17 @@ export {
2079
2086
  resolveProjectCollections,
2080
2087
  search,
2081
2088
  seedProjectHome,
2089
+ seedTemplates,
2082
2090
  serializeArticle,
2083
2091
  setArticleMeta,
2084
2092
  setConfigValue,
2093
+ setContextCollections,
2085
2094
  setTags,
2086
2095
  showSystemEnabled,
2087
2096
  slugify,
2088
2097
  slugifyPath,
2098
+ stripFrontmatter,
2099
+ templatesDir,
2089
2100
  toTreeNodes,
2090
2101
  tokenize,
2091
2102
  unregisterCollection,