diffwiki-core 0.3.0 → 0.4.0-rc.202607220518.0ade9d3

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,148 +1,72 @@
1
- // src/errors.ts
2
- var DiffwikiError = class extends Error {
3
- constructor(message) {
4
- super(message);
5
- this.name = new.target.name;
6
- }
7
- };
8
- var CollectionExistsError = class extends DiffwikiError {
9
- constructor(name) {
10
- super(`collection "${name}" already exists`);
11
- }
12
- };
13
- var CollectionNotFoundError = class extends DiffwikiError {
14
- constructor(name) {
15
- super(`collection "${name}" not found`);
16
- }
17
- };
18
- var NoDefaultCollectionError = class extends DiffwikiError {
19
- constructor() {
20
- super("no default collection set (use: diffwiki config-set defaultCollection <name>)");
21
- }
22
- };
23
- var ArticleNotFoundError = class extends DiffwikiError {
24
- constructor(target) {
25
- super(`article "${target}" not found`);
26
- }
27
- };
28
- var InvalidTargetError = class extends DiffwikiError {
29
- constructor(target, expected) {
30
- super(`invalid target "${target}" \u2014 ${expected}`);
31
- }
32
- };
33
-
34
- // src/paths.ts
35
- import os from "os";
36
- import path from "path";
37
- function resolveHome() {
38
- const override = process.env.DIFFWIKI_HOME;
39
- return override && override.length > 0 ? override : path.join(os.homedir(), ".diffwiki");
40
- }
41
- function registryPath() {
42
- return path.join(resolveHome(), "registry.json");
43
- }
44
- function configPath() {
45
- return path.join(resolveHome(), "config.json");
46
- }
47
- function collectionsDir() {
48
- return path.join(resolveHome(), "collections");
49
- }
50
- function collectionDir(name) {
51
- return path.join(collectionsDir(), name);
52
- }
1
+ import {
2
+ ArticleNotFoundError,
3
+ CollectionExistsError,
4
+ CollectionNotFoundError,
5
+ DiffwikiError,
6
+ InvalidTargetError,
7
+ NATIVE_ENGINE,
8
+ NoDefaultCollectionError,
9
+ PluginError,
10
+ collectionDir,
11
+ collectionsDir,
12
+ configPath,
13
+ createCollection,
14
+ deregisterPlugin,
15
+ emitPluginEvent,
16
+ findCollection,
17
+ findEnabledPlugin,
18
+ listCollections,
19
+ listEnabledPlugins,
20
+ listSearchModes,
21
+ loadSearchProvider,
22
+ pluginsDir,
23
+ pluginsRegistryPath,
24
+ readPluginRegistry,
25
+ readRegistry,
26
+ registerCollection,
27
+ registryPath,
28
+ removeCollection,
29
+ resolveHome,
30
+ spawnPluginClient,
31
+ unregisterCollection,
32
+ upsertPlugin,
33
+ writePluginRegistry,
34
+ writeRegistry
35
+ } from "./chunk-I3PPUXYY.js";
53
36
 
54
37
  // src/config.ts
55
- import fs3 from "fs/promises";
56
-
57
- // src/collections.ts
58
- import fs2 from "fs/promises";
59
-
60
- // src/registry.ts
61
38
  import fs from "fs/promises";
62
39
  function isMissing(err) {
63
40
  return err?.code === "ENOENT";
64
41
  }
65
- async function readRegistry() {
66
- try {
67
- const parsed = JSON.parse(await fs.readFile(registryPath(), "utf8"));
68
- return { version: parsed.version ?? 1, collections: parsed.collections ?? [] };
69
- } catch (err) {
70
- if (isMissing(err)) return { version: 1, collections: [] };
71
- throw err;
72
- }
73
- }
74
- async function writeRegistry(registry) {
75
- await fs.mkdir(resolveHome(), { recursive: true });
76
- await fs.writeFile(registryPath(), `${JSON.stringify(registry, null, 2)}
77
- `, "utf8");
78
- }
79
- async function registerCollection(entry) {
80
- const registry = await readRegistry();
81
- const index = registry.collections.findIndex((c) => c.name === entry.name);
82
- if (index >= 0) {
83
- registry.collections[index] = entry;
84
- } else {
85
- registry.collections.push(entry);
86
- }
87
- await writeRegistry(registry);
88
- }
89
- async function unregisterCollection(name) {
90
- const registry = await readRegistry();
91
- registry.collections = registry.collections.filter((c) => c.name !== name);
92
- await writeRegistry(registry);
93
- }
94
-
95
- // src/collections.ts
96
- async function listCollections() {
97
- return (await readRegistry()).collections;
98
- }
99
- async function findCollection(name) {
100
- return (await readRegistry()).collections.find((c) => c.name === name);
101
- }
102
- async function createCollection(name) {
103
- if (await findCollection(name)) throw new CollectionExistsError(name);
104
- const dir = collectionDir(name);
105
- await fs2.mkdir(dir, { recursive: true });
106
- const entry = {
107
- name,
108
- type: "global",
109
- path: dir,
110
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
111
- };
112
- await registerCollection(entry);
113
- return entry;
114
- }
115
- async function removeCollection(name) {
116
- const entry = await findCollection(name);
117
- if (!entry) throw new CollectionNotFoundError(name);
118
- await unregisterCollection(name);
119
- if (entry.type === "global") {
120
- await fs2.rm(entry.path, { recursive: true, force: true });
121
- }
122
- return entry;
123
- }
124
-
125
- // src/config.ts
126
- function isMissing2(err) {
127
- return err?.code === "ENOENT";
128
- }
129
42
  async function readConfig() {
130
43
  try {
131
- return JSON.parse(await fs3.readFile(configPath(), "utf8"));
44
+ return JSON.parse(await fs.readFile(configPath(), "utf8"));
132
45
  } catch (err) {
133
- if (isMissing2(err)) return {};
46
+ if (isMissing(err)) return {};
134
47
  throw err;
135
48
  }
136
49
  }
137
50
  async function writeConfig(config) {
138
- await fs3.mkdir(resolveHome(), { recursive: true });
139
- await fs3.writeFile(configPath(), `${JSON.stringify(config, null, 2)}
51
+ await fs.mkdir(resolveHome(), { recursive: true });
52
+ await fs.writeFile(configPath(), `${JSON.stringify(config, null, 2)}
140
53
  `, "utf8");
141
54
  }
142
55
  async function setConfigValue(key, value) {
143
56
  if (key === "defaultCollection" && !await findCollection(value)) {
144
57
  throw new CollectionNotFoundError(value);
145
58
  }
59
+ if (key === "defaultSearch") {
60
+ const engine = value.split(":")[0];
61
+ if (engine !== NATIVE_ENGINE) {
62
+ const enabled = await listEnabledPlugins("search");
63
+ if (!enabled.some((p) => p.name === engine)) {
64
+ console.warn(
65
+ `! defaultSearch "${value}" names an engine that is not installed/enabled \u2014 native will be used until it is`
66
+ );
67
+ }
68
+ }
69
+ }
146
70
  const config = await readConfig();
147
71
  config[key] = value;
148
72
  await writeConfig(config);
@@ -161,23 +85,60 @@ function slugify(input) {
161
85
 
162
86
  // src/frontmatter.ts
163
87
  import matter from "gray-matter";
88
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
89
+ var YAML_ENGINE = {
90
+ parse: (input) => {
91
+ const data = parseYaml(input);
92
+ return data && typeof data === "object" ? data : {};
93
+ },
94
+ stringify: (data) => stringifyYaml(data)
95
+ };
96
+ var MATTER_OPTS = { engines: { yaml: YAML_ENGINE } };
97
+ function parseFallback(raw) {
98
+ const fenceRe = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*\n/;
99
+ const fenceMatch = fenceRe.exec(raw);
100
+ let title = "";
101
+ let body;
102
+ if (fenceMatch) {
103
+ const inner = fenceMatch[1];
104
+ const titleMatch = /^title:\s*(.+)$/m.exec(inner);
105
+ if (titleMatch) {
106
+ title = titleMatch[1].trim().replace(/^["']|["']$/g, "");
107
+ }
108
+ body = raw.slice(fenceMatch[0].length);
109
+ } else {
110
+ body = raw;
111
+ }
112
+ return { title, tags: [], body: body.replace(/^\n+/, "") };
113
+ }
164
114
  function parseArticle(raw) {
165
- const { data, content } = matter(raw);
166
- const title = typeof data.title === "string" ? data.title : "";
167
- const tags = Array.isArray(data.tags) ? data.tags.map((t) => String(t)) : [];
168
- const created = typeof data.created === "string" ? data.created : void 0;
169
- return { title, tags, created, body: content.replace(/^\n+/, "") };
115
+ try {
116
+ const { data, content } = matter(raw, MATTER_OPTS);
117
+ const title = typeof data.title === "string" ? data.title : "";
118
+ const tags = Array.isArray(data.tags) ? data.tags.map((t) => String(t)) : [];
119
+ const created = typeof data.created === "string" ? data.created : void 0;
120
+ return { title, tags, created, body: content.replace(/^\n+/, "") };
121
+ } catch {
122
+ return parseFallback(raw);
123
+ }
170
124
  }
171
125
  function serializeArticle(article) {
172
126
  const front = { title: article.title, tags: article.tags };
173
127
  if (article.created) front.created = article.created;
174
128
  if (article.updated) front.updated = article.updated;
175
- return matter.stringify(article.body, front);
129
+ return matter.stringify(article.body, front, MATTER_OPTS);
176
130
  }
177
131
 
178
132
  // src/articles.ts
179
- import fs4 from "fs/promises";
180
- import path2 from "path";
133
+ import fs2 from "fs/promises";
134
+ import path from "path";
135
+ async function fireHook(event, collection, relPath) {
136
+ try {
137
+ const { emitPluginEvent: emitPluginEvent2 } = await import("./events-656CR64Q.js");
138
+ await emitPluginEvent2(event, collection, relPath);
139
+ } catch {
140
+ }
141
+ }
181
142
  function parseAddTarget(target) {
182
143
  const idx = target.indexOf(":");
183
144
  if (idx === -1) {
@@ -210,37 +171,38 @@ async function resolveCollectionDir(name) {
210
171
  }
211
172
  function ensureMdPath(file) {
212
173
  if (/\.mdx?$/.test(file)) return file;
213
- const dir = path2.dirname(file);
214
- const base = slugify(path2.basename(file));
215
- const rel = dir === "." ? `${base}.md` : path2.join(dir, `${base}.md`);
174
+ const dir = path.dirname(file);
175
+ const base = slugify(path.basename(file));
176
+ const rel = dir === "." ? `${base}.md` : path.join(dir, `${base}.md`);
216
177
  return rel;
217
178
  }
218
179
  async function createArticle(opts) {
219
180
  const { collection, title } = parseAddTarget(opts.target);
220
181
  const { name, dir } = await resolveCollectionDir(collection);
221
- const filePath = path2.join(dir, `${slugify(title)}.md`);
182
+ const filePath = path.join(dir, `${slugify(title)}.md`);
222
183
  const now = (/* @__PURE__ */ new Date()).toISOString();
223
184
  const body = opts.body ?? `# ${title}
224
185
  `;
225
- await fs4.mkdir(dir, { recursive: true });
226
- await fs4.writeFile(filePath, serializeArticle({ title, tags: opts.tags, created: now, updated: now, body }), "utf8");
186
+ await fs2.mkdir(dir, { recursive: true });
187
+ await fs2.writeFile(filePath, serializeArticle({ title, tags: opts.tags, created: now, updated: now, body }), "utf8");
188
+ void fireHook("article-created", name, path.relative(dir, filePath));
227
189
  return { title, tags: opts.tags, path: filePath, collection: name };
228
190
  }
229
191
  async function loadArticle(target) {
230
192
  const { collection, file } = parsePathTarget(target);
231
193
  const { dir } = await resolveCollectionDir(collection);
232
- const filePath = path2.join(dir, ensureMdPath(file));
194
+ const filePath = path.join(dir, ensureMdPath(file));
233
195
  try {
234
- return { filePath, raw: await fs4.readFile(filePath, "utf8"), collection };
196
+ return { filePath, dir, raw: await fs2.readFile(filePath, "utf8"), collection };
235
197
  } catch (err) {
236
198
  if (err.code === "ENOENT") throw new ArticleNotFoundError(target);
237
199
  throw err;
238
200
  }
239
201
  }
240
202
  async function updateArticleBody(target, body) {
241
- const { filePath, raw, collection } = await loadArticle(target);
203
+ const { filePath, dir, raw, collection } = await loadArticle(target);
242
204
  const parsed = parseArticle(raw);
243
- await fs4.writeFile(
205
+ await fs2.writeFile(
244
206
  filePath,
245
207
  serializeArticle({
246
208
  title: parsed.title,
@@ -251,13 +213,14 @@ async function updateArticleBody(target, body) {
251
213
  }),
252
214
  "utf8"
253
215
  );
216
+ void fireHook("article-updated", collection, path.relative(dir, filePath));
254
217
  return { title: parsed.title, tags: parsed.tags, path: filePath, collection };
255
218
  }
256
219
  async function mutateTags(target, fn) {
257
- const { filePath, raw, collection } = await loadArticle(target);
220
+ const { filePath, dir, raw, collection } = await loadArticle(target);
258
221
  const parsed = parseArticle(raw);
259
222
  const tags = fn(parsed.tags);
260
- await fs4.writeFile(
223
+ await fs2.writeFile(
261
224
  filePath,
262
225
  serializeArticle({
263
226
  title: parsed.title,
@@ -268,6 +231,7 @@ async function mutateTags(target, fn) {
268
231
  }),
269
232
  "utf8"
270
233
  );
234
+ void fireHook("article-updated", collection, path.relative(dir, filePath));
271
235
  return { title: parsed.title, tags, path: filePath, collection };
272
236
  }
273
237
  function addTags(target, tags) {
@@ -281,34 +245,35 @@ function setTags(target, tags) {
281
245
  return mutateTags(target, () => Array.from(new Set(tags)));
282
246
  }
283
247
  async function removeArticle(target) {
284
- const { filePath } = await loadArticle(target);
285
- await fs4.rm(filePath);
248
+ const { filePath, dir, collection } = await loadArticle(target);
249
+ await fs2.rm(filePath);
250
+ void fireHook("article-removed", collection, path.relative(dir, filePath));
286
251
  return filePath;
287
252
  }
288
253
 
289
254
  // src/repo.ts
290
- import fs5 from "fs/promises";
291
- import path3 from "path";
255
+ import fs3 from "fs/promises";
256
+ import path2 from "path";
292
257
  import { execFile } from "child_process";
293
258
  import { promisify } from "util";
294
- import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
259
+ import { parse as parseYaml2, stringify as stringifyYaml2 } from "yaml";
295
260
  var execFileAsync = promisify(execFile);
296
261
  var REPO_CONFIG_FILE = "diffwiki.yaml";
297
262
  async function detectRepoName(cwd) {
298
263
  try {
299
264
  const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd });
300
- return path3.basename(stdout.trim());
265
+ return path2.basename(stdout.trim());
301
266
  } catch {
302
- return path3.basename(path3.resolve(cwd));
267
+ return path2.basename(path2.resolve(cwd));
303
268
  }
304
269
  }
305
270
  async function readRepoConfig(cwd) {
306
271
  try {
307
- const raw = await fs5.readFile(path3.join(cwd, REPO_CONFIG_FILE), "utf8");
308
- const parsed = parseYaml(raw) ?? {};
272
+ const raw = await fs3.readFile(path2.join(cwd, REPO_CONFIG_FILE), "utf8");
273
+ const parsed = parseYaml2(raw) ?? {};
309
274
  if (!parsed.path) return void 0;
310
275
  return {
311
- collection: parsed.collection ?? path3.basename(path3.resolve(cwd)),
276
+ collection: parsed.collection ?? path2.basename(path2.resolve(cwd)),
312
277
  path: parsed.path
313
278
  };
314
279
  } catch (err) {
@@ -317,9 +282,9 @@ async function readRepoConfig(cwd) {
317
282
  }
318
283
  }
319
284
  async function initRepoWiki(opts) {
320
- const cwd = path3.resolve(opts.cwd);
285
+ const cwd = path2.resolve(opts.cwd);
321
286
  const wikiPath = opts.wikiPath ?? "wiki";
322
- const absWiki = path3.join(cwd, wikiPath);
287
+ const absWiki = path2.join(cwd, wikiPath);
323
288
  let name = opts.collection ?? await detectRepoName(cwd);
324
289
  const existing = await findCollection(name);
325
290
  if (existing && existing.path !== absWiki) {
@@ -334,9 +299,9 @@ async function initRepoWiki(opts) {
334
299
  n += 1;
335
300
  }
336
301
  }
337
- await fs5.mkdir(absWiki, { recursive: true });
302
+ await fs3.mkdir(absWiki, { recursive: true });
338
303
  const config = { collection: name, path: wikiPath };
339
- await fs5.writeFile(path3.join(cwd, REPO_CONFIG_FILE), stringifyYaml(config), "utf8");
304
+ await fs3.writeFile(path2.join(cwd, REPO_CONFIG_FILE), stringifyYaml2(config), "utf8");
340
305
  const entry = {
341
306
  name,
342
307
  type: "repo",
@@ -348,44 +313,356 @@ async function initRepoWiki(opts) {
348
313
  }
349
314
 
350
315
  // src/query.ts
351
- import fs6 from "fs/promises";
352
- import path4 from "path";
353
- async function query(term, collection) {
354
- let targets;
316
+ import fs4 from "fs/promises";
317
+ import path3 from "path";
318
+ async function collectMarkdownFiles(dir) {
319
+ let entries;
320
+ try {
321
+ entries = await fs4.readdir(dir, { withFileTypes: true });
322
+ } catch {
323
+ return [];
324
+ }
325
+ const files = [];
326
+ for (const entry of entries) {
327
+ const full = path3.join(dir, entry.name);
328
+ if (entry.isDirectory()) {
329
+ files.push(...await collectMarkdownFiles(full));
330
+ } else if (entry.isFile() && /\.mdx?$/.test(entry.name)) {
331
+ files.push(full);
332
+ }
333
+ }
334
+ return files;
335
+ }
336
+ var K1 = 1.5;
337
+ var B = 0.75;
338
+ var FIELD_WEIGHTS = { title: 3, tags: 2, collection: 1.5, body: 1 };
339
+ function tokenize(text) {
340
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
341
+ }
342
+ function termFreqs(tokens) {
343
+ const freqs = /* @__PURE__ */ new Map();
344
+ for (const t of tokens) {
345
+ freqs.set(t, (freqs.get(t) ?? 0) + 1);
346
+ }
347
+ return freqs;
348
+ }
349
+ function bm25FieldScore(queryTerms, fieldTokens, avgFieldLen, idf) {
350
+ const tf = termFreqs(fieldTokens);
351
+ const dl = fieldTokens.length;
352
+ const avgDl = avgFieldLen || 1;
353
+ let score = 0;
354
+ for (const term of queryTerms) {
355
+ const freq = tf.get(term) ?? 0;
356
+ if (freq === 0) continue;
357
+ const termIdf = idf.get(term) ?? 0;
358
+ const numerator = freq * (K1 + 1);
359
+ const denominator = freq + K1 * (1 - B + B * (dl / avgDl));
360
+ score += termIdf * (numerator / denominator);
361
+ }
362
+ return score;
363
+ }
364
+ async function nativeSearch(term, collections, collection) {
365
+ let targets = collections;
355
366
  if (collection) {
356
- const one = await findCollection(collection);
367
+ const one = collections.find((c) => c.name === collection);
357
368
  if (!one) throw new CollectionNotFoundError(collection);
358
369
  targets = [one];
359
- } else {
360
- targets = await listCollections();
361
370
  }
362
- const needle = term.toLowerCase();
363
- const hits = [];
371
+ const docs = [];
364
372
  for (const entry of targets) {
365
- let files;
366
- try {
367
- files = await fs6.readdir(entry.path);
368
- } catch {
369
- continue;
373
+ for (const full of await collectMarkdownFiles(entry.path)) {
374
+ const fallbackTitle = path3.basename(full).replace(/\.mdx?$/, "");
375
+ let title = fallbackTitle;
376
+ let tags = [];
377
+ let body = "";
378
+ try {
379
+ const parsed = parseArticle(await fs4.readFile(full, "utf8"));
380
+ if (parsed.title) title = parsed.title;
381
+ tags = parsed.tags;
382
+ body = parsed.body;
383
+ } catch {
384
+ }
385
+ docs.push({
386
+ collectionName: entry.name,
387
+ filePath: full,
388
+ title,
389
+ tags,
390
+ fields: {
391
+ title: tokenize(title),
392
+ tags: tokenize(tags.join(" ")),
393
+ collection: tokenize(entry.name),
394
+ body: tokenize(body)
395
+ }
396
+ });
397
+ }
398
+ }
399
+ if (!term.trim()) {
400
+ return docs.map((d) => ({
401
+ collection: d.collectionName,
402
+ path: d.filePath,
403
+ title: d.title,
404
+ tags: d.tags
405
+ }));
406
+ }
407
+ const queryTerms = tokenize(term);
408
+ if (queryTerms.length === 0) {
409
+ return docs.map((d) => ({
410
+ collection: d.collectionName,
411
+ path: d.filePath,
412
+ title: d.title,
413
+ tags: d.tags
414
+ }));
415
+ }
416
+ const docTokenSets = docs.map(
417
+ (doc) => /* @__PURE__ */ new Set([...doc.fields.title, ...doc.fields.tags, ...doc.fields.collection, ...doc.fields.body])
418
+ );
419
+ const N = docs.length;
420
+ const idf = /* @__PURE__ */ new Map();
421
+ for (const qt of queryTerms) {
422
+ let docFreq = 0;
423
+ for (const tokenSet of docTokenSets) {
424
+ if (tokenSet.has(qt)) docFreq++;
425
+ }
426
+ idf.set(qt, Math.log((N - docFreq + 0.5) / (docFreq + 0.5) + 1));
427
+ }
428
+ const avgLens = {
429
+ title: docs.reduce((sum, d) => sum + d.fields.title.length, 0) / (N || 1),
430
+ tags: docs.reduce((sum, d) => sum + d.fields.tags.length, 0) / (N || 1),
431
+ collection: docs.reduce((sum, d) => sum + d.fields.collection.length, 0) / (N || 1),
432
+ body: docs.reduce((sum, d) => sum + d.fields.body.length, 0) / (N || 1)
433
+ };
434
+ const scored = [];
435
+ for (const doc of docs) {
436
+ const titleScore = bm25FieldScore(queryTerms, doc.fields.title, avgLens.title, idf) * FIELD_WEIGHTS.title;
437
+ const tagsScore = bm25FieldScore(queryTerms, doc.fields.tags, avgLens.tags, idf) * FIELD_WEIGHTS.tags;
438
+ const collScore = bm25FieldScore(queryTerms, doc.fields.collection, avgLens.collection, idf) * FIELD_WEIGHTS.collection;
439
+ const bodyScore = bm25FieldScore(queryTerms, doc.fields.body, avgLens.body, idf) * FIELD_WEIGHTS.body;
440
+ const total = titleScore + tagsScore + collScore + bodyScore;
441
+ if (total > 0) {
442
+ scored.push({ doc, score: total });
370
443
  }
371
- for (const file of files) {
372
- if (!/\.mdx?$/.test(file)) continue;
373
- const full = path4.join(entry.path, file);
374
- const parsed = parseArticle(await fs6.readFile(full, "utf8"));
375
- const haystack = [parsed.title, file, ...parsed.tags].join(" ").toLowerCase();
376
- if (!needle || haystack.includes(needle)) {
377
- hits.push({ collection: entry.name, path: full, title: parsed.title, tags: parsed.tags });
444
+ }
445
+ scored.sort((a, b) => b.score - a.score);
446
+ return scored.map((s) => ({
447
+ collection: s.doc.collectionName,
448
+ path: s.doc.filePath,
449
+ title: s.doc.title,
450
+ tags: s.doc.tags,
451
+ score: s.score
452
+ }));
453
+ }
454
+ async function resolveDefaultEngine() {
455
+ const config = await readConfig();
456
+ const enabled = await listEnabledPlugins("search");
457
+ const configured = config.defaultSearch?.split(":")[0];
458
+ if (configured && enabled.some((p) => p.name === configured)) return configured;
459
+ if (enabled.length > 0) return enabled[0].name;
460
+ return NATIVE_ENGINE;
461
+ }
462
+ async function resolveDefaultSearchMode() {
463
+ const config = await readConfig();
464
+ const enabled = await listEnabledPlugins("search");
465
+ const configured = config.defaultSearch?.split(":")[0];
466
+ const configuredType = config.defaultSearch?.split(":")[1];
467
+ let engine = NATIVE_ENGINE;
468
+ if (configured && enabled.some((p) => p.name === configured)) engine = configured;
469
+ else if (enabled.length > 0) engine = enabled[0].name;
470
+ if (engine === NATIVE_ENGINE) return { engine, type: "basic" };
471
+ const modes = await listSearchModes();
472
+ const engineModes = modes.filter((m) => m.plugin === engine);
473
+ if (engineModes.length === 0) return { engine: NATIVE_ENGINE, type: "basic" };
474
+ if (configuredType && engineModes.some((m) => m.type === configuredType)) {
475
+ return { engine, type: configuredType };
476
+ }
477
+ return { engine, type: engineModes[0].type };
478
+ }
479
+ async function query(term, opts) {
480
+ const collections = await listCollections();
481
+ const engine = opts?.engine ?? await resolveDefaultEngine();
482
+ if (engine === NATIVE_ENGINE) return nativeSearch(term, collections, opts?.collection);
483
+ const provider = await loadSearchProvider(engine);
484
+ if (!provider) return nativeSearch(term, collections, opts?.collection);
485
+ try {
486
+ return await provider.search(term, collections, opts);
487
+ } catch {
488
+ return nativeSearch(term, collections, opts?.collection);
489
+ } finally {
490
+ await provider.close();
491
+ }
492
+ }
493
+
494
+ // src/doctor.ts
495
+ import fs6 from "fs/promises";
496
+
497
+ // src/plugins/manager.ts
498
+ import fs5 from "fs/promises";
499
+ import path4 from "path";
500
+ import { execFile as execFile2 } from "child_process";
501
+ import { promisify as promisify2 } from "util";
502
+ var run = promisify2(execFile2);
503
+ async function readPackageJson(file) {
504
+ try {
505
+ return JSON.parse(await fs5.readFile(file, "utf8"));
506
+ } catch {
507
+ return null;
508
+ }
509
+ }
510
+ function binOf(manifest) {
511
+ if (typeof manifest.bin === "string") return manifest.bin;
512
+ if (manifest.bin && typeof manifest.bin === "object") {
513
+ const first = Object.values(manifest.bin)[0];
514
+ return typeof first === "string" ? first : null;
515
+ }
516
+ return null;
517
+ }
518
+ async function pluginManifestDirs(nodeModules) {
519
+ let entries;
520
+ try {
521
+ entries = await fs5.readdir(nodeModules);
522
+ } catch {
523
+ return [];
524
+ }
525
+ const dirs = [];
526
+ for (const entry of entries) {
527
+ if (entry.startsWith(".")) continue;
528
+ if (entry.startsWith("@")) {
529
+ let scoped;
530
+ try {
531
+ scoped = await fs5.readdir(path4.join(nodeModules, entry));
532
+ } catch {
533
+ continue;
378
534
  }
535
+ for (const pkg of scoped) {
536
+ if (pkg.startsWith(".")) continue;
537
+ dirs.push(path4.join(nodeModules, entry, pkg));
538
+ }
539
+ } else {
540
+ dirs.push(path4.join(nodeModules, entry));
379
541
  }
380
542
  }
381
- return hits;
543
+ return dirs;
544
+ }
545
+ async function ensurePluginRoot() {
546
+ const dir = pluginsDir();
547
+ await fs5.mkdir(dir, { recursive: true });
548
+ const pkgPath = path4.join(dir, "package.json");
549
+ if (await readPackageJson(pkgPath) === null) {
550
+ const pkg = { name: "diffwiki-plugins", private: true, version: "0.0.0" };
551
+ await fs5.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
552
+ `, "utf8");
553
+ }
554
+ return dir;
555
+ }
556
+ async function discoverSearchPackage(dir) {
557
+ const nodeModules = path4.join(dir, "node_modules");
558
+ for (const pkgDir of await pluginManifestDirs(nodeModules)) {
559
+ const manifest = await readPackageJson(path4.join(pkgDir, "package.json"));
560
+ if (!manifest?.name || manifest.diffwiki?.kind !== "search") continue;
561
+ const bin = binOf(manifest);
562
+ if (!bin) continue;
563
+ return { name: manifest.name, version: manifest.version, binAbs: path4.resolve(pkgDir, bin) };
564
+ }
565
+ return null;
566
+ }
567
+ async function bestEffortClose(provider) {
568
+ try {
569
+ await provider?.close();
570
+ } catch {
571
+ }
572
+ }
573
+ async function runSetup(name, prefetchModels) {
574
+ let provider = null;
575
+ try {
576
+ provider = await loadSearchProvider(name);
577
+ if (provider) {
578
+ await provider.setup(await listCollections(), { embed: false, prefetchModels });
579
+ }
580
+ } catch {
581
+ } finally {
582
+ await bestEffortClose(provider);
583
+ }
584
+ }
585
+ async function installPlugin(spec, opts) {
586
+ const dir = await ensurePluginRoot();
587
+ try {
588
+ await run("npm", ["install", spec, "--prefix", dir]);
589
+ } catch (err) {
590
+ throw new PluginError(`npm install ${spec} failed: ${err.message}`);
591
+ }
592
+ const discovered = await discoverSearchPackage(dir);
593
+ if (!discovered) {
594
+ throw new PluginError(
595
+ `no diffwiki search plugin found in "${spec}" (need package.json diffwiki.kind === "search" + bin)`
596
+ );
597
+ }
598
+ const record = {
599
+ name: discovered.name,
600
+ kind: "search",
601
+ command: ["node", discovered.binAbs],
602
+ version: discovered.version,
603
+ enabled: true,
604
+ managed: true
605
+ };
606
+ await upsertPlugin(record);
607
+ if (opts?.setup !== false) {
608
+ await runSetup(record.name, !!opts?.prefetchModels);
609
+ }
610
+ return record;
611
+ }
612
+ async function registerPlugin(name, command) {
613
+ if (command.length === 0) throw new PluginError(`register "${name}": --command must not be empty`);
614
+ const record = { name, kind: "search", command, enabled: true, managed: false };
615
+ await upsertPlugin(record);
616
+ const provider = await loadSearchProvider(name);
617
+ if (!provider) {
618
+ await deregisterPlugin(name);
619
+ throw new PluginError(`plugin "${name}" did not spawn as a search plugin (command: ${command.join(" ")})`);
620
+ }
621
+ try {
622
+ await provider.setup(await listCollections(), { embed: false, prefetchModels: false });
623
+ } catch {
624
+ } finally {
625
+ await bestEffortClose(provider);
626
+ }
627
+ return record;
628
+ }
629
+ async function removePlugin(name) {
630
+ const record = (await readPluginRegistry()).plugins.find((p) => p.name === name);
631
+ if (!record) throw new PluginError(`plugin "${name}" is not registered`);
632
+ if (record.managed) {
633
+ const dir = await ensurePluginRoot();
634
+ try {
635
+ await run("npm", ["uninstall", name, "--prefix", dir]);
636
+ } catch {
637
+ }
638
+ }
639
+ await deregisterPlugin(name);
640
+ }
641
+ async function probeSpawnable(name) {
642
+ let provider = null;
643
+ try {
644
+ provider = await loadSearchProvider(name);
645
+ return provider !== null;
646
+ } catch {
647
+ return false;
648
+ } finally {
649
+ await bestEffortClose(provider);
650
+ }
651
+ }
652
+ async function listPlugins() {
653
+ const registry = await readPluginRegistry();
654
+ const out = [];
655
+ for (const record of registry.plugins) {
656
+ const spawnable = record.enabled ? await probeSpawnable(record.name) : false;
657
+ out.push({ ...record, spawnable });
658
+ }
659
+ return out;
382
660
  }
383
661
 
384
662
  // src/doctor.ts
385
- import fs7 from "fs/promises";
386
663
  async function exists(target) {
387
664
  try {
388
- await fs7.access(target);
665
+ await fs6.access(target);
389
666
  return true;
390
667
  } catch {
391
668
  return false;
@@ -418,46 +695,364 @@ async function doctor() {
418
695
  } else {
419
696
  out.push({ level: "warn", message: "no default collection set" });
420
697
  }
698
+ await appendPluginDiagnostics(out, config);
421
699
  return out;
422
700
  }
701
+ function readinessLine(name, health) {
702
+ const r = health.readiness;
703
+ if (!r) {
704
+ return { level: health.ready ? "ok" : "warn", message: `plugin "${name}" ${health.ready ? "ready" : "not ready"}` };
705
+ }
706
+ if (r.firstSearchCost === "model-download") {
707
+ return {
708
+ level: "warn",
709
+ message: `plugin "${name}": ${r.docsIndexed} doc(s) indexed \u2014 first search will download models (~2GB)`
710
+ };
711
+ }
712
+ if (r.firstSearchCost === "model-load") {
713
+ return {
714
+ level: "warn",
715
+ message: `plugin "${name}": ${r.docsIndexed} doc(s) indexed \u2014 first search loads models (a few seconds)`
716
+ };
717
+ }
718
+ return { level: "ok", message: `plugin "${name}": ${r.docsIndexed} doc(s) indexed \u2014 first search is instant` };
719
+ }
720
+ async function appendPluginDiagnostics(out, config) {
721
+ let plugins = [];
722
+ try {
723
+ plugins = await listPlugins();
724
+ } catch (err) {
725
+ out.push({ level: "error", message: `failed to read plugin registry: ${err.message}` });
726
+ return;
727
+ }
728
+ if (plugins.length === 0) {
729
+ out.push({ level: "warn", message: "no search plugins installed \u2014 using built-in native search" });
730
+ }
731
+ const collections = await listCollections();
732
+ for (const p of plugins) {
733
+ if (!p.enabled) {
734
+ out.push({ level: "warn", message: `plugin "${p.name}" registered but disabled` });
735
+ continue;
736
+ }
737
+ if (!p.spawnable) {
738
+ out.push({
739
+ level: "error",
740
+ message: `plugin "${p.name}" registered but its command is not spawnable: ${p.command.join(" ")}`
741
+ });
742
+ continue;
743
+ }
744
+ out.push({ level: "ok", message: `plugin "${p.name}" enabled and spawnable` });
745
+ let provider = null;
746
+ try {
747
+ provider = await loadSearchProvider(p.name);
748
+ if (!provider) {
749
+ out.push({ level: "error", message: `plugin "${p.name}" failed to load for a health check` });
750
+ continue;
751
+ }
752
+ const health = await provider.health(collections);
753
+ for (const d of health.diagnostics) out.push({ level: d.level, message: `${p.name} \xB7 ${d.message}` });
754
+ out.push(readinessLine(p.name, health));
755
+ } catch (err) {
756
+ out.push({ level: "error", message: `plugin "${p.name}" health check failed: ${err.message}` });
757
+ } finally {
758
+ try {
759
+ await provider?.close();
760
+ } catch {
761
+ }
762
+ }
763
+ }
764
+ if (config.defaultSearch) {
765
+ const engine = config.defaultSearch.split(":")[0];
766
+ const known = engine === NATIVE_ENGINE || plugins.some((p) => p.name === engine && p.enabled);
767
+ if (!known) {
768
+ out.push({
769
+ level: "warn",
770
+ message: `defaultSearch "${config.defaultSearch}" is not installed/enabled \u2014 using native`
771
+ });
772
+ }
773
+ }
774
+ }
775
+
776
+ // src/browse.ts
777
+ import fs7 from "fs/promises";
778
+ import path5 from "path";
779
+ var INDEX_RE = /^(index|readme)\.mdx?$/i;
780
+ function buildArticleTree(relPaths) {
781
+ const root = {};
782
+ for (const raw of relPaths) {
783
+ const rel = raw.replace(/\\/g, "/").replace(/^\/+/, "");
784
+ if (!rel) continue;
785
+ const segs = rel.split("/");
786
+ let cur = root;
787
+ segs.forEach((seg, i) => {
788
+ if (i === segs.length - 1) {
789
+ if (!(seg in cur)) cur[seg] = null;
790
+ } else {
791
+ if (cur[seg] === null || !(seg in cur)) cur[seg] = {};
792
+ cur = cur[seg];
793
+ }
794
+ });
795
+ }
796
+ const stripExt = (f) => f.replace(/\.(mdx?)$/, "");
797
+ const walk = (trie, parents) => {
798
+ const entries = Object.entries(trie);
799
+ const branches = entries.filter(([, v]) => v !== null);
800
+ const leaves = entries.filter(([, v]) => v === null).map(([k]) => k).filter((f) => !INDEX_RE.test(f));
801
+ branches.sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()));
802
+ leaves.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
803
+ const branchNodes = branches.map(([seg, sub]) => ({
804
+ name: seg,
805
+ title: seg,
806
+ // Folders are navigable to their index page (an authored index.md/README,
807
+ // else a generated listing).
808
+ slug: [...parents, seg].join("/"),
809
+ children: walk(sub, [...parents, seg])
810
+ }));
811
+ const leafNodes = leaves.map((file) => {
812
+ const nameNoExt = stripExt(file);
813
+ return { name: nameNoExt, title: nameNoExt, slug: [...parents, nameNoExt].join("/") };
814
+ });
815
+ return [...branchNodes, ...leafNodes];
816
+ };
817
+ return walk(root, []);
818
+ }
819
+ async function collectRelPaths(dir, rel = "") {
820
+ let dirents;
821
+ try {
822
+ dirents = await fs7.readdir(dir, { withFileTypes: true });
823
+ } catch {
824
+ return [];
825
+ }
826
+ const out = [];
827
+ for (const d of dirents) {
828
+ if (d.name.startsWith(".")) continue;
829
+ const childRel = rel ? `${rel}/${d.name}` : d.name;
830
+ if (d.isDirectory()) {
831
+ out.push(...await collectRelPaths(path5.join(dir, d.name), childRel));
832
+ } else if (/\.(mdx?)$/.test(d.name)) {
833
+ out.push(childRel);
834
+ }
835
+ }
836
+ return out;
837
+ }
838
+ async function readdirSafe(dir) {
839
+ try {
840
+ return await fs7.readdir(dir);
841
+ } catch {
842
+ return [];
843
+ }
844
+ }
845
+ function pickIndexFile(names) {
846
+ const rank = (n) => {
847
+ const lower = n.toLowerCase();
848
+ const base = lower.startsWith("index") ? 0 : 1;
849
+ const ext = lower.endsWith(".mdx") ? 0 : 1;
850
+ return base * 2 + ext;
851
+ };
852
+ return names.filter((n) => INDEX_RE.test(n)).sort((a, b) => rank(a) - rank(b))[0];
853
+ }
854
+ async function cheapTitle(absPath) {
855
+ let handle;
856
+ try {
857
+ handle = await fs7.open(absPath, "r");
858
+ const buf = Buffer.alloc(512);
859
+ const { bytesRead } = await handle.read(buf, 0, 512, 0);
860
+ const snippet = buf.subarray(0, bytesRead).toString("utf8");
861
+ const m = snippet.match(/^---[\s\S]*?^title:\s*(.+)$/m);
862
+ if (m) return m[1].trim().replace(/^["']|["']$/g, "");
863
+ } catch {
864
+ } finally {
865
+ await handle?.close();
866
+ }
867
+ return void 0;
868
+ }
869
+ async function enrichTitles(nodes, collDir) {
870
+ return Promise.all(
871
+ nodes.map(async (node) => {
872
+ if (node.children) {
873
+ const children = await enrichTitles(node.children, collDir);
874
+ let title = node.title;
875
+ if (node.slug) {
876
+ const dir = path5.join(collDir, node.slug);
877
+ const idx = pickIndexFile(await readdirSafe(dir));
878
+ if (idx) title = await cheapTitle(path5.join(dir, idx)) ?? title;
879
+ }
880
+ return { ...node, title, children };
881
+ }
882
+ for (const ext of [".mdx", ".md"]) {
883
+ const title = await cheapTitle(path5.join(collDir, `${node.slug}${ext}`));
884
+ if (title) return { ...node, title };
885
+ }
886
+ return node;
887
+ })
888
+ );
889
+ }
890
+ async function listArticleTree(coll) {
891
+ const entry = await findCollection(coll);
892
+ if (!entry) throw new CollectionNotFoundError(coll);
893
+ const rels = await collectRelPaths(entry.path);
894
+ return enrichTitles(buildArticleTree(rels), path5.resolve(entry.path));
895
+ }
896
+ async function readArticle(coll, slug) {
897
+ const entry = await findCollection(coll);
898
+ if (!entry) throw new CollectionNotFoundError(coll);
899
+ const collDir = path5.resolve(entry.path);
900
+ const normalized = slug.replace(/\\/g, "/");
901
+ if (normalized.split("/").some((s) => s === "..")) {
902
+ throw new InvalidTargetError(slug, "path traversal detected");
903
+ }
904
+ const safeSlug = normalized.split("/").filter((s) => s !== "" && s !== ".").join("/");
905
+ const inJail = (p) => p === collDir || p.startsWith(collDir + path5.sep);
906
+ let abs;
907
+ for (const rel of safeSlug ? [`${safeSlug}.mdx`, `${safeSlug}.md`] : []) {
908
+ const candidate = path5.resolve(collDir, rel);
909
+ if (!inJail(candidate)) throw new InvalidTargetError(slug, "path traversal detected");
910
+ try {
911
+ await fs7.access(candidate);
912
+ abs = candidate;
913
+ break;
914
+ } catch {
915
+ }
916
+ }
917
+ if (!abs) {
918
+ const dir = path5.resolve(collDir, safeSlug);
919
+ if (inJail(dir)) {
920
+ const idx = pickIndexFile(await readdirSafe(dir));
921
+ if (idx) abs = path5.join(dir, idx);
922
+ }
923
+ }
924
+ if (!abs) throw new ArticleNotFoundError(`${coll}/${safeSlug}`);
925
+ const parsed = parseArticle(await fs7.readFile(abs, "utf8"));
926
+ const title = parsed.title?.trim() || (safeSlug ? safeSlug.split("/").pop() ?? safeSlug : coll);
927
+ return { coll, slug: safeSlug, title, tags: parsed.tags, body: parsed.body, path: abs };
928
+ }
929
+
930
+ // src/render.ts
931
+ import { unified } from "unified";
932
+ import remarkParse from "remark-parse";
933
+ import remarkGfm from "remark-gfm";
934
+ import remarkRehype from "remark-rehype";
935
+ import rehypeSlug from "rehype-slug";
936
+ import rehypeShiki from "@shikijs/rehype";
937
+ import rehypeStringify from "rehype-stringify";
938
+ import { visit, SKIP } from "unist-util-visit";
939
+ import { toString as hastToString } from "hast-util-to-string";
940
+ var LANG_ALIASES = {
941
+ "c#": "csharp",
942
+ cs: "csharp",
943
+ "c++": "cpp",
944
+ cxx: "cpp",
945
+ hs: "haskell",
946
+ ex: "elixir"
947
+ };
948
+ function rehypeCollectToc(toc) {
949
+ return (tree) => {
950
+ visit(tree, "element", (node) => {
951
+ if (node.tagName !== "h2" && node.tagName !== "h3") return;
952
+ const id = node.properties?.id;
953
+ if (!id) return;
954
+ toc.push({ slug: String(id), text: hastToString(node), depth: node.tagName === "h2" ? 2 : 3 });
955
+ });
956
+ };
957
+ }
958
+ function rehypeStripLeadingH1() {
959
+ return (tree) => {
960
+ let removed = false;
961
+ visit(tree, "element", (node, index, parent) => {
962
+ if (!removed && node.tagName === "h1" && parent && index != null) {
963
+ parent.children.splice(index, 1);
964
+ removed = true;
965
+ return [SKIP, index];
966
+ }
967
+ });
968
+ };
969
+ }
970
+ function rehypeMarkMermaid() {
971
+ return (tree) => {
972
+ visit(tree, "element", (node) => {
973
+ if (node.tagName !== "pre") return;
974
+ const code = node.children.find((c) => c.type === "element" && c.tagName === "code");
975
+ const cls = code?.properties?.className ?? [];
976
+ if (!code || !cls.includes("language-mermaid")) return;
977
+ const source = hastToString(code);
978
+ node.properties = { className: ["mermaid"] };
979
+ node.children = [{ type: "text", value: source }];
980
+ });
981
+ };
982
+ }
983
+ async function renderArticle(body) {
984
+ const toc = [];
985
+ const file = await unified().use(remarkParse).use(remarkGfm).use(remarkRehype).use(rehypeSlug).use(rehypeCollectToc, toc).use(rehypeStripLeadingH1).use(rehypeMarkMermaid).use(rehypeShiki, {
986
+ themes: { light: "github-light", dark: "github-dark" },
987
+ defaultColor: false,
988
+ langAlias: LANG_ALIASES,
989
+ fallbackLanguage: "plaintext"
990
+ }).use(rehypeStringify).process(body);
991
+ return { html: String(file), toc };
992
+ }
423
993
  export {
424
994
  ArticleNotFoundError,
425
995
  CollectionExistsError,
426
996
  CollectionNotFoundError,
427
997
  DiffwikiError,
428
998
  InvalidTargetError,
999
+ NATIVE_ENGINE,
429
1000
  NoDefaultCollectionError,
1001
+ PluginError,
430
1002
  REPO_CONFIG_FILE,
431
1003
  addTags,
1004
+ buildArticleTree,
432
1005
  collectionDir,
433
1006
  collectionsDir,
434
1007
  configPath,
435
1008
  createArticle,
436
1009
  createCollection,
1010
+ deregisterPlugin,
437
1011
  doctor,
1012
+ emitPluginEvent,
1013
+ ensurePluginRoot,
438
1014
  findCollection,
1015
+ findEnabledPlugin,
439
1016
  getConfigValue,
440
1017
  initRepoWiki,
1018
+ installPlugin,
1019
+ listArticleTree,
441
1020
  listCollections,
1021
+ listEnabledPlugins,
1022
+ listPlugins,
1023
+ listSearchModes,
1024
+ loadSearchProvider,
1025
+ nativeSearch,
442
1026
  parseAddTarget,
443
1027
  parseArticle,
444
1028
  parsePathTarget,
1029
+ pluginsDir,
1030
+ pluginsRegistryPath,
445
1031
  query,
1032
+ readArticle,
446
1033
  readConfig,
1034
+ readPluginRegistry,
447
1035
  readRegistry,
448
1036
  readRepoConfig,
449
1037
  registerCollection,
1038
+ registerPlugin,
450
1039
  registryPath,
451
1040
  removeArticle,
452
1041
  removeCollection,
1042
+ removePlugin,
453
1043
  removeTags,
1044
+ renderArticle,
1045
+ resolveDefaultSearchMode,
454
1046
  resolveHome,
455
1047
  serializeArticle,
456
1048
  setConfigValue,
457
1049
  setTags,
458
1050
  slugify,
1051
+ spawnPluginClient,
459
1052
  unregisterCollection,
460
1053
  updateArticleBody,
1054
+ upsertPlugin,
461
1055
  writeConfig,
1056
+ writePluginRegistry,
462
1057
  writeRegistry
463
1058
  };