codealmanac 0.1.6 → 0.1.8
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-3C5SY5SE.js +1239 -0
- package/dist/chunk-3C5SY5SE.js.map +1 -0
- package/dist/chunk-3LC55TG6.js +566 -0
- package/dist/chunk-3LC55TG6.js.map +1 -0
- package/dist/chunk-4CODZRHH.js +19 -0
- package/dist/chunk-4CODZRHH.js.map +1 -0
- package/dist/chunk-73A5TGBC.js +441 -0
- package/dist/chunk-73A5TGBC.js.map +1 -0
- package/dist/chunk-7JUX4ADQ.js +38 -0
- package/dist/chunk-7JUX4ADQ.js.map +1 -0
- package/dist/chunk-AXFPUHBN.js +227 -0
- package/dist/chunk-AXFPUHBN.js.map +1 -0
- package/dist/chunk-BJVZLP6O.js +145 -0
- package/dist/chunk-BJVZLP6O.js.map +1 -0
- package/dist/chunk-FM3VRDK7.js +20 -0
- package/dist/chunk-FM3VRDK7.js.map +1 -0
- package/dist/chunk-P3LDTCLB.js +34 -0
- package/dist/chunk-P3LDTCLB.js.map +1 -0
- package/dist/chunk-QHQ6YH7U.js +81 -0
- package/dist/chunk-QHQ6YH7U.js.map +1 -0
- package/dist/chunk-Z4MWLVS2.js +355 -0
- package/dist/chunk-Z4MWLVS2.js.map +1 -0
- package/dist/chunk-Z6MBJ3D2.js +203 -0
- package/dist/chunk-Z6MBJ3D2.js.map +1 -0
- package/dist/cli-HIXXCUSQ.js +393 -0
- package/dist/cli-HIXXCUSQ.js.map +1 -0
- package/dist/codealmanac.js +32 -5
- package/dist/codealmanac.js.map +1 -1
- package/dist/doctor-IS6N7V63.js +15 -0
- package/dist/doctor-IS6N7V63.js.map +1 -0
- package/dist/hook-CRJMWSSO.js +12 -0
- package/dist/hook-CRJMWSSO.js.map +1 -0
- package/dist/register-commands-JAPO3AUB.js +2647 -0
- package/dist/register-commands-JAPO3AUB.js.map +1 -0
- package/dist/uninstall-HE2Z2LN2.js +12 -0
- package/dist/uninstall-HE2Z2LN2.js.map +1 -0
- package/dist/update-IL243I4E.js +10 -0
- package/dist/update-IL243I4E.js.map +1 -0
- package/dist/wiki-EHZ7LG7R.js +238 -0
- package/dist/wiki-EHZ7LG7R.js.map +1 -0
- package/package.json +2 -2
- package/dist/cli-GTEC5PC7.js +0 -6237
- package/dist/cli-GTEC5PC7.js.map +0 -1
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
BLUE,
|
|
4
|
+
BOLD,
|
|
5
|
+
DIM,
|
|
6
|
+
GREEN,
|
|
7
|
+
RED,
|
|
8
|
+
RST
|
|
9
|
+
} from "./chunk-FM3VRDK7.js";
|
|
10
|
+
import {
|
|
11
|
+
findNearestAlmanacDir,
|
|
12
|
+
getGlobalAlmanacDir,
|
|
13
|
+
getRegistryPath
|
|
14
|
+
} from "./chunk-7JUX4ADQ.js";
|
|
15
|
+
|
|
16
|
+
// src/indexer/schema.ts
|
|
17
|
+
import Database from "better-sqlite3";
|
|
18
|
+
var SCHEMA_DDL = `
|
|
19
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
20
|
+
slug TEXT PRIMARY KEY,
|
|
21
|
+
title TEXT,
|
|
22
|
+
file_path TEXT NOT NULL,
|
|
23
|
+
content_hash TEXT NOT NULL,
|
|
24
|
+
updated_at INTEGER NOT NULL,
|
|
25
|
+
archived_at INTEGER,
|
|
26
|
+
superseded_by TEXT
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS topics (
|
|
30
|
+
slug TEXT PRIMARY KEY,
|
|
31
|
+
title TEXT,
|
|
32
|
+
description TEXT
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS page_topics (
|
|
36
|
+
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
37
|
+
topic_slug TEXT NOT NULL,
|
|
38
|
+
PRIMARY KEY (page_slug, topic_slug)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS topic_parents (
|
|
42
|
+
child_slug TEXT NOT NULL,
|
|
43
|
+
parent_slug TEXT NOT NULL,
|
|
44
|
+
PRIMARY KEY (child_slug, parent_slug),
|
|
45
|
+
CHECK (child_slug != parent_slug)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS file_refs (
|
|
49
|
+
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
50
|
+
path TEXT NOT NULL,
|
|
51
|
+
original_path TEXT NOT NULL,
|
|
52
|
+
is_dir INTEGER NOT NULL,
|
|
53
|
+
PRIMARY KEY (page_slug, path)
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_file_refs_path ON file_refs(path);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS wikilinks (
|
|
58
|
+
source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
59
|
+
target_slug TEXT NOT NULL,
|
|
60
|
+
PRIMARY KEY (source_slug, target_slug)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS cross_wiki_links (
|
|
64
|
+
source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
65
|
+
target_wiki TEXT NOT NULL,
|
|
66
|
+
target_slug TEXT NOT NULL,
|
|
67
|
+
PRIMARY KEY (source_slug, target_wiki, target_slug)
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
-- NOTE: virtual FTS5 table \u2014 ON DELETE CASCADE from pages does NOT apply.
|
|
71
|
+
-- The indexer must explicitly DELETE FROM fts_pages whenever it removes
|
|
72
|
+
-- or replaces a page row, or we leak orphaned FTS rows.
|
|
73
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts_pages USING fts5(slug, title, content);
|
|
74
|
+
`;
|
|
75
|
+
var SCHEMA_VERSION = 2;
|
|
76
|
+
function openIndex(dbPath) {
|
|
77
|
+
const db = new Database(dbPath);
|
|
78
|
+
const mode = db.pragma("journal_mode", { simple: true });
|
|
79
|
+
if (typeof mode !== "string" || mode.toLowerCase() !== "wal") {
|
|
80
|
+
db.pragma("journal_mode = WAL");
|
|
81
|
+
}
|
|
82
|
+
db.pragma("foreign_keys = ON");
|
|
83
|
+
const rawVersion = db.pragma("user_version", { simple: true });
|
|
84
|
+
const currentVersion = typeof rawVersion === "number" ? rawVersion : 0;
|
|
85
|
+
if (currentVersion < SCHEMA_VERSION) {
|
|
86
|
+
db.exec("DROP TABLE IF EXISTS file_refs");
|
|
87
|
+
try {
|
|
88
|
+
db.exec("UPDATE pages SET content_hash = ''");
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
92
|
+
}
|
|
93
|
+
db.exec(SCHEMA_DDL);
|
|
94
|
+
return db;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/indexer/index.ts
|
|
98
|
+
import { createHash } from "crypto";
|
|
99
|
+
import { existsSync as existsSync4, statSync as statSync2 } from "fs";
|
|
100
|
+
import { readFile as readFile2, utimes } from "fs/promises";
|
|
101
|
+
import { basename, join as join2 } from "path";
|
|
102
|
+
import fg2 from "fast-glob";
|
|
103
|
+
|
|
104
|
+
// src/slug.ts
|
|
105
|
+
function toKebabCase(input) {
|
|
106
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/topics/yaml.ts
|
|
110
|
+
import { existsSync } from "fs";
|
|
111
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
112
|
+
import { dirname } from "path";
|
|
113
|
+
import yaml from "js-yaml";
|
|
114
|
+
async function loadTopicsFile(path) {
|
|
115
|
+
if (!existsSync(path)) {
|
|
116
|
+
return { topics: [] };
|
|
117
|
+
}
|
|
118
|
+
let raw;
|
|
119
|
+
try {
|
|
120
|
+
raw = await readFile(path, "utf8");
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (isNodeError(err) && err.code === "ENOENT") {
|
|
123
|
+
return { topics: [] };
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
const trimmed = raw.trim();
|
|
128
|
+
if (trimmed.length === 0) {
|
|
129
|
+
return { topics: [] };
|
|
130
|
+
}
|
|
131
|
+
let parsed;
|
|
132
|
+
try {
|
|
133
|
+
parsed = yaml.load(raw);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
136
|
+
throw new Error(`topics.yaml at ${path} is not valid YAML: ${message}`);
|
|
137
|
+
}
|
|
138
|
+
if (parsed === null || parsed === void 0) {
|
|
139
|
+
return { topics: [] };
|
|
140
|
+
}
|
|
141
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
142
|
+
throw new Error(`topics.yaml at ${path} must be a mapping`);
|
|
143
|
+
}
|
|
144
|
+
const obj = parsed;
|
|
145
|
+
const rawTopics = obj.topics;
|
|
146
|
+
if (rawTopics === void 0 || rawTopics === null) {
|
|
147
|
+
return { topics: [] };
|
|
148
|
+
}
|
|
149
|
+
if (!Array.isArray(rawTopics)) {
|
|
150
|
+
throw new Error(`topics.yaml at ${path} \u2014 "topics" must be a list`);
|
|
151
|
+
}
|
|
152
|
+
const topics = [];
|
|
153
|
+
for (const item of rawTopics) {
|
|
154
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const entry = item;
|
|
158
|
+
const slugRaw = entry.slug;
|
|
159
|
+
if (typeof slugRaw !== "string" || slugRaw.trim().length === 0) continue;
|
|
160
|
+
const slug = toKebabCase(slugRaw);
|
|
161
|
+
if (slug.length === 0) continue;
|
|
162
|
+
const title = typeof entry.title === "string" && entry.title.trim().length > 0 ? entry.title.trim() : titleCase(slug);
|
|
163
|
+
const description = typeof entry.description === "string" && entry.description.trim().length > 0 ? entry.description.trim() : null;
|
|
164
|
+
const parents = [];
|
|
165
|
+
if (Array.isArray(entry.parents)) {
|
|
166
|
+
for (const p of entry.parents) {
|
|
167
|
+
if (typeof p === "string" && p.trim().length > 0) {
|
|
168
|
+
const ps = toKebabCase(p);
|
|
169
|
+
if (ps.length > 0 && ps !== slug && !parents.includes(ps)) {
|
|
170
|
+
parents.push(ps);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
topics.push({ slug, title, description, parents });
|
|
176
|
+
}
|
|
177
|
+
return { topics };
|
|
178
|
+
}
|
|
179
|
+
async function writeTopicsFile(path, file) {
|
|
180
|
+
const sorted = [...file.topics].sort((a, b) => a.slug.localeCompare(b.slug));
|
|
181
|
+
const doc = {
|
|
182
|
+
topics: sorted.map((t) => {
|
|
183
|
+
return {
|
|
184
|
+
slug: t.slug,
|
|
185
|
+
title: t.title,
|
|
186
|
+
description: t.description,
|
|
187
|
+
parents: t.parents
|
|
188
|
+
};
|
|
189
|
+
})
|
|
190
|
+
};
|
|
191
|
+
const header = `# .almanac/topics.yaml \u2014 source of truth for topic metadata.
|
|
192
|
+
# Managed by \`almanac topics\` commands. User-added comments
|
|
193
|
+
# between entries will be stripped on the next write (js-yaml
|
|
194
|
+
# doesn't round-trip comments). Edit at your own risk \u2014 or use the
|
|
195
|
+
# CLI (\`almanac topics create|link|describe|rename|delete\`)
|
|
196
|
+
# which preserves the structure correctly.
|
|
197
|
+
`;
|
|
198
|
+
const body = yaml.dump(doc, {
|
|
199
|
+
lineWidth: 100,
|
|
200
|
+
noRefs: true,
|
|
201
|
+
sortKeys: false
|
|
202
|
+
});
|
|
203
|
+
const content = `${header}${body}`;
|
|
204
|
+
const tmpPath = `${path}.tmp`;
|
|
205
|
+
const parent = dirname(path);
|
|
206
|
+
if (!existsSync(parent)) {
|
|
207
|
+
await mkdir(parent, { recursive: true });
|
|
208
|
+
}
|
|
209
|
+
await writeFile(tmpPath, content, "utf8");
|
|
210
|
+
await rename(tmpPath, path);
|
|
211
|
+
}
|
|
212
|
+
function findTopic(file, slug) {
|
|
213
|
+
for (const t of file.topics) {
|
|
214
|
+
if (t.slug === slug) return t;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
function ensureTopic(file, slug) {
|
|
219
|
+
const existing = findTopic(file, slug);
|
|
220
|
+
if (existing !== null) return existing;
|
|
221
|
+
const entry = {
|
|
222
|
+
slug,
|
|
223
|
+
title: titleCase(slug),
|
|
224
|
+
description: null,
|
|
225
|
+
parents: []
|
|
226
|
+
};
|
|
227
|
+
file.topics.push(entry);
|
|
228
|
+
return entry;
|
|
229
|
+
}
|
|
230
|
+
function titleCase(slug) {
|
|
231
|
+
if (slug.length === 0) return slug;
|
|
232
|
+
return slug.split("-").filter((s) => s.length > 0).map((s) => `${s[0]?.toUpperCase() ?? ""}${s.slice(1)}`).join(" ");
|
|
233
|
+
}
|
|
234
|
+
function isNodeError(err) {
|
|
235
|
+
return err instanceof Error && "code" in err;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/indexer/frontmatter.ts
|
|
239
|
+
import yaml2 from "js-yaml";
|
|
240
|
+
function parseFrontmatter(raw) {
|
|
241
|
+
const empty = {
|
|
242
|
+
topics: [],
|
|
243
|
+
files: [],
|
|
244
|
+
archived_at: null,
|
|
245
|
+
superseded_by: null,
|
|
246
|
+
supersedes: null,
|
|
247
|
+
body: raw
|
|
248
|
+
};
|
|
249
|
+
if (!raw.startsWith("---")) {
|
|
250
|
+
return empty;
|
|
251
|
+
}
|
|
252
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
253
|
+
if (match === null) {
|
|
254
|
+
return empty;
|
|
255
|
+
}
|
|
256
|
+
const yamlBody = match[1] ?? "";
|
|
257
|
+
const body = match[2] ?? "";
|
|
258
|
+
let parsed;
|
|
259
|
+
try {
|
|
260
|
+
parsed = yaml2.load(yamlBody);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
263
|
+
process.stderr.write(`almanac: malformed frontmatter (${message})
|
|
264
|
+
`);
|
|
265
|
+
return empty;
|
|
266
|
+
}
|
|
267
|
+
if (parsed === null || parsed === void 0) {
|
|
268
|
+
return { ...empty, body };
|
|
269
|
+
}
|
|
270
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
271
|
+
return { ...empty, body };
|
|
272
|
+
}
|
|
273
|
+
const obj = parsed;
|
|
274
|
+
return {
|
|
275
|
+
title: coerceString(obj.title),
|
|
276
|
+
topics: coerceStringArray(obj.topics),
|
|
277
|
+
files: coerceStringArray(obj.files),
|
|
278
|
+
archived_at: coerceEpochSeconds(obj.archived_at),
|
|
279
|
+
superseded_by: coerceString(obj.superseded_by) ?? null,
|
|
280
|
+
supersedes: coerceString(obj.supersedes) ?? null,
|
|
281
|
+
body
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function firstH1(body) {
|
|
285
|
+
const lines = body.split(/\r?\n/, 40);
|
|
286
|
+
for (const line of lines) {
|
|
287
|
+
const m = line.match(/^#\s+(.+?)\s*#*\s*$/);
|
|
288
|
+
if (m !== null) {
|
|
289
|
+
return m[1];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return void 0;
|
|
293
|
+
}
|
|
294
|
+
function coerceString(v) {
|
|
295
|
+
if (typeof v === "string" && v.trim().length > 0) return v.trim();
|
|
296
|
+
return void 0;
|
|
297
|
+
}
|
|
298
|
+
function coerceStringArray(v) {
|
|
299
|
+
if (!Array.isArray(v)) return [];
|
|
300
|
+
const out = [];
|
|
301
|
+
for (const item of v) {
|
|
302
|
+
if (typeof item === "string" && item.trim().length > 0) {
|
|
303
|
+
out.push(item.trim());
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
function coerceEpochSeconds(v) {
|
|
309
|
+
if (v instanceof Date) {
|
|
310
|
+
return Math.floor(v.getTime() / 1e3);
|
|
311
|
+
}
|
|
312
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
313
|
+
return Math.floor(v);
|
|
314
|
+
}
|
|
315
|
+
if (typeof v === "string" && v.trim().length > 0) {
|
|
316
|
+
const t = Date.parse(v.trim());
|
|
317
|
+
if (!Number.isNaN(t)) {
|
|
318
|
+
return Math.floor(t / 1e3);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/indexer/freshness.ts
|
|
325
|
+
import { existsSync as existsSync2, statSync } from "fs";
|
|
326
|
+
import { join } from "path";
|
|
327
|
+
import fg from "fast-glob";
|
|
328
|
+
var PAGES_GLOB = "**/*.md";
|
|
329
|
+
function pagesNewerThan(pagesDir, dbPath) {
|
|
330
|
+
let dbMtime;
|
|
331
|
+
try {
|
|
332
|
+
dbMtime = statSync(dbPath).mtimeMs;
|
|
333
|
+
} catch {
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
const entries = fg.sync(PAGES_GLOB, {
|
|
337
|
+
cwd: pagesDir,
|
|
338
|
+
absolute: true,
|
|
339
|
+
onlyFiles: true,
|
|
340
|
+
stats: true
|
|
341
|
+
});
|
|
342
|
+
for (const entry of entries) {
|
|
343
|
+
const mtime = entry.stats?.mtimeMs;
|
|
344
|
+
if (mtime !== void 0 && mtime > dbMtime) return true;
|
|
345
|
+
}
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
function topicsYamlNewerThan(almanacDir, dbPath) {
|
|
349
|
+
const path = join(almanacDir, "topics.yaml");
|
|
350
|
+
if (!existsSync2(path)) return false;
|
|
351
|
+
let dbMtime;
|
|
352
|
+
try {
|
|
353
|
+
dbMtime = statSync(dbPath).mtimeMs;
|
|
354
|
+
} catch {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const st = statSync(path);
|
|
359
|
+
return st.mtimeMs > dbMtime;
|
|
360
|
+
} catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/indexer/paths.ts
|
|
366
|
+
function normalizePath(raw, isDir) {
|
|
367
|
+
const normalized = normalizeShape(raw, isDir);
|
|
368
|
+
return normalized.toLowerCase();
|
|
369
|
+
}
|
|
370
|
+
function normalizePathPreservingCase(raw, isDir) {
|
|
371
|
+
return normalizeShape(raw, isDir);
|
|
372
|
+
}
|
|
373
|
+
function normalizeShape(raw, isDir) {
|
|
374
|
+
let s = raw.trim();
|
|
375
|
+
s = s.replace(/\\+/g, "/");
|
|
376
|
+
while (s.startsWith("./")) s = s.slice(2);
|
|
377
|
+
s = s.replace(/\/+/g, "/");
|
|
378
|
+
s = s.replace(/\/+$/, "");
|
|
379
|
+
if (isDir) {
|
|
380
|
+
return `${s}/`;
|
|
381
|
+
}
|
|
382
|
+
return s;
|
|
383
|
+
}
|
|
384
|
+
function looksLikeDir(raw) {
|
|
385
|
+
const s = raw.trim().replace(/\\+/g, "/");
|
|
386
|
+
return s.endsWith("/");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/indexer/topics-yaml.ts
|
|
390
|
+
import { existsSync as existsSync3 } from "fs";
|
|
391
|
+
var TOPICS_YAML_FILENAME = "topics.yaml";
|
|
392
|
+
async function applyTopicsYaml(db, topicsYamlPath) {
|
|
393
|
+
if (!existsSync3(topicsYamlPath)) return;
|
|
394
|
+
let file;
|
|
395
|
+
try {
|
|
396
|
+
file = await loadTopicsFile(topicsYamlPath);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
399
|
+
process.stderr.write(`almanac: ${message}
|
|
400
|
+
`);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const upsertTopic = db.prepare(
|
|
404
|
+
`INSERT INTO topics (slug, title, description) VALUES (?, ?, ?)
|
|
405
|
+
ON CONFLICT(slug) DO UPDATE SET
|
|
406
|
+
title = excluded.title,
|
|
407
|
+
description = excluded.description`
|
|
408
|
+
);
|
|
409
|
+
const clearParents = db.prepare(
|
|
410
|
+
"DELETE FROM topic_parents WHERE child_slug = ?"
|
|
411
|
+
);
|
|
412
|
+
const insertParent = db.prepare(
|
|
413
|
+
"INSERT OR IGNORE INTO topic_parents (child_slug, parent_slug) VALUES (?, ?)"
|
|
414
|
+
);
|
|
415
|
+
const declared = /* @__PURE__ */ new Set();
|
|
416
|
+
for (const t of file.topics) declared.add(t.slug);
|
|
417
|
+
const adHoc = db.prepare(
|
|
418
|
+
"SELECT DISTINCT topic_slug FROM page_topics"
|
|
419
|
+
).all();
|
|
420
|
+
for (const r of adHoc) declared.add(r.topic_slug);
|
|
421
|
+
const apply = db.transaction(() => {
|
|
422
|
+
for (const t of file.topics) {
|
|
423
|
+
upsertTopic.run(t.slug, t.title, t.description);
|
|
424
|
+
clearParents.run(t.slug);
|
|
425
|
+
for (const parent of t.parents) {
|
|
426
|
+
if (parent === t.slug) continue;
|
|
427
|
+
insertParent.run(t.slug, parent);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const existing = db.prepare("SELECT slug FROM topics").all();
|
|
431
|
+
const deleteTopic = db.prepare("DELETE FROM topics WHERE slug = ?");
|
|
432
|
+
const deleteEdgesByChild = db.prepare(
|
|
433
|
+
"DELETE FROM topic_parents WHERE child_slug = ?"
|
|
434
|
+
);
|
|
435
|
+
const deleteEdgesByParent = db.prepare(
|
|
436
|
+
"DELETE FROM topic_parents WHERE parent_slug = ?"
|
|
437
|
+
);
|
|
438
|
+
for (const r of existing) {
|
|
439
|
+
if (declared.has(r.slug)) continue;
|
|
440
|
+
deleteEdgesByChild.run(r.slug);
|
|
441
|
+
deleteEdgesByParent.run(r.slug);
|
|
442
|
+
deleteTopic.run(r.slug);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
apply();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/indexer/wikilinks.ts
|
|
449
|
+
function classifyWikilink(raw) {
|
|
450
|
+
const pipe = raw.indexOf("|");
|
|
451
|
+
let body = pipe === -1 ? raw : raw.slice(0, pipe);
|
|
452
|
+
body = body.trim();
|
|
453
|
+
if (body.length === 0) return null;
|
|
454
|
+
const firstColon = body.indexOf(":");
|
|
455
|
+
const firstSlash = body.indexOf("/");
|
|
456
|
+
if (firstColon !== -1 && (firstSlash === -1 || firstColon < firstSlash)) {
|
|
457
|
+
const wiki = body.slice(0, firstColon).trim();
|
|
458
|
+
const target2 = body.slice(firstColon + 1).trim();
|
|
459
|
+
if (wiki.length === 0 || target2.length === 0) return null;
|
|
460
|
+
return { kind: "xwiki", wiki, target: target2 };
|
|
461
|
+
}
|
|
462
|
+
if (firstSlash !== -1) {
|
|
463
|
+
const isDir = looksLikeDir(body);
|
|
464
|
+
const path = normalizePath(body, isDir);
|
|
465
|
+
const originalPath = normalizePathPreservingCase(body, isDir);
|
|
466
|
+
if (path.length === 0) return null;
|
|
467
|
+
return isDir ? { kind: "folder", path, originalPath } : { kind: "file", path, originalPath };
|
|
468
|
+
}
|
|
469
|
+
const target = toKebabCase(body);
|
|
470
|
+
if (target.length === 0) return null;
|
|
471
|
+
return { kind: "page", target };
|
|
472
|
+
}
|
|
473
|
+
function extractWikilinks(body) {
|
|
474
|
+
const out = [];
|
|
475
|
+
const re = /\[\[([^\]\n]+)\]\]/g;
|
|
476
|
+
let m;
|
|
477
|
+
while ((m = re.exec(body)) !== null) {
|
|
478
|
+
const ref = classifyWikilink(m[1] ?? "");
|
|
479
|
+
if (ref !== null) out.push(ref);
|
|
480
|
+
}
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/indexer/index.ts
|
|
485
|
+
async function ensureFreshIndex(ctx) {
|
|
486
|
+
const almanacDir = join2(ctx.repoRoot, ".almanac");
|
|
487
|
+
const dbPath = join2(almanacDir, "index.db");
|
|
488
|
+
const pagesDir = join2(almanacDir, "pages");
|
|
489
|
+
if (!existsSync4(pagesDir)) {
|
|
490
|
+
const db = openIndex(dbPath);
|
|
491
|
+
db.close();
|
|
492
|
+
return emptyResult();
|
|
493
|
+
}
|
|
494
|
+
if (!existsSync4(dbPath) || pagesNewerThan(pagesDir, dbPath) || topicsYamlNewerThan(almanacDir, dbPath)) {
|
|
495
|
+
return runIndexer(ctx);
|
|
496
|
+
}
|
|
497
|
+
return emptyResult();
|
|
498
|
+
}
|
|
499
|
+
function emptyResult() {
|
|
500
|
+
return {
|
|
501
|
+
changed: 0,
|
|
502
|
+
removed: 0,
|
|
503
|
+
total: 0,
|
|
504
|
+
pagesIndexed: 0,
|
|
505
|
+
filesSeen: 0,
|
|
506
|
+
filesSkipped: 0
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
async function runIndexer(ctx) {
|
|
510
|
+
const almanacDir = join2(ctx.repoRoot, ".almanac");
|
|
511
|
+
const dbPath = join2(almanacDir, "index.db");
|
|
512
|
+
const pagesDir = join2(almanacDir, "pages");
|
|
513
|
+
const db = openIndex(dbPath);
|
|
514
|
+
let result;
|
|
515
|
+
try {
|
|
516
|
+
result = await indexPagesInto(db, pagesDir);
|
|
517
|
+
await applyTopicsYaml(db, join2(almanacDir, TOPICS_YAML_FILENAME));
|
|
518
|
+
} finally {
|
|
519
|
+
db.close();
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
const now = /* @__PURE__ */ new Date();
|
|
523
|
+
await utimes(dbPath, now, now);
|
|
524
|
+
} catch {
|
|
525
|
+
}
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
528
|
+
async function indexPagesInto(db, pagesDir) {
|
|
529
|
+
const files = await fg2(PAGES_GLOB, {
|
|
530
|
+
cwd: pagesDir,
|
|
531
|
+
absolute: false,
|
|
532
|
+
onlyFiles: true,
|
|
533
|
+
caseSensitiveMatch: true
|
|
534
|
+
});
|
|
535
|
+
const existingRows = db.prepare("SELECT slug, content_hash, file_path FROM pages").all();
|
|
536
|
+
const existingBySlug = /* @__PURE__ */ new Map();
|
|
537
|
+
for (const row of existingRows) existingBySlug.set(row.slug, row);
|
|
538
|
+
const planned = [];
|
|
539
|
+
const seenSlugs = /* @__PURE__ */ new Set();
|
|
540
|
+
let filesSkipped = 0;
|
|
541
|
+
for (const rel of files) {
|
|
542
|
+
const fullPath = join2(pagesDir, rel);
|
|
543
|
+
const base = basename(rel, ".md");
|
|
544
|
+
const slug = toKebabCase(base);
|
|
545
|
+
if (slug.length === 0) {
|
|
546
|
+
process.stderr.write(
|
|
547
|
+
`almanac: skipping "${rel}" \u2014 filename has no slug-able characters
|
|
548
|
+
`
|
|
549
|
+
);
|
|
550
|
+
filesSkipped++;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (slug !== base) {
|
|
554
|
+
process.stderr.write(
|
|
555
|
+
`almanac: warning \u2014 "${rel}" is not canonical; indexed as slug "${slug}"
|
|
556
|
+
`
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
if (seenSlugs.has(slug)) {
|
|
560
|
+
process.stderr.write(
|
|
561
|
+
`almanac: warning \u2014 slug "${slug}" collides with an earlier file; skipping "${rel}"
|
|
562
|
+
`
|
|
563
|
+
);
|
|
564
|
+
filesSkipped++;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
let st;
|
|
568
|
+
let raw;
|
|
569
|
+
try {
|
|
570
|
+
st = statSync2(fullPath);
|
|
571
|
+
raw = await readFile2(fullPath, "utf8");
|
|
572
|
+
} catch (err) {
|
|
573
|
+
if (err instanceof Error && "code" in err && (err.code === "ENOENT" || err.code === "EACCES")) {
|
|
574
|
+
process.stderr.write(
|
|
575
|
+
`almanac: skipping "${rel}" \u2014 ${err.message}
|
|
576
|
+
`
|
|
577
|
+
);
|
|
578
|
+
filesSkipped++;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
throw err;
|
|
582
|
+
}
|
|
583
|
+
seenSlugs.add(slug);
|
|
584
|
+
const updatedAt = Math.floor(st.mtimeMs / 1e3);
|
|
585
|
+
const contentHash = hashContent(raw);
|
|
586
|
+
const existing = existingBySlug.get(slug);
|
|
587
|
+
if (existing !== void 0 && existing.content_hash === contentHash && existing.file_path === fullPath) {
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const fm = parseFrontmatter(raw);
|
|
591
|
+
const title = fm.title ?? firstH1(fm.body) ?? base;
|
|
592
|
+
const links = extractWikilinks(fm.body);
|
|
593
|
+
planned.push({
|
|
594
|
+
slug,
|
|
595
|
+
title,
|
|
596
|
+
filePath: rel,
|
|
597
|
+
fullPath,
|
|
598
|
+
contentHash,
|
|
599
|
+
updatedAt,
|
|
600
|
+
archivedAt: fm.archived_at,
|
|
601
|
+
supersededBy: fm.superseded_by,
|
|
602
|
+
topics: fm.topics,
|
|
603
|
+
frontmatterFiles: fm.files,
|
|
604
|
+
wikilinks: links,
|
|
605
|
+
content: fm.body
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
const toDelete = [];
|
|
609
|
+
for (const slug of existingBySlug.keys()) {
|
|
610
|
+
if (!seenSlugs.has(slug)) toDelete.push(slug);
|
|
611
|
+
}
|
|
612
|
+
const deleteByPage = db.prepare("DELETE FROM pages WHERE slug = ?");
|
|
613
|
+
const deleteFtsByPage = db.prepare(
|
|
614
|
+
"DELETE FROM fts_pages WHERE slug = ?"
|
|
615
|
+
);
|
|
616
|
+
const replacePage = db.prepare(
|
|
617
|
+
`INSERT INTO pages (slug, title, file_path, content_hash, updated_at, archived_at, superseded_by)
|
|
618
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
619
|
+
ON CONFLICT(slug) DO UPDATE SET
|
|
620
|
+
title = excluded.title,
|
|
621
|
+
file_path = excluded.file_path,
|
|
622
|
+
content_hash = excluded.content_hash,
|
|
623
|
+
updated_at = excluded.updated_at,
|
|
624
|
+
archived_at = excluded.archived_at,
|
|
625
|
+
superseded_by = excluded.superseded_by`
|
|
626
|
+
);
|
|
627
|
+
const deletePageTopics = db.prepare(
|
|
628
|
+
"DELETE FROM page_topics WHERE page_slug = ?"
|
|
629
|
+
);
|
|
630
|
+
const insertPageTopic = db.prepare(
|
|
631
|
+
"INSERT OR IGNORE INTO page_topics (page_slug, topic_slug) VALUES (?, ?)"
|
|
632
|
+
);
|
|
633
|
+
const insertTopic = db.prepare(
|
|
634
|
+
"INSERT OR IGNORE INTO topics (slug, title) VALUES (?, ?)"
|
|
635
|
+
);
|
|
636
|
+
const deleteFileRefs = db.prepare(
|
|
637
|
+
"DELETE FROM file_refs WHERE page_slug = ?"
|
|
638
|
+
);
|
|
639
|
+
const insertFileRef = db.prepare(
|
|
640
|
+
"INSERT OR IGNORE INTO file_refs (page_slug, path, original_path, is_dir) VALUES (?, ?, ?, ?)"
|
|
641
|
+
);
|
|
642
|
+
const deleteWikilinks = db.prepare(
|
|
643
|
+
"DELETE FROM wikilinks WHERE source_slug = ?"
|
|
644
|
+
);
|
|
645
|
+
const insertWikilink = db.prepare(
|
|
646
|
+
"INSERT OR IGNORE INTO wikilinks (source_slug, target_slug) VALUES (?, ?)"
|
|
647
|
+
);
|
|
648
|
+
const deleteXwiki = db.prepare(
|
|
649
|
+
"DELETE FROM cross_wiki_links WHERE source_slug = ?"
|
|
650
|
+
);
|
|
651
|
+
const insertXwiki = db.prepare(
|
|
652
|
+
"INSERT OR IGNORE INTO cross_wiki_links (source_slug, target_wiki, target_slug) VALUES (?, ?, ?)"
|
|
653
|
+
);
|
|
654
|
+
const insertFts = db.prepare(
|
|
655
|
+
"INSERT INTO fts_pages (slug, title, content) VALUES (?, ?, ?)"
|
|
656
|
+
);
|
|
657
|
+
const apply = db.transaction(() => {
|
|
658
|
+
for (const slug of toDelete) {
|
|
659
|
+
deleteFtsByPage.run(slug);
|
|
660
|
+
deleteByPage.run(slug);
|
|
661
|
+
}
|
|
662
|
+
for (const p of planned) {
|
|
663
|
+
deletePageTopics.run(p.slug);
|
|
664
|
+
deleteFileRefs.run(p.slug);
|
|
665
|
+
deleteWikilinks.run(p.slug);
|
|
666
|
+
deleteXwiki.run(p.slug);
|
|
667
|
+
deleteFtsByPage.run(p.slug);
|
|
668
|
+
replacePage.run(
|
|
669
|
+
p.slug,
|
|
670
|
+
p.title,
|
|
671
|
+
p.fullPath,
|
|
672
|
+
p.contentHash,
|
|
673
|
+
p.updatedAt,
|
|
674
|
+
p.archivedAt,
|
|
675
|
+
p.supersededBy
|
|
676
|
+
);
|
|
677
|
+
for (const topic of p.topics) {
|
|
678
|
+
const topicSlug = toKebabCase(topic);
|
|
679
|
+
if (topicSlug.length === 0) continue;
|
|
680
|
+
insertTopic.run(topicSlug, titleCase(topicSlug));
|
|
681
|
+
insertPageTopic.run(p.slug, topicSlug);
|
|
682
|
+
}
|
|
683
|
+
for (const raw of p.frontmatterFiles) {
|
|
684
|
+
const isDir = looksLikeDir(raw);
|
|
685
|
+
const path = normalizePath(raw, isDir);
|
|
686
|
+
const originalPath = normalizePathPreservingCase(raw, isDir);
|
|
687
|
+
if (path.length === 0) continue;
|
|
688
|
+
insertFileRef.run(p.slug, path, originalPath, isDir ? 1 : 0);
|
|
689
|
+
}
|
|
690
|
+
for (const ref of p.wikilinks) {
|
|
691
|
+
switch (ref.kind) {
|
|
692
|
+
case "page":
|
|
693
|
+
insertWikilink.run(p.slug, ref.target);
|
|
694
|
+
break;
|
|
695
|
+
case "file":
|
|
696
|
+
insertFileRef.run(p.slug, ref.path, ref.originalPath, 0);
|
|
697
|
+
break;
|
|
698
|
+
case "folder":
|
|
699
|
+
insertFileRef.run(p.slug, ref.path, ref.originalPath, 1);
|
|
700
|
+
break;
|
|
701
|
+
case "xwiki":
|
|
702
|
+
insertXwiki.run(p.slug, ref.wiki, ref.target);
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
insertFts.run(p.slug, p.title, p.content);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
apply();
|
|
710
|
+
const pagesIndexed = seenSlugs.size;
|
|
711
|
+
return {
|
|
712
|
+
changed: planned.length,
|
|
713
|
+
removed: toDelete.length,
|
|
714
|
+
total: pagesIndexed,
|
|
715
|
+
pagesIndexed,
|
|
716
|
+
filesSeen: files.length,
|
|
717
|
+
filesSkipped
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
function hashContent(raw) {
|
|
721
|
+
return createHash("sha256").update(raw).digest("hex");
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/registry/index.ts
|
|
725
|
+
import { mkdir as mkdir2, readFile as readFile3, rename as rename2, writeFile as writeFile2 } from "fs/promises";
|
|
726
|
+
import { dirname as dirname2 } from "path";
|
|
727
|
+
async function readRegistry() {
|
|
728
|
+
const path = getRegistryPath();
|
|
729
|
+
let raw;
|
|
730
|
+
try {
|
|
731
|
+
raw = await readFile3(path, "utf8");
|
|
732
|
+
} catch (err) {
|
|
733
|
+
if (isNodeError2(err) && err.code === "ENOENT") {
|
|
734
|
+
return [];
|
|
735
|
+
}
|
|
736
|
+
throw err;
|
|
737
|
+
}
|
|
738
|
+
const trimmed = raw.trim();
|
|
739
|
+
if (trimmed.length === 0) {
|
|
740
|
+
return [];
|
|
741
|
+
}
|
|
742
|
+
let parsed;
|
|
743
|
+
try {
|
|
744
|
+
parsed = JSON.parse(trimmed);
|
|
745
|
+
} catch (err) {
|
|
746
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
747
|
+
throw new Error(`registry at ${path} is not valid JSON: ${message}`);
|
|
748
|
+
}
|
|
749
|
+
if (!Array.isArray(parsed)) {
|
|
750
|
+
throw new Error(`registry at ${path} must be a JSON array`);
|
|
751
|
+
}
|
|
752
|
+
return parsed.map((item, idx) => {
|
|
753
|
+
if (typeof item !== "object" || item === null) {
|
|
754
|
+
throw new Error(`registry entry ${idx} is not an object`);
|
|
755
|
+
}
|
|
756
|
+
const e = item;
|
|
757
|
+
const name = typeof e.name === "string" ? e.name : "";
|
|
758
|
+
const path2 = typeof e.path === "string" ? e.path : "";
|
|
759
|
+
if (name.length === 0) {
|
|
760
|
+
throw new Error(`registry entry ${idx} is missing a non-empty "name"`);
|
|
761
|
+
}
|
|
762
|
+
if (path2.length === 0) {
|
|
763
|
+
throw new Error(`registry entry ${idx} is missing a non-empty "path"`);
|
|
764
|
+
}
|
|
765
|
+
return {
|
|
766
|
+
name,
|
|
767
|
+
description: typeof e.description === "string" ? e.description : "",
|
|
768
|
+
path: path2,
|
|
769
|
+
registered_at: typeof e.registered_at === "string" ? e.registered_at : ""
|
|
770
|
+
};
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
async function writeRegistry(entries) {
|
|
774
|
+
const path = getRegistryPath();
|
|
775
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
776
|
+
const body = `${JSON.stringify(entries, null, 2)}
|
|
777
|
+
`;
|
|
778
|
+
const tmpPath = `${path}.tmp`;
|
|
779
|
+
await writeFile2(tmpPath, body, "utf8");
|
|
780
|
+
await rename2(tmpPath, path);
|
|
781
|
+
}
|
|
782
|
+
function pathsEqual(a, b) {
|
|
783
|
+
if (process.platform === "darwin" || process.platform === "win32") {
|
|
784
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
785
|
+
}
|
|
786
|
+
return a === b;
|
|
787
|
+
}
|
|
788
|
+
async function addEntry(entry) {
|
|
789
|
+
const existing = await readRegistry();
|
|
790
|
+
const filtered = existing.filter(
|
|
791
|
+
(e) => e.name !== entry.name && !pathsEqual(e.path, entry.path)
|
|
792
|
+
);
|
|
793
|
+
filtered.push(entry);
|
|
794
|
+
await writeRegistry(filtered);
|
|
795
|
+
return filtered;
|
|
796
|
+
}
|
|
797
|
+
async function dropEntry(name) {
|
|
798
|
+
const existing = await readRegistry();
|
|
799
|
+
const idx = existing.findIndex((e) => e.name === name);
|
|
800
|
+
if (idx === -1) {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
const [removed] = existing.splice(idx, 1);
|
|
804
|
+
await writeRegistry(existing);
|
|
805
|
+
return removed ?? null;
|
|
806
|
+
}
|
|
807
|
+
async function findEntry(params) {
|
|
808
|
+
const entries = await readRegistry();
|
|
809
|
+
for (const entry of entries) {
|
|
810
|
+
if (params.name !== void 0 && entry.name === params.name) return entry;
|
|
811
|
+
if (params.path !== void 0 && pathsEqual(entry.path, params.path)) {
|
|
812
|
+
return entry;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
async function ensureGlobalDir() {
|
|
818
|
+
await mkdir2(getGlobalAlmanacDir(), { recursive: true });
|
|
819
|
+
}
|
|
820
|
+
function isNodeError2(err) {
|
|
821
|
+
return err instanceof Error && "code" in err;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/commands/health.ts
|
|
825
|
+
import { existsSync as existsSync6 } from "fs";
|
|
826
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
827
|
+
import { basename as basename2, join as join4 } from "path";
|
|
828
|
+
import fg3 from "fast-glob";
|
|
829
|
+
|
|
830
|
+
// src/indexer/duration.ts
|
|
831
|
+
function parseDuration(input) {
|
|
832
|
+
const trimmed = input.trim();
|
|
833
|
+
const m = trimmed.match(/^(\d+)([mhdw])$/);
|
|
834
|
+
if (m === null) {
|
|
835
|
+
throw new Error(
|
|
836
|
+
`invalid duration "${input}" (expected Nw, Nd, Nh, or Nm \u2014 e.g. 2w, 30d)`
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
const n = Number.parseInt(m[1] ?? "0", 10);
|
|
840
|
+
const unit = m[2];
|
|
841
|
+
switch (unit) {
|
|
842
|
+
case "m":
|
|
843
|
+
return n * 60;
|
|
844
|
+
case "h":
|
|
845
|
+
return n * 60 * 60;
|
|
846
|
+
case "d":
|
|
847
|
+
return n * 60 * 60 * 24;
|
|
848
|
+
case "w":
|
|
849
|
+
return n * 60 * 60 * 24 * 7;
|
|
850
|
+
default:
|
|
851
|
+
throw new Error(`invalid duration unit "${unit ?? ""}"`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// src/indexer/resolve-wiki.ts
|
|
856
|
+
import { existsSync as existsSync5 } from "fs";
|
|
857
|
+
import { join as join3 } from "path";
|
|
858
|
+
async function resolveWikiRoot(params) {
|
|
859
|
+
if (params.wiki !== void 0) {
|
|
860
|
+
const entry = await findEntry({ name: params.wiki });
|
|
861
|
+
if (entry === null) {
|
|
862
|
+
throw new Error(`no registered wiki named "${params.wiki}"`);
|
|
863
|
+
}
|
|
864
|
+
if (!existsSync5(join3(entry.path, ".almanac"))) {
|
|
865
|
+
throw new Error(
|
|
866
|
+
`wiki "${params.wiki}" path is unreachable (${entry.path})`
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
return entry.path;
|
|
870
|
+
}
|
|
871
|
+
const nearest = findNearestAlmanacDir(params.cwd);
|
|
872
|
+
if (nearest === null) {
|
|
873
|
+
throw new Error(
|
|
874
|
+
"no .almanac/ found in this directory or any parent; run `almanac bootstrap` first"
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
return nearest;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// src/topics/dag.ts
|
|
881
|
+
var DAG_DEPTH_CAP = 32;
|
|
882
|
+
function ancestorsInFile(file, slug) {
|
|
883
|
+
const parentsOf = /* @__PURE__ */ new Map();
|
|
884
|
+
for (const t of file.topics) {
|
|
885
|
+
parentsOf.set(t.slug, t.parents);
|
|
886
|
+
}
|
|
887
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
888
|
+
let frontier = parentsOf.get(slug) ?? [];
|
|
889
|
+
let depth = 0;
|
|
890
|
+
while (frontier.length > 0 && depth < DAG_DEPTH_CAP) {
|
|
891
|
+
const next = [];
|
|
892
|
+
for (const node of frontier) {
|
|
893
|
+
if (ancestors.has(node)) continue;
|
|
894
|
+
ancestors.add(node);
|
|
895
|
+
const ps = parentsOf.get(node);
|
|
896
|
+
if (ps !== void 0) next.push(...ps);
|
|
897
|
+
}
|
|
898
|
+
frontier = next;
|
|
899
|
+
depth += 1;
|
|
900
|
+
}
|
|
901
|
+
return ancestors;
|
|
902
|
+
}
|
|
903
|
+
function descendantsInDb(db, slug) {
|
|
904
|
+
const rows = db.prepare(
|
|
905
|
+
`WITH RECURSIVE desc(slug, depth) AS (
|
|
906
|
+
SELECT child_slug, 1 FROM topic_parents WHERE parent_slug = ?
|
|
907
|
+
UNION
|
|
908
|
+
SELECT tp.child_slug, d.depth + 1
|
|
909
|
+
FROM topic_parents tp
|
|
910
|
+
JOIN desc d ON tp.parent_slug = d.slug
|
|
911
|
+
WHERE d.depth < ?
|
|
912
|
+
)
|
|
913
|
+
SELECT DISTINCT slug FROM desc ORDER BY slug`
|
|
914
|
+
).all(slug, DAG_DEPTH_CAP).map((r) => r.slug);
|
|
915
|
+
return rows;
|
|
916
|
+
}
|
|
917
|
+
function subtreeInDb(db, slug) {
|
|
918
|
+
return [slug, ...descendantsInDb(db, slug)];
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// src/commands/health.ts
|
|
922
|
+
var DEFAULT_STALE_SECONDS = 90 * 24 * 60 * 60;
|
|
923
|
+
async function runHealth(options) {
|
|
924
|
+
const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
|
|
925
|
+
await ensureFreshIndex({ repoRoot });
|
|
926
|
+
const almanacDir = join4(repoRoot, ".almanac");
|
|
927
|
+
const pagesDir = join4(almanacDir, "pages");
|
|
928
|
+
const db = openIndex(join4(almanacDir, "index.db"));
|
|
929
|
+
try {
|
|
930
|
+
const staleSeconds = options.stale !== void 0 ? parseDuration(options.stale) : DEFAULT_STALE_SECONDS;
|
|
931
|
+
const scope = resolveScope(db, options);
|
|
932
|
+
const report = {
|
|
933
|
+
orphans: findOrphans(db, scope),
|
|
934
|
+
stale: findStale(db, scope, staleSeconds),
|
|
935
|
+
dead_refs: await findDeadRefs(db, scope, repoRoot),
|
|
936
|
+
broken_links: findBrokenLinks(db, scope),
|
|
937
|
+
broken_xwiki: await findBrokenXwiki(db, scope),
|
|
938
|
+
empty_topics: findEmptyTopics(db, scope),
|
|
939
|
+
empty_pages: await findEmptyPages(db, scope, pagesDir),
|
|
940
|
+
slug_collisions: await findSlugCollisions(pagesDir)
|
|
941
|
+
};
|
|
942
|
+
if (options.json === true) {
|
|
943
|
+
return {
|
|
944
|
+
stdout: `${JSON.stringify(report, null, 2)}
|
|
945
|
+
`,
|
|
946
|
+
stderr: "",
|
|
947
|
+
exitCode: 0
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
stdout: formatReport(report),
|
|
952
|
+
stderr: "",
|
|
953
|
+
exitCode: 0
|
|
954
|
+
};
|
|
955
|
+
} finally {
|
|
956
|
+
db.close();
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
function resolveScope(db, options) {
|
|
960
|
+
let pages = null;
|
|
961
|
+
let topics = null;
|
|
962
|
+
if (options.topic !== void 0) {
|
|
963
|
+
const rootSlug = toKebabCase(options.topic);
|
|
964
|
+
if (rootSlug.length > 0) {
|
|
965
|
+
const subtree = subtreeInDb(db, rootSlug);
|
|
966
|
+
topics = new Set(subtree);
|
|
967
|
+
const placeholders = subtree.map(() => "?").join(", ");
|
|
968
|
+
const rows = db.prepare(
|
|
969
|
+
`SELECT DISTINCT page_slug FROM page_topics
|
|
970
|
+
WHERE topic_slug IN (${placeholders})`
|
|
971
|
+
).all(...subtree);
|
|
972
|
+
pages = new Set(rows.map((r) => r.page_slug));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (options.stdin === true && options.stdinInput !== void 0) {
|
|
976
|
+
const stdinPages = /* @__PURE__ */ new Set();
|
|
977
|
+
for (const line of options.stdinInput.split(/\r?\n/)) {
|
|
978
|
+
const s = line.trim();
|
|
979
|
+
if (s.length > 0) stdinPages.add(s);
|
|
980
|
+
}
|
|
981
|
+
if (pages === null) pages = stdinPages;
|
|
982
|
+
else {
|
|
983
|
+
const out = /* @__PURE__ */ new Set();
|
|
984
|
+
for (const s of stdinPages) if (pages.has(s)) out.add(s);
|
|
985
|
+
pages = out;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return { pages, topics };
|
|
989
|
+
}
|
|
990
|
+
function inPageScope(scope, slug) {
|
|
991
|
+
if (scope.pages === null) return true;
|
|
992
|
+
return scope.pages.has(slug);
|
|
993
|
+
}
|
|
994
|
+
function findOrphans(db, scope) {
|
|
995
|
+
const rows = db.prepare(
|
|
996
|
+
`SELECT p.slug FROM pages p
|
|
997
|
+
WHERE p.archived_at IS NULL
|
|
998
|
+
AND NOT EXISTS (
|
|
999
|
+
SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug
|
|
1000
|
+
)
|
|
1001
|
+
ORDER BY p.slug`
|
|
1002
|
+
).all();
|
|
1003
|
+
return rows.filter((r) => inPageScope(scope, r.slug));
|
|
1004
|
+
}
|
|
1005
|
+
function findStale(db, scope, staleSeconds) {
|
|
1006
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1007
|
+
const threshold = now - staleSeconds;
|
|
1008
|
+
const rows = db.prepare(
|
|
1009
|
+
`SELECT slug, updated_at FROM pages
|
|
1010
|
+
WHERE archived_at IS NULL AND updated_at < ?
|
|
1011
|
+
ORDER BY updated_at ASC`
|
|
1012
|
+
).all(threshold);
|
|
1013
|
+
return rows.filter((r) => inPageScope(scope, r.slug)).map((r) => ({
|
|
1014
|
+
slug: r.slug,
|
|
1015
|
+
days_since_update: Math.floor((now - r.updated_at) / (60 * 60 * 24))
|
|
1016
|
+
}));
|
|
1017
|
+
}
|
|
1018
|
+
async function findDeadRefs(db, scope, repoRoot) {
|
|
1019
|
+
const rows = db.prepare(
|
|
1020
|
+
`SELECT p.slug, r.path, r.original_path, r.is_dir
|
|
1021
|
+
FROM file_refs r
|
|
1022
|
+
JOIN pages p ON p.slug = r.page_slug
|
|
1023
|
+
WHERE p.archived_at IS NULL
|
|
1024
|
+
ORDER BY p.slug, r.path`
|
|
1025
|
+
).all();
|
|
1026
|
+
const out = [];
|
|
1027
|
+
for (const r of rows) {
|
|
1028
|
+
if (!inPageScope(scope, r.slug)) continue;
|
|
1029
|
+
const abs = join4(repoRoot, r.original_path);
|
|
1030
|
+
if (!existsSync6(abs)) {
|
|
1031
|
+
out.push({ slug: r.slug, path: r.original_path });
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return out;
|
|
1035
|
+
}
|
|
1036
|
+
function findBrokenLinks(db, scope) {
|
|
1037
|
+
const rows = db.prepare(
|
|
1038
|
+
`SELECT w.source_slug, w.target_slug
|
|
1039
|
+
FROM wikilinks w
|
|
1040
|
+
JOIN pages src ON src.slug = w.source_slug
|
|
1041
|
+
LEFT JOIN pages tgt ON tgt.slug = w.target_slug
|
|
1042
|
+
WHERE tgt.slug IS NULL AND src.archived_at IS NULL
|
|
1043
|
+
ORDER BY w.source_slug, w.target_slug`
|
|
1044
|
+
).all();
|
|
1045
|
+
return rows.filter((r) => inPageScope(scope, r.source_slug));
|
|
1046
|
+
}
|
|
1047
|
+
async function findBrokenXwiki(db, scope) {
|
|
1048
|
+
const rows = db.prepare(
|
|
1049
|
+
// Same archived-source filter as `findBrokenLinks`. Retired pages
|
|
1050
|
+
// shouldn't spam the report with links to wikis that may have
|
|
1051
|
+
// been intentionally retired too.
|
|
1052
|
+
`SELECT x.source_slug, x.target_wiki, x.target_slug
|
|
1053
|
+
FROM cross_wiki_links x
|
|
1054
|
+
JOIN pages src ON src.slug = x.source_slug
|
|
1055
|
+
WHERE src.archived_at IS NULL
|
|
1056
|
+
ORDER BY x.source_slug, x.target_wiki, x.target_slug`
|
|
1057
|
+
).all();
|
|
1058
|
+
const out = [];
|
|
1059
|
+
const reachableCache = /* @__PURE__ */ new Map();
|
|
1060
|
+
for (const r of rows) {
|
|
1061
|
+
if (!inPageScope(scope, r.source_slug)) continue;
|
|
1062
|
+
let ok = reachableCache.get(r.target_wiki);
|
|
1063
|
+
if (ok === void 0) {
|
|
1064
|
+
const entry = await findEntry({ name: r.target_wiki });
|
|
1065
|
+
ok = entry !== null && existsSync6(join4(entry.path, ".almanac"));
|
|
1066
|
+
reachableCache.set(r.target_wiki, ok);
|
|
1067
|
+
}
|
|
1068
|
+
if (!ok) {
|
|
1069
|
+
out.push({
|
|
1070
|
+
source_slug: r.source_slug,
|
|
1071
|
+
target_wiki: r.target_wiki,
|
|
1072
|
+
target_slug: r.target_slug
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return out;
|
|
1077
|
+
}
|
|
1078
|
+
function findEmptyTopics(db, scope) {
|
|
1079
|
+
const rows = db.prepare(
|
|
1080
|
+
`SELECT t.slug FROM topics t
|
|
1081
|
+
WHERE NOT EXISTS (
|
|
1082
|
+
SELECT 1 FROM page_topics pt WHERE pt.topic_slug = t.slug
|
|
1083
|
+
)
|
|
1084
|
+
ORDER BY t.slug`
|
|
1085
|
+
).all();
|
|
1086
|
+
if (scope.topics === null) return rows;
|
|
1087
|
+
return rows.filter((r) => scope.topics.has(r.slug));
|
|
1088
|
+
}
|
|
1089
|
+
async function findEmptyPages(db, scope, pagesDir) {
|
|
1090
|
+
const rows = db.prepare(
|
|
1091
|
+
`SELECT slug, file_path FROM pages
|
|
1092
|
+
WHERE archived_at IS NULL
|
|
1093
|
+
ORDER BY slug`
|
|
1094
|
+
).all();
|
|
1095
|
+
const out = [];
|
|
1096
|
+
for (const r of rows) {
|
|
1097
|
+
if (!inPageScope(scope, r.slug)) continue;
|
|
1098
|
+
let raw;
|
|
1099
|
+
try {
|
|
1100
|
+
raw = await readFile4(r.file_path, "utf8");
|
|
1101
|
+
} catch {
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
const m = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
|
|
1105
|
+
const body = m !== null ? m[1] ?? "" : raw;
|
|
1106
|
+
void pagesDir;
|
|
1107
|
+
const hasSubstance = body.split(/\r?\n/).some((l) => {
|
|
1108
|
+
const t = l.trim();
|
|
1109
|
+
if (t.length === 0) return false;
|
|
1110
|
+
if (t.startsWith("#")) return false;
|
|
1111
|
+
return true;
|
|
1112
|
+
});
|
|
1113
|
+
if (!hasSubstance) {
|
|
1114
|
+
out.push({ slug: r.slug });
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return out;
|
|
1118
|
+
}
|
|
1119
|
+
async function findSlugCollisions(pagesDir) {
|
|
1120
|
+
if (!existsSync6(pagesDir)) return [];
|
|
1121
|
+
const files = await fg3("**/*.md", {
|
|
1122
|
+
cwd: pagesDir,
|
|
1123
|
+
absolute: false,
|
|
1124
|
+
onlyFiles: true,
|
|
1125
|
+
caseSensitiveMatch: true
|
|
1126
|
+
});
|
|
1127
|
+
const bySlug = /* @__PURE__ */ new Map();
|
|
1128
|
+
for (const rel of files) {
|
|
1129
|
+
const slug = toKebabCase(basename2(rel, ".md"));
|
|
1130
|
+
if (slug.length === 0) continue;
|
|
1131
|
+
const list = bySlug.get(slug) ?? [];
|
|
1132
|
+
list.push(rel);
|
|
1133
|
+
bySlug.set(slug, list);
|
|
1134
|
+
}
|
|
1135
|
+
const out = [];
|
|
1136
|
+
for (const [slug, paths] of bySlug.entries()) {
|
|
1137
|
+
if (paths.length > 1) {
|
|
1138
|
+
out.push({ slug, paths: paths.sort() });
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
out.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
1142
|
+
return out;
|
|
1143
|
+
}
|
|
1144
|
+
function formatReport(r) {
|
|
1145
|
+
const sections = [];
|
|
1146
|
+
sections.push(
|
|
1147
|
+
section(
|
|
1148
|
+
"orphans",
|
|
1149
|
+
r.orphans.length,
|
|
1150
|
+
r.orphans.map((o) => ` ${BLUE}${o.slug}${RST}`)
|
|
1151
|
+
)
|
|
1152
|
+
);
|
|
1153
|
+
sections.push(
|
|
1154
|
+
section(
|
|
1155
|
+
"stale",
|
|
1156
|
+
r.stale.length,
|
|
1157
|
+
r.stale.map((s) => ` ${BLUE}${s.slug}${RST} ${DIM}(${s.days_since_update} days)${RST}`)
|
|
1158
|
+
)
|
|
1159
|
+
);
|
|
1160
|
+
sections.push(
|
|
1161
|
+
section(
|
|
1162
|
+
"dead-refs",
|
|
1163
|
+
r.dead_refs.length,
|
|
1164
|
+
r.dead_refs.map((d) => ` ${BLUE}${d.slug}${RST} references ${d.path} ${DIM}(missing)${RST}`)
|
|
1165
|
+
)
|
|
1166
|
+
);
|
|
1167
|
+
sections.push(
|
|
1168
|
+
section(
|
|
1169
|
+
"broken-links",
|
|
1170
|
+
r.broken_links.length,
|
|
1171
|
+
r.broken_links.map(
|
|
1172
|
+
(b) => ` ${BLUE}${b.source_slug}${RST} \u2192 ${b.target_slug} ${DIM}(target does not exist)${RST}`
|
|
1173
|
+
)
|
|
1174
|
+
)
|
|
1175
|
+
);
|
|
1176
|
+
sections.push(
|
|
1177
|
+
section(
|
|
1178
|
+
"broken-xwiki",
|
|
1179
|
+
r.broken_xwiki.length,
|
|
1180
|
+
r.broken_xwiki.map(
|
|
1181
|
+
(b) => ` ${BLUE}${b.source_slug}${RST} \u2192 ${b.target_wiki}:${b.target_slug} ${DIM}(wiki unregistered or unreachable)${RST}`
|
|
1182
|
+
)
|
|
1183
|
+
)
|
|
1184
|
+
);
|
|
1185
|
+
sections.push(
|
|
1186
|
+
section(
|
|
1187
|
+
"empty-topics",
|
|
1188
|
+
r.empty_topics.length,
|
|
1189
|
+
r.empty_topics.map((e) => ` ${BLUE}${e.slug}${RST}`)
|
|
1190
|
+
)
|
|
1191
|
+
);
|
|
1192
|
+
sections.push(
|
|
1193
|
+
section(
|
|
1194
|
+
"empty-pages",
|
|
1195
|
+
r.empty_pages.length,
|
|
1196
|
+
r.empty_pages.map((e) => ` ${BLUE}${e.slug}${RST}`)
|
|
1197
|
+
)
|
|
1198
|
+
);
|
|
1199
|
+
sections.push(
|
|
1200
|
+
section(
|
|
1201
|
+
"slug-collisions",
|
|
1202
|
+
r.slug_collisions.length,
|
|
1203
|
+
r.slug_collisions.map((c) => ` ${BLUE}${c.slug}${RST}: ${c.paths.join(", ")}`)
|
|
1204
|
+
)
|
|
1205
|
+
);
|
|
1206
|
+
return `${sections.join("\n\n")}
|
|
1207
|
+
`;
|
|
1208
|
+
}
|
|
1209
|
+
function section(label, count, lines) {
|
|
1210
|
+
if (count === 0) return `${BOLD}${label}${RST} ${GREEN}(0): (ok)${RST}`;
|
|
1211
|
+
return `${BOLD}${label}${RST} ${RED}(${count})${RST}:
|
|
1212
|
+
${lines.join("\n")}`;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
export {
|
|
1216
|
+
toKebabCase,
|
|
1217
|
+
loadTopicsFile,
|
|
1218
|
+
writeTopicsFile,
|
|
1219
|
+
findTopic,
|
|
1220
|
+
ensureTopic,
|
|
1221
|
+
titleCase,
|
|
1222
|
+
parseFrontmatter,
|
|
1223
|
+
normalizePath,
|
|
1224
|
+
looksLikeDir,
|
|
1225
|
+
openIndex,
|
|
1226
|
+
ensureFreshIndex,
|
|
1227
|
+
runIndexer,
|
|
1228
|
+
readRegistry,
|
|
1229
|
+
addEntry,
|
|
1230
|
+
dropEntry,
|
|
1231
|
+
findEntry,
|
|
1232
|
+
ensureGlobalDir,
|
|
1233
|
+
resolveWikiRoot,
|
|
1234
|
+
ancestorsInFile,
|
|
1235
|
+
descendantsInDb,
|
|
1236
|
+
parseDuration,
|
|
1237
|
+
runHealth
|
|
1238
|
+
};
|
|
1239
|
+
//# sourceMappingURL=chunk-3C5SY5SE.js.map
|