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/chunk-I3PPUXYY.js +474 -0
- package/dist/events-656CR64Q.js +6 -0
- package/dist/index.cjs +1279 -149
- package/dist/index.d.cts +216 -15
- package/dist/index.d.ts +216 -15
- package/dist/index.js +779 -184
- package/package.json +11 -1
package/dist/index.js
CHANGED
|
@@ -1,148 +1,72 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
44
|
+
return JSON.parse(await fs.readFile(configPath(), "utf8"));
|
|
132
45
|
} catch (err) {
|
|
133
|
-
if (
|
|
46
|
+
if (isMissing(err)) return {};
|
|
134
47
|
throw err;
|
|
135
48
|
}
|
|
136
49
|
}
|
|
137
50
|
async function writeConfig(config) {
|
|
138
|
-
await
|
|
139
|
-
await
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
180
|
-
import
|
|
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 =
|
|
214
|
-
const base = slugify(
|
|
215
|
-
const rel = 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 =
|
|
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
|
|
226
|
-
await
|
|
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 =
|
|
194
|
+
const filePath = path.join(dir, ensureMdPath(file));
|
|
233
195
|
try {
|
|
234
|
-
return { filePath, raw: await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
291
|
-
import
|
|
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
|
|
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
|
|
265
|
+
return path2.basename(stdout.trim());
|
|
301
266
|
} catch {
|
|
302
|
-
return
|
|
267
|
+
return path2.basename(path2.resolve(cwd));
|
|
303
268
|
}
|
|
304
269
|
}
|
|
305
270
|
async function readRepoConfig(cwd) {
|
|
306
271
|
try {
|
|
307
|
-
const raw = await
|
|
308
|
-
const parsed =
|
|
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 ??
|
|
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 =
|
|
285
|
+
const cwd = path2.resolve(opts.cwd);
|
|
321
286
|
const wikiPath = opts.wikiPath ?? "wiki";
|
|
322
|
-
const absWiki =
|
|
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
|
|
302
|
+
await fs3.mkdir(absWiki, { recursive: true });
|
|
338
303
|
const config = { collection: name, path: wikiPath };
|
|
339
|
-
await
|
|
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
|
|
352
|
-
import
|
|
353
|
-
async function
|
|
354
|
-
let
|
|
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 =
|
|
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
|
|
363
|
-
const hits = [];
|
|
371
|
+
const docs = [];
|
|
364
372
|
for (const entry of targets) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
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
|
|
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
|
};
|