@mulmoclaude/core 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/collection/registry/server/index.cjs +1 -1
  2. package/dist/collection/registry/server/index.js +1 -1
  3. package/dist/collection/server/index.cjs +2 -1
  4. package/dist/collection/server/index.js +2 -2
  5. package/dist/collection/server/io.d.ts +37 -9
  6. package/dist/collection-watchers/index.cjs +1 -1
  7. package/dist/collection-watchers/index.js +1 -1
  8. package/dist/feeds/server/index.cjs +2 -2
  9. package/dist/feeds/server/index.cjs.map +1 -1
  10. package/dist/feeds/server/index.js +2 -2
  11. package/dist/feeds/server/index.js.map +1 -1
  12. package/dist/graph-2R4HbQAg.cjs +433 -0
  13. package/dist/graph-2R4HbQAg.cjs.map +1 -0
  14. package/dist/graph-o5FZZbXf.js +326 -0
  15. package/dist/graph-o5FZZbXf.js.map +1 -0
  16. package/dist/{server-DRoqc8dL.js → server-D44bvGjw.js} +51 -17
  17. package/dist/server-D44bvGjw.js.map +1 -0
  18. package/dist/{server-DoDXibCq.cjs → server-cmnH6g2O.cjs} +56 -16
  19. package/dist/server-cmnH6g2O.cjs.map +1 -0
  20. package/dist/wiki/index.cjs +20 -341
  21. package/dist/wiki/index.cjs.map +1 -1
  22. package/dist/wiki/index.js +1 -322
  23. package/dist/wiki/index.js.map +1 -1
  24. package/dist/wiki/paths.cjs +42 -0
  25. package/dist/wiki/paths.cjs.map +1 -0
  26. package/dist/wiki/paths.js +38 -0
  27. package/dist/wiki/paths.js.map +1 -0
  28. package/dist/wiki/server/engine.d.ts +39 -0
  29. package/dist/wiki/server/frontmatter.d.ts +9 -0
  30. package/dist/wiki/server/fs.d.ts +5 -0
  31. package/dist/wiki/server/index.cjs +241 -21
  32. package/dist/wiki/server/index.cjs.map +1 -1
  33. package/dist/wiki/server/index.d.ts +4 -1
  34. package/dist/wiki/server/index.js +230 -22
  35. package/dist/wiki/server/index.js.map +1 -1
  36. package/dist/wiki/server/pageIndex.d.ts +13 -0
  37. package/dist/wiki/server/paths.d.ts +8 -0
  38. package/package.json +9 -2
  39. package/dist/server-DRoqc8dL.js.map +0 -1
  40. package/dist/server-DoDXibCq.cjs.map +0 -1
@@ -1,30 +1,250 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_rolldown_runtime = require("../../rolldown-runtime-D6vf50IK.cjs");
3
+ const require_graph = require("../../graph-2R4HbQAg.cjs");
3
4
  const require_slug = require("../../slug-DbwORN9z.cjs");
5
+ const require_wiki_paths = require("../paths.cjs");
4
6
  let node_path = require("node:path");
5
7
  node_path = require_rolldown_runtime.__toESM(node_path, 1);
6
- //#region src/wiki/server/paths.ts
7
- /** Given an absolute path and the absolute `pagesDir`, return the
8
- * slug if `absPath` is a direct `.md` child of `pagesDir`, else
9
- * null. Pure path-string math — no fs IO, no symlink resolution.
10
- *
11
- * Caller responsibility: pass already-realpath'd values for both
12
- * arguments. Mixing a realpath'd `absPath` with a symlinked
13
- * `pagesDir` (or vice versa) silently mismatches because
14
- * `path.relative` is plain string arithmetic. The trap caused
15
- * #883 review-iter-1 — a symlinked workspace silently routed
16
- * wiki writes through the generic writer. */
17
- function wikiSlugFromAbsPath(absPath, pagesDir) {
18
- const rel = node_path.default.relative(pagesDir, absPath);
19
- if (rel.length === 0) return null;
20
- if (node_path.default.isAbsolute(rel)) return null;
21
- if (rel.includes(node_path.default.sep)) return null;
22
- if (!rel.endsWith(".md")) return null;
23
- const slug = rel.slice(0, -3);
24
- if (!require_slug.isSafeSlug(slug)) return null;
25
- return slug;
8
+ let node_fs = require("node:fs");
9
+ let node_fs_promises = require("node:fs/promises");
10
+ let js_yaml = require("js-yaml");
11
+ //#region src/wiki/server/fs.ts
12
+ async function readTextSafe(absPath) {
13
+ try {
14
+ return await (0, node_fs_promises.readFile)(absPath, "utf-8");
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+ function readTextSafeSync(absPath) {
20
+ try {
21
+ return (0, node_fs.readFileSync)(absPath, "utf-8");
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ async function statSafeAsync(absPath) {
27
+ try {
28
+ return await (0, node_fs_promises.stat)(absPath);
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+ async function readDirSafeAsync(absPath) {
34
+ try {
35
+ return await (0, node_fs_promises.readdir)(absPath, { withFileTypes: true });
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+ //#endregion
41
+ //#region src/wiki/server/pageIndex.ts
42
+ var cache = /* @__PURE__ */ new Map();
43
+ /**
44
+ * Get the page index for `pagesDir`. Returns a cached value as long as
45
+ * THAT directory's mtime hasn't advanced; otherwise rebuilds. Safe to
46
+ * call concurrently — racing builds produce the same result.
47
+ */
48
+ async function getPageIndex(pagesDir) {
49
+ const stat = await statSafeAsync(pagesDir);
50
+ if (!stat) return {
51
+ mtimeMs: 0,
52
+ slugs: /* @__PURE__ */ new Map()
53
+ };
54
+ const cached = cache.get(pagesDir);
55
+ if (cached && cached.mtimeMs >= stat.mtimeMs) return cached;
56
+ const entries = await readDirSafeAsync(pagesDir);
57
+ const slugs = /* @__PURE__ */ new Map();
58
+ for (const entry of entries) {
59
+ if (!entry.isFile()) continue;
60
+ const { name } = entry;
61
+ if (!name.endsWith(".md")) continue;
62
+ slugs.set(name.slice(0, -3), name);
63
+ }
64
+ const built = {
65
+ mtimeMs: stat.mtimeMs,
66
+ slugs
67
+ };
68
+ cache.set(pagesDir, built);
69
+ return built;
70
+ }
71
+ /** Test-only: drop the module-level cache (all directories). */
72
+ function __resetPageIndexCache() {
73
+ cache.clear();
74
+ }
75
+ //#endregion
76
+ //#region src/wiki/server/frontmatter.ts
77
+ var FRONTMATTER_OPEN = /^---\r?\n/;
78
+ var FRONTMATTER_CLOSE = /(?:^|\r?\n)---\s*(?:\r?\n|$)/;
79
+ /** Parse `---\n…\n---\n` frontmatter. Never throws; malformed YAML in a
80
+ * well-formed envelope yields `{ meta: {}, hasHeader: false }`. */
81
+ function parseFrontmatter(raw) {
82
+ if (!FRONTMATTER_OPEN.test(raw)) return {
83
+ meta: {},
84
+ hasHeader: false
85
+ };
86
+ const afterOpen = raw.replace(FRONTMATTER_OPEN, "");
87
+ const closeMatch = FRONTMATTER_CLOSE.exec(afterOpen);
88
+ if (!closeMatch || closeMatch.index === void 0) return {
89
+ meta: {},
90
+ hasHeader: false
91
+ };
92
+ const meta = safeYamlLoad(afterOpen.slice(0, closeMatch.index));
93
+ if (meta === null) return {
94
+ meta: {},
95
+ hasHeader: false
96
+ };
97
+ return {
98
+ meta,
99
+ hasHeader: true
100
+ };
101
+ }
102
+ function safeYamlLoad(text) {
103
+ if (text.trim() === "") return {};
104
+ try {
105
+ const loaded = (0, js_yaml.load)(text, { schema: js_yaml.FAILSAFE_SCHEMA });
106
+ if (loaded === null || loaded === void 0) return {};
107
+ if (typeof loaded !== "object" || Array.isArray(loaded)) return null;
108
+ return loaded;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+ function cleanTagToken(token) {
114
+ return token.trim().replace(/^['"]|['"]$/g, "").replace(/^#/, "").toLowerCase();
115
+ }
116
+ /** Narrow `tags:` reader. Handles flow (`tags: [a, b]`) and block-list
117
+ * style; anything unparseable returns `[]` (best-effort, never throws). */
118
+ function parseFrontmatterTags(content) {
119
+ const parsed = parseFrontmatter(content);
120
+ if (!parsed.hasHeader) return [];
121
+ const tagsValue = parsed.meta.tags;
122
+ if (!Array.isArray(tagsValue)) return [];
123
+ return tagsValue.filter((item) => typeof item === "string").map(cleanTagToken).filter((token) => token.length > 0);
124
+ }
125
+ //#endregion
126
+ //#region src/wiki/server/engine.ts
127
+ var MIN_FUZZY_SLUG_LEN = 6;
128
+ function readFileOrEmpty(absPath) {
129
+ return readTextSafeSync(absPath) ?? "";
130
+ }
131
+ /** Walk every indexed slug for an `includes`-style match. Returns the
132
+ * single best candidate, or null when the slug is too short OR several
133
+ * candidates tie at the top score (ambiguous → defer to the caller's
134
+ * title-match fallback). Score = min/max length, decoupled from Map
135
+ * iteration order so resolution is deterministic across hosts. */
136
+ function pickFuzzyMatch(slug, slugs) {
137
+ if (slug.length < MIN_FUZZY_SLUG_LEN) return null;
138
+ let bestFile = null;
139
+ let bestScore = 0;
140
+ let bestIsTied = false;
141
+ for (const [key, file] of slugs) {
142
+ if (!slug.includes(key) && !key.includes(slug)) continue;
143
+ const score = Math.min(slug.length, key.length) / Math.max(slug.length, key.length);
144
+ if (score > bestScore) {
145
+ bestScore = score;
146
+ bestFile = file;
147
+ bestIsTied = false;
148
+ } else if (score === bestScore) bestIsTied = true;
149
+ }
150
+ return bestIsTied ? null : bestFile;
151
+ }
152
+ /** Resolve a page name to an absolute `.md` path: exact slug → fuzzy →
153
+ * index-title fallback (for non-ASCII names that slugify to empty).
154
+ * `pageName` may carry the `[[target|display]]` form; `parseWikiLink`
155
+ * strips the display half so the lookup uses just the target. */
156
+ async function resolvePagePath(workspace, pageName) {
157
+ const { pagesDir, indexFile } = require_wiki_paths.wikiDirs(workspace);
158
+ const { slugs } = await getPageIndex(pagesDir);
159
+ if (slugs.size === 0) return null;
160
+ const { target } = require_graph.parseWikiLink(pageName);
161
+ const slug = require_slug.wikiSlugify(target);
162
+ if (slug.length > 0) {
163
+ const exact = slugs.get(slug);
164
+ if (exact) return node_path.default.join(pagesDir, exact);
165
+ const fuzzy = pickFuzzyMatch(slug, slugs);
166
+ if (fuzzy) return node_path.default.join(pagesDir, fuzzy);
167
+ }
168
+ const titleMatch = require_graph.parseIndexEntries(readFileOrEmpty(indexFile)).find((entry) => entry.title === target);
169
+ if (titleMatch) {
170
+ const file = slugs.get(titleMatch.slug);
171
+ if (file) return node_path.default.join(pagesDir, file);
172
+ }
173
+ return null;
174
+ }
175
+ /** Raw `index.md` content + its parsed entries. */
176
+ function readWikiIndex(workspace) {
177
+ const content = readFileOrEmpty(require_wiki_paths.wikiDirs(workspace).indexFile);
178
+ return {
179
+ content,
180
+ entries: require_graph.parseIndexEntries(content)
181
+ };
182
+ }
183
+ /** Raw `log.md` content (empty string if absent). */
184
+ function readWikiLog(workspace) {
185
+ return readFileOrEmpty(require_wiki_paths.wikiDirs(workspace).logFile);
186
+ }
187
+ /** Resolve + read a page. Distinguishes missing (`exists: false`) from
188
+ * empty-but-present (`exists: true`, `content: ""`). */
189
+ async function readWikiPage(workspace, pageName) {
190
+ const filePath = await resolvePagePath(workspace, pageName);
191
+ const content = filePath ? readFileOrEmpty(filePath) : "";
192
+ const resolvedTitle = filePath ? node_path.default.basename(filePath, ".md") : pageName;
193
+ return {
194
+ filePath,
195
+ content,
196
+ exists: Boolean(filePath),
197
+ resolvedTitle
198
+ };
199
+ }
200
+ /** Read every page + the index and build the page→page link graph.
201
+ * No cache: the graph is requested explicitly and a content edit does
202
+ * not advance the pagesDir mtime the page index caches on. */
203
+ async function loadWikiGraph(workspace) {
204
+ const { pagesDir, indexFile } = require_wiki_paths.wikiDirs(workspace);
205
+ const { slugs } = await getPageIndex(pagesDir);
206
+ const fileEntries = [...slugs.entries()];
207
+ const contents = await Promise.all(fileEntries.map(async ([, fileName]) => await readTextSafe(node_path.default.join(pagesDir, fileName)) ?? ""));
208
+ return require_graph.buildWikiGraph(fileEntries.map(([slug], i) => ({
209
+ slug,
210
+ content: contents[i]
211
+ })), require_graph.parseIndexEntries(readFileOrEmpty(indexFile)));
212
+ }
213
+ /** Run every lint rule over the on-disk wiki, returning issue strings. */
214
+ async function collectLintIssues(workspace) {
215
+ const { pagesDir, indexFile } = require_wiki_paths.wikiDirs(workspace);
216
+ const { slugs } = await getPageIndex(pagesDir);
217
+ if (slugs.size === 0) return ["- Wiki `pages/` directory does not exist yet. Start ingesting sources."];
218
+ const pageEntries = require_graph.parseIndexEntries(readFileOrEmpty(indexFile));
219
+ const indexedSlugs = new Set(pageEntries.map((entry) => entry.slug));
220
+ const pageFiles = [...slugs.values()];
221
+ const fileSlugs = new Set(slugs.keys());
222
+ const issues = [];
223
+ issues.push(...require_graph.findOrphanPages(fileSlugs, indexedSlugs));
224
+ issues.push(...require_graph.findMissingFiles(pageEntries, fileSlugs));
225
+ const contents = await Promise.all(pageFiles.map(async (fileName) => await readTextSafe(node_path.default.join(pagesDir, fileName)) ?? ""));
226
+ const frontmatterTagsBySlug = /* @__PURE__ */ new Map();
227
+ for (let i = 0; i < pageFiles.length; i++) {
228
+ issues.push(...require_graph.findBrokenLinksInPage(pageFiles[i], contents[i], fileSlugs));
229
+ const slug = pageFiles[i].replace(/\.md$/i, "").toLowerCase();
230
+ frontmatterTagsBySlug.set(slug, parseFrontmatterTags(contents[i]));
231
+ }
232
+ issues.push(...require_graph.findTagDrift(pageEntries, frontmatterTagsBySlug));
233
+ return issues;
26
234
  }
27
235
  //#endregion
28
- exports.wikiSlugFromAbsPath = wikiSlugFromAbsPath;
236
+ exports.__resetPageIndexCache = __resetPageIndexCache;
237
+ exports.collectLintIssues = collectLintIssues;
238
+ exports.getPageIndex = getPageIndex;
239
+ exports.loadWikiGraph = loadWikiGraph;
240
+ exports.parseFrontmatter = parseFrontmatter;
241
+ exports.parseFrontmatterTags = parseFrontmatterTags;
242
+ exports.pickFuzzyMatch = pickFuzzyMatch;
243
+ exports.readWikiIndex = readWikiIndex;
244
+ exports.readWikiLog = readWikiLog;
245
+ exports.readWikiPage = readWikiPage;
246
+ exports.resolvePagePath = resolvePagePath;
247
+ exports.wikiDirs = require_wiki_paths.wikiDirs;
248
+ exports.wikiSlugFromAbsPath = require_wiki_paths.wikiSlugFromAbsPath;
29
249
 
30
250
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../../../src/wiki/server/paths.ts"],"sourcesContent":["// Node-only wiki-page helpers — anything that needs `node:path`\n// lives here so the rest of `@mulmoclaude/core/wiki` stays pure\n// (string-only, importable from both browser and Node bundles).\n// Frontend code MUST NOT import this file directly; use the pure\n// `@mulmoclaude/core/wiki` modules instead.\n\nimport path from \"node:path\";\nimport { isSafeSlug } from \"../slug.js\";\n\n/** Given an absolute path and the absolute `pagesDir`, return the\n * slug if `absPath` is a direct `.md` child of `pagesDir`, else\n * null. Pure path-string math — no fs IO, no symlink resolution.\n *\n * Caller responsibility: pass already-realpath'd values for both\n * arguments. Mixing a realpath'd `absPath` with a symlinked\n * `pagesDir` (or vice versa) silently mismatches because\n * `path.relative` is plain string arithmetic. The trap caused\n * #883 review-iter-1 — a symlinked workspace silently routed\n * wiki writes through the generic writer. */\nexport function wikiSlugFromAbsPath(absPath: string, pagesDir: string): string | null {\n const rel = path.relative(pagesDir, absPath);\n if (rel.length === 0) return null;\n if (path.isAbsolute(rel)) return null;\n // Direct child only — no nested layout today. Any separator\n // means the path either escapes (`../secret.md`) or descends\n // (`subdir/foo.md`). A literal page name like `..foo.md` is a\n // single segment without a separator and is allowed (codex\n // iter-3 #883 — the prior `startsWith(\"..\")` rule wrongly\n // rejected it).\n if (rel.includes(path.sep)) return null;\n if (!rel.endsWith(\".md\")) return null;\n const slug = rel.slice(0, -\".md\".length);\n if (!isSafeSlug(slug)) return null;\n return slug;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAmBA,SAAgB,oBAAoB,SAAiB,UAAiC;CACpF,MAAM,MAAM,UAAA,QAAK,SAAS,UAAU,OAAO;CAC3C,IAAI,IAAI,WAAW,GAAG,OAAO;CAC7B,IAAI,UAAA,QAAK,WAAW,GAAG,GAAG,OAAO;CAOjC,IAAI,IAAI,SAAS,UAAA,QAAK,GAAG,GAAG,OAAO;CACnC,IAAI,CAAC,IAAI,SAAS,KAAK,GAAG,OAAO;CACjC,MAAM,OAAO,IAAI,MAAM,GAAG,EAAa;CACvC,IAAI,CAAC,aAAA,WAAW,IAAI,GAAG,OAAO;CAC9B,OAAO;AACT"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../../src/wiki/server/fs.ts","../../../src/wiki/server/pageIndex.ts","../../../src/wiki/server/frontmatter.ts","../../../src/wiki/server/engine.ts"],"sourcesContent":["// Minimal ENOENT-swallowing fs wrappers for the wiki read-engine.\n// Self-contained (core can't reach the host's `server/utils/files/safe.ts`);\n// only the handful of helpers the wiki engine needs. Same \"return\n// null / [] on any error\" contract so callers branch on the value\n// instead of try/catch.\n\nimport type { Dirent, Stats } from \"node:fs\";\nimport { readFileSync } from \"node:fs\";\nimport { readFile, readdir, stat } from \"node:fs/promises\";\n\nexport async function readTextSafe(absPath: string): Promise<string | null> {\n try {\n return await readFile(absPath, \"utf-8\");\n } catch {\n return null;\n }\n}\n\nexport function readTextSafeSync(absPath: string): string | null {\n try {\n return readFileSync(absPath, \"utf-8\");\n } catch {\n return null;\n }\n}\n\nexport async function statSafeAsync(absPath: string): Promise<Stats | null> {\n try {\n return await stat(absPath);\n } catch {\n return null;\n }\n}\n\nexport async function readDirSafeAsync(absPath: string): Promise<Dirent[]> {\n try {\n return await readdir(absPath, { withFileTypes: true });\n } catch {\n return [];\n }\n}\n","// In-memory index of `<pagesDir>/*.md` keyed by slug (= filename\n// without the `.md` extension). Kept fresh via `pagesDir` mtime —\n// adding, removing, or renaming a file under `pagesDir` advances the\n// directory's mtime on every major filesystem we target (macOS APFS,\n// Linux ext4, Windows NTFS), so one cheap `stat()` per request is\n// enough to decide whether to rebuild.\n\nimport { readDirSafeAsync, statSafeAsync } from \"./fs.js\";\n\nexport interface PageIndex {\n mtimeMs: number;\n /** slug → filename (e.g. \"sakura-internet\" → \"sakura-internet.md\"). */\n slugs: Map<string, string>;\n}\n\n// Keyed by `pagesDir`: the engine is workspace-injected and may be\n// called with more than one pages directory in a single process. A\n// single global slot would let one workspace's slug map be returned\n// for another whenever their mtimes happen to tie (Codex P2 on #1876).\nconst cache = new Map<string, PageIndex>();\n\n/**\n * Get the page index for `pagesDir`. Returns a cached value as long as\n * THAT directory's mtime hasn't advanced; otherwise rebuilds. Safe to\n * call concurrently — racing builds produce the same result.\n */\nexport async function getPageIndex(pagesDir: string): Promise<PageIndex> {\n const stat = await statSafeAsync(pagesDir);\n if (!stat) {\n // Dir doesn't exist yet (never ingested). Return empty but don't\n // cache a stale-forever value — the next call re-stats.\n return { mtimeMs: 0, slugs: new Map() };\n }\n const cached = cache.get(pagesDir);\n if (cached && cached.mtimeMs >= stat.mtimeMs) return cached;\n const entries = await readDirSafeAsync(pagesDir);\n const slugs = new Map<string, string>();\n for (const entry of entries) {\n if (!entry.isFile()) continue;\n const { name } = entry;\n if (!name.endsWith(\".md\")) continue;\n slugs.set(name.slice(0, -\".md\".length), name);\n }\n const built: PageIndex = { mtimeMs: stat.mtimeMs, slugs };\n cache.set(pagesDir, built);\n return built;\n}\n\n/** Test-only: drop the module-level cache (all directories). */\nexport function __resetPageIndexCache(): void {\n cache.clear();\n}\n","// Read-side YAML frontmatter parsing for the wiki engine. Mirrors the\n// host's `server/utils/markdown/frontmatter.ts` parser (value-preserving\n// FAILSAFE_SCHEMA) so MulmoClaude and MulmoTerminal read `tags:` (and\n// any other field) identically. Only the READ path lives here — the\n// serialize/merge write side stays host-side until a host needs it.\n\nimport { FAILSAFE_SCHEMA, load as yamlLoad } from \"js-yaml\";\n\nconst FRONTMATTER_OPEN = /^---\\r?\\n/;\n// `(?:^|\\r?\\n)` lets the closing fence sit at the very start of\n// `afterOpen` — needed for the empty-envelope case `---\\n---\\n`.\nconst FRONTMATTER_CLOSE = /(?:^|\\r?\\n)---\\s*(?:\\r?\\n|$)/;\n\n/** Parse `---\\n…\\n---\\n` frontmatter. Never throws; malformed YAML in a\n * well-formed envelope yields `{ meta: {}, hasHeader: false }`. */\nexport function parseFrontmatter(raw: string): { meta: Record<string, unknown>; hasHeader: boolean } {\n if (!FRONTMATTER_OPEN.test(raw)) return { meta: {}, hasHeader: false };\n const afterOpen = raw.replace(FRONTMATTER_OPEN, \"\");\n const closeMatch = FRONTMATTER_CLOSE.exec(afterOpen);\n if (!closeMatch || closeMatch.index === undefined) return { meta: {}, hasHeader: false };\n const meta = safeYamlLoad(afterOpen.slice(0, closeMatch.index));\n if (meta === null) return { meta: {}, hasHeader: false };\n return { meta, hasHeader: true };\n}\n\nfunction safeYamlLoad(text: string): Record<string, unknown> | null {\n // js-yaml throws on empty/whitespace-only input; an empty header\n // (`---\\n---\\n`) means \"no metadata\", not \"malformed\".\n if (text.trim() === \"\") return {};\n try {\n // FAILSAFE_SCHEMA keeps every scalar a string (no YAML-1.1 date\n // coercion, no numeric-string truncation) — matches the host.\n const loaded = yamlLoad(text, { schema: FAILSAFE_SCHEMA });\n if (loaded === null || loaded === undefined) return {};\n if (typeof loaded !== \"object\" || Array.isArray(loaded)) return null;\n return loaded as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\nfunction cleanTagToken(token: string): string {\n return token\n .trim()\n .replace(/^['\"]|['\"]$/g, \"\")\n .replace(/^#/, \"\")\n .toLowerCase();\n}\n\n/** Narrow `tags:` reader. Handles flow (`tags: [a, b]`) and block-list\n * style; anything unparseable returns `[]` (best-effort, never throws). */\nexport function parseFrontmatterTags(content: string): string[] {\n const parsed = parseFrontmatter(content);\n if (!parsed.hasHeader) return [];\n const tagsValue = parsed.meta.tags;\n if (!Array.isArray(tagsValue)) return [];\n return tagsValue\n .filter((item): item is string => typeof item === \"string\")\n .map(cleanTagToken)\n .filter((token) => token.length > 0);\n}\n","// Workspace-injected wiki read-engine — the filesystem layer that feeds\n// the pure helpers in `@mulmoclaude/core/wiki`. Shared by every host so\n// the two apps reading the same `data/wiki/` can't disagree on slug\n// resolution, graph edges, or lint findings. Mirrors the bodies that\n// previously lived in MulmoClaude's `server/api/routes/wiki.ts`; the\n// hosts keep only their HTTP response shaping on top.\n//\n// The read/write WRITE side (writeWikiPage / snapshots) stays host-side\n// until a host needs it shared.\n\nimport path from \"node:path\";\nimport { parseWikiLink } from \"../link.js\";\nimport { wikiSlugify } from \"../slug.js\";\nimport { type WikiPageEntry, parseIndexEntries } from \"../index-parse.js\";\nimport { findBrokenLinksInPage, findMissingFiles, findOrphanPages, findTagDrift } from \"../lint.js\";\nimport { type WikiGraph, buildWikiGraph } from \"../graph.js\";\nimport { readTextSafe, readTextSafeSync } from \"./fs.js\";\nimport { getPageIndex } from \"./pageIndex.js\";\nimport { parseFrontmatterTags } from \"./frontmatter.js\";\nimport { wikiDirs } from \"./paths.js\";\n\n// Below this length the fuzzy `includes` step is skipped — CJK /\n// emoji-only / very-short page names slugify down to a short noise\n// tail that partial-matches almost anything; the index.md title-match\n// fallback still handles the legitimate non-ASCII case (#1194).\nconst MIN_FUZZY_SLUG_LEN = 6;\n\nfunction readFileOrEmpty(absPath: string): string {\n return readTextSafeSync(absPath) ?? \"\";\n}\n\n/** Walk every indexed slug for an `includes`-style match. Returns the\n * single best candidate, or null when the slug is too short OR several\n * candidates tie at the top score (ambiguous → defer to the caller's\n * title-match fallback). Score = min/max length, decoupled from Map\n * iteration order so resolution is deterministic across hosts. */\nexport function pickFuzzyMatch(slug: string, slugs: ReadonlyMap<string, string>): string | null {\n if (slug.length < MIN_FUZZY_SLUG_LEN) return null;\n let bestFile: string | null = null;\n let bestScore = 0;\n let bestIsTied = false;\n for (const [key, file] of slugs) {\n if (!slug.includes(key) && !key.includes(slug)) continue;\n const shorter = Math.min(slug.length, key.length);\n const longer = Math.max(slug.length, key.length);\n const score = shorter / longer;\n if (score > bestScore) {\n bestScore = score;\n bestFile = file;\n bestIsTied = false;\n } else if (score === bestScore) {\n bestIsTied = true;\n }\n }\n return bestIsTied ? null : bestFile;\n}\n\n/** Resolve a page name to an absolute `.md` path: exact slug → fuzzy →\n * index-title fallback (for non-ASCII names that slugify to empty).\n * `pageName` may carry the `[[target|display]]` form; `parseWikiLink`\n * strips the display half so the lookup uses just the target. */\nexport async function resolvePagePath(workspace: string, pageName: string): Promise<string | null> {\n const { pagesDir, indexFile } = wikiDirs(workspace);\n const { slugs } = await getPageIndex(pagesDir);\n if (slugs.size === 0) return null;\n\n const { target } = parseWikiLink(pageName);\n const slug = wikiSlugify(target);\n\n if (slug.length > 0) {\n const exact = slugs.get(slug);\n if (exact) return path.join(pagesDir, exact);\n const fuzzy = pickFuzzyMatch(slug, slugs);\n if (fuzzy) return path.join(pagesDir, fuzzy);\n }\n\n const entries = parseIndexEntries(readFileOrEmpty(indexFile));\n const titleMatch = entries.find((entry) => entry.title === target);\n if (titleMatch) {\n const file = slugs.get(titleMatch.slug);\n if (file) return path.join(pagesDir, file);\n }\n return null;\n}\n\n/** Raw `index.md` content + its parsed entries. */\nexport function readWikiIndex(workspace: string): { content: string; entries: WikiPageEntry[] } {\n const content = readFileOrEmpty(wikiDirs(workspace).indexFile);\n return { content, entries: parseIndexEntries(content) };\n}\n\n/** Raw `log.md` content (empty string if absent). */\nexport function readWikiLog(workspace: string): string {\n return readFileOrEmpty(wikiDirs(workspace).logFile);\n}\n\nexport interface WikiPageRead {\n /** Absolute path of the resolved file, or null when nothing matched. */\n filePath: string | null;\n /** File body (empty when missing OR when the file is an empty placeholder). */\n content: string;\n /** True iff a page file resolved (distinct from empty content). */\n exists: boolean;\n /** Title to display — the resolved filename stem, or the raw pageName. */\n resolvedTitle: string;\n}\n\n/** Resolve + read a page. Distinguishes missing (`exists: false`) from\n * empty-but-present (`exists: true`, `content: \"\"`). */\nexport async function readWikiPage(workspace: string, pageName: string): Promise<WikiPageRead> {\n const filePath = await resolvePagePath(workspace, pageName);\n const content = filePath ? readFileOrEmpty(filePath) : \"\";\n const resolvedTitle = filePath ? path.basename(filePath, \".md\") : pageName;\n return { filePath, content, exists: Boolean(filePath), resolvedTitle };\n}\n\n/** Read every page + the index and build the page→page link graph.\n * No cache: the graph is requested explicitly and a content edit does\n * not advance the pagesDir mtime the page index caches on. */\nexport async function loadWikiGraph(workspace: string): Promise<WikiGraph> {\n const { pagesDir, indexFile } = wikiDirs(workspace);\n const { slugs } = await getPageIndex(pagesDir);\n const fileEntries = [...slugs.entries()];\n const contents = await Promise.all(fileEntries.map(async ([, fileName]) => (await readTextSafe(path.join(pagesDir, fileName))) ?? \"\"));\n const pages = fileEntries.map(([slug], i) => ({ slug, content: contents[i] }));\n const indexEntries = parseIndexEntries(readFileOrEmpty(indexFile));\n return buildWikiGraph(pages, indexEntries);\n}\n\n/** Run every lint rule over the on-disk wiki, returning issue strings. */\nexport async function collectLintIssues(workspace: string): Promise<string[]> {\n const { pagesDir, indexFile } = wikiDirs(workspace);\n const { slugs } = await getPageIndex(pagesDir);\n if (slugs.size === 0) {\n return [\"- Wiki `pages/` directory does not exist yet. Start ingesting sources.\"];\n }\n const pageEntries = parseIndexEntries(readFileOrEmpty(indexFile));\n const indexedSlugs = new Set(pageEntries.map((entry) => entry.slug));\n const pageFiles = [...slugs.values()];\n const fileSlugs = new Set(slugs.keys());\n\n const issues: string[] = [];\n issues.push(...findOrphanPages(fileSlugs, indexedSlugs));\n issues.push(...findMissingFiles(pageEntries, fileSlugs));\n const contents = await Promise.all(pageFiles.map(async (fileName) => (await readTextSafe(path.join(pagesDir, fileName))) ?? \"\"));\n const frontmatterTagsBySlug = new Map<string, string[]>();\n for (let i = 0; i < pageFiles.length; i++) {\n issues.push(...findBrokenLinksInPage(pageFiles[i], contents[i], fileSlugs));\n // Lowercase the key so a `MyPage.md` filename matches an\n // `entry.slug` of `mypage`; `findTagDrift` lowercases the lookup.\n const slug = pageFiles[i].replace(/\\.md$/i, \"\").toLowerCase();\n frontmatterTagsBySlug.set(slug, parseFrontmatterTags(contents[i]));\n }\n issues.push(...findTagDrift(pageEntries, frontmatterTagsBySlug));\n return issues;\n}\n"],"mappings":"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"}
@@ -1 +1,4 @@
1
- export { wikiSlugFromAbsPath } from './paths.js';
1
+ export { wikiSlugFromAbsPath, wikiDirs } from './paths.js';
2
+ export { getPageIndex, __resetPageIndexCache, type PageIndex } from './pageIndex.js';
3
+ export { parseFrontmatter, parseFrontmatterTags } from './frontmatter.js';
4
+ export { pickFuzzyMatch, resolvePagePath, readWikiIndex, readWikiLog, readWikiPage, loadWikiGraph, collectLintIssues, type WikiPageRead } from './engine.js';
@@ -1,27 +1,235 @@
1
- import { t as isSafeSlug } from "../../slug-CdN-pQX1.js";
1
+ import { a as findBrokenLinksInPage, c as findTagDrift, h as parseIndexEntries, o as findMissingFiles, s as findOrphanPages, t as buildWikiGraph, v as parseWikiLink } from "../../graph-o5FZZbXf.js";
2
+ import { n as wikiSlugify } from "../../slug-CdN-pQX1.js";
3
+ import { wikiDirs, wikiSlugFromAbsPath } from "../paths.js";
2
4
  import path from "node:path";
3
- //#region src/wiki/server/paths.ts
4
- /** Given an absolute path and the absolute `pagesDir`, return the
5
- * slug if `absPath` is a direct `.md` child of `pagesDir`, else
6
- * null. Pure path-string math — no fs IO, no symlink resolution.
7
- *
8
- * Caller responsibility: pass already-realpath'd values for both
9
- * arguments. Mixing a realpath'd `absPath` with a symlinked
10
- * `pagesDir` (or vice versa) silently mismatches because
11
- * `path.relative` is plain string arithmetic. The trap caused
12
- * #883 review-iter-1 — a symlinked workspace silently routed
13
- * wiki writes through the generic writer. */
14
- function wikiSlugFromAbsPath(absPath, pagesDir) {
15
- const rel = path.relative(pagesDir, absPath);
16
- if (rel.length === 0) return null;
17
- if (path.isAbsolute(rel)) return null;
18
- if (rel.includes(path.sep)) return null;
19
- if (!rel.endsWith(".md")) return null;
20
- const slug = rel.slice(0, -3);
21
- if (!isSafeSlug(slug)) return null;
22
- return slug;
5
+ import { readFileSync } from "node:fs";
6
+ import { readFile, readdir, stat } from "node:fs/promises";
7
+ import { FAILSAFE_SCHEMA, load } from "js-yaml";
8
+ //#region src/wiki/server/fs.ts
9
+ async function readTextSafe(absPath) {
10
+ try {
11
+ return await readFile(absPath, "utf-8");
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+ function readTextSafeSync(absPath) {
17
+ try {
18
+ return readFileSync(absPath, "utf-8");
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+ async function statSafeAsync(absPath) {
24
+ try {
25
+ return await stat(absPath);
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+ async function readDirSafeAsync(absPath) {
31
+ try {
32
+ return await readdir(absPath, { withFileTypes: true });
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+ //#endregion
38
+ //#region src/wiki/server/pageIndex.ts
39
+ var cache = /* @__PURE__ */ new Map();
40
+ /**
41
+ * Get the page index for `pagesDir`. Returns a cached value as long as
42
+ * THAT directory's mtime hasn't advanced; otherwise rebuilds. Safe to
43
+ * call concurrently — racing builds produce the same result.
44
+ */
45
+ async function getPageIndex(pagesDir) {
46
+ const stat = await statSafeAsync(pagesDir);
47
+ if (!stat) return {
48
+ mtimeMs: 0,
49
+ slugs: /* @__PURE__ */ new Map()
50
+ };
51
+ const cached = cache.get(pagesDir);
52
+ if (cached && cached.mtimeMs >= stat.mtimeMs) return cached;
53
+ const entries = await readDirSafeAsync(pagesDir);
54
+ const slugs = /* @__PURE__ */ new Map();
55
+ for (const entry of entries) {
56
+ if (!entry.isFile()) continue;
57
+ const { name } = entry;
58
+ if (!name.endsWith(".md")) continue;
59
+ slugs.set(name.slice(0, -3), name);
60
+ }
61
+ const built = {
62
+ mtimeMs: stat.mtimeMs,
63
+ slugs
64
+ };
65
+ cache.set(pagesDir, built);
66
+ return built;
67
+ }
68
+ /** Test-only: drop the module-level cache (all directories). */
69
+ function __resetPageIndexCache() {
70
+ cache.clear();
71
+ }
72
+ //#endregion
73
+ //#region src/wiki/server/frontmatter.ts
74
+ var FRONTMATTER_OPEN = /^---\r?\n/;
75
+ var FRONTMATTER_CLOSE = /(?:^|\r?\n)---\s*(?:\r?\n|$)/;
76
+ /** Parse `---\n…\n---\n` frontmatter. Never throws; malformed YAML in a
77
+ * well-formed envelope yields `{ meta: {}, hasHeader: false }`. */
78
+ function parseFrontmatter(raw) {
79
+ if (!FRONTMATTER_OPEN.test(raw)) return {
80
+ meta: {},
81
+ hasHeader: false
82
+ };
83
+ const afterOpen = raw.replace(FRONTMATTER_OPEN, "");
84
+ const closeMatch = FRONTMATTER_CLOSE.exec(afterOpen);
85
+ if (!closeMatch || closeMatch.index === void 0) return {
86
+ meta: {},
87
+ hasHeader: false
88
+ };
89
+ const meta = safeYamlLoad(afterOpen.slice(0, closeMatch.index));
90
+ if (meta === null) return {
91
+ meta: {},
92
+ hasHeader: false
93
+ };
94
+ return {
95
+ meta,
96
+ hasHeader: true
97
+ };
98
+ }
99
+ function safeYamlLoad(text) {
100
+ if (text.trim() === "") return {};
101
+ try {
102
+ const loaded = load(text, { schema: FAILSAFE_SCHEMA });
103
+ if (loaded === null || loaded === void 0) return {};
104
+ if (typeof loaded !== "object" || Array.isArray(loaded)) return null;
105
+ return loaded;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+ function cleanTagToken(token) {
111
+ return token.trim().replace(/^['"]|['"]$/g, "").replace(/^#/, "").toLowerCase();
112
+ }
113
+ /** Narrow `tags:` reader. Handles flow (`tags: [a, b]`) and block-list
114
+ * style; anything unparseable returns `[]` (best-effort, never throws). */
115
+ function parseFrontmatterTags(content) {
116
+ const parsed = parseFrontmatter(content);
117
+ if (!parsed.hasHeader) return [];
118
+ const tagsValue = parsed.meta.tags;
119
+ if (!Array.isArray(tagsValue)) return [];
120
+ return tagsValue.filter((item) => typeof item === "string").map(cleanTagToken).filter((token) => token.length > 0);
121
+ }
122
+ //#endregion
123
+ //#region src/wiki/server/engine.ts
124
+ var MIN_FUZZY_SLUG_LEN = 6;
125
+ function readFileOrEmpty(absPath) {
126
+ return readTextSafeSync(absPath) ?? "";
127
+ }
128
+ /** Walk every indexed slug for an `includes`-style match. Returns the
129
+ * single best candidate, or null when the slug is too short OR several
130
+ * candidates tie at the top score (ambiguous → defer to the caller's
131
+ * title-match fallback). Score = min/max length, decoupled from Map
132
+ * iteration order so resolution is deterministic across hosts. */
133
+ function pickFuzzyMatch(slug, slugs) {
134
+ if (slug.length < MIN_FUZZY_SLUG_LEN) return null;
135
+ let bestFile = null;
136
+ let bestScore = 0;
137
+ let bestIsTied = false;
138
+ for (const [key, file] of slugs) {
139
+ if (!slug.includes(key) && !key.includes(slug)) continue;
140
+ const score = Math.min(slug.length, key.length) / Math.max(slug.length, key.length);
141
+ if (score > bestScore) {
142
+ bestScore = score;
143
+ bestFile = file;
144
+ bestIsTied = false;
145
+ } else if (score === bestScore) bestIsTied = true;
146
+ }
147
+ return bestIsTied ? null : bestFile;
148
+ }
149
+ /** Resolve a page name to an absolute `.md` path: exact slug → fuzzy →
150
+ * index-title fallback (for non-ASCII names that slugify to empty).
151
+ * `pageName` may carry the `[[target|display]]` form; `parseWikiLink`
152
+ * strips the display half so the lookup uses just the target. */
153
+ async function resolvePagePath(workspace, pageName) {
154
+ const { pagesDir, indexFile } = wikiDirs(workspace);
155
+ const { slugs } = await getPageIndex(pagesDir);
156
+ if (slugs.size === 0) return null;
157
+ const { target } = parseWikiLink(pageName);
158
+ const slug = wikiSlugify(target);
159
+ if (slug.length > 0) {
160
+ const exact = slugs.get(slug);
161
+ if (exact) return path.join(pagesDir, exact);
162
+ const fuzzy = pickFuzzyMatch(slug, slugs);
163
+ if (fuzzy) return path.join(pagesDir, fuzzy);
164
+ }
165
+ const titleMatch = parseIndexEntries(readFileOrEmpty(indexFile)).find((entry) => entry.title === target);
166
+ if (titleMatch) {
167
+ const file = slugs.get(titleMatch.slug);
168
+ if (file) return path.join(pagesDir, file);
169
+ }
170
+ return null;
171
+ }
172
+ /** Raw `index.md` content + its parsed entries. */
173
+ function readWikiIndex(workspace) {
174
+ const content = readFileOrEmpty(wikiDirs(workspace).indexFile);
175
+ return {
176
+ content,
177
+ entries: parseIndexEntries(content)
178
+ };
179
+ }
180
+ /** Raw `log.md` content (empty string if absent). */
181
+ function readWikiLog(workspace) {
182
+ return readFileOrEmpty(wikiDirs(workspace).logFile);
183
+ }
184
+ /** Resolve + read a page. Distinguishes missing (`exists: false`) from
185
+ * empty-but-present (`exists: true`, `content: ""`). */
186
+ async function readWikiPage(workspace, pageName) {
187
+ const filePath = await resolvePagePath(workspace, pageName);
188
+ const content = filePath ? readFileOrEmpty(filePath) : "";
189
+ const resolvedTitle = filePath ? path.basename(filePath, ".md") : pageName;
190
+ return {
191
+ filePath,
192
+ content,
193
+ exists: Boolean(filePath),
194
+ resolvedTitle
195
+ };
196
+ }
197
+ /** Read every page + the index and build the page→page link graph.
198
+ * No cache: the graph is requested explicitly and a content edit does
199
+ * not advance the pagesDir mtime the page index caches on. */
200
+ async function loadWikiGraph(workspace) {
201
+ const { pagesDir, indexFile } = wikiDirs(workspace);
202
+ const { slugs } = await getPageIndex(pagesDir);
203
+ const fileEntries = [...slugs.entries()];
204
+ const contents = await Promise.all(fileEntries.map(async ([, fileName]) => await readTextSafe(path.join(pagesDir, fileName)) ?? ""));
205
+ return buildWikiGraph(fileEntries.map(([slug], i) => ({
206
+ slug,
207
+ content: contents[i]
208
+ })), parseIndexEntries(readFileOrEmpty(indexFile)));
209
+ }
210
+ /** Run every lint rule over the on-disk wiki, returning issue strings. */
211
+ async function collectLintIssues(workspace) {
212
+ const { pagesDir, indexFile } = wikiDirs(workspace);
213
+ const { slugs } = await getPageIndex(pagesDir);
214
+ if (slugs.size === 0) return ["- Wiki `pages/` directory does not exist yet. Start ingesting sources."];
215
+ const pageEntries = parseIndexEntries(readFileOrEmpty(indexFile));
216
+ const indexedSlugs = new Set(pageEntries.map((entry) => entry.slug));
217
+ const pageFiles = [...slugs.values()];
218
+ const fileSlugs = new Set(slugs.keys());
219
+ const issues = [];
220
+ issues.push(...findOrphanPages(fileSlugs, indexedSlugs));
221
+ issues.push(...findMissingFiles(pageEntries, fileSlugs));
222
+ const contents = await Promise.all(pageFiles.map(async (fileName) => await readTextSafe(path.join(pagesDir, fileName)) ?? ""));
223
+ const frontmatterTagsBySlug = /* @__PURE__ */ new Map();
224
+ for (let i = 0; i < pageFiles.length; i++) {
225
+ issues.push(...findBrokenLinksInPage(pageFiles[i], contents[i], fileSlugs));
226
+ const slug = pageFiles[i].replace(/\.md$/i, "").toLowerCase();
227
+ frontmatterTagsBySlug.set(slug, parseFrontmatterTags(contents[i]));
228
+ }
229
+ issues.push(...findTagDrift(pageEntries, frontmatterTagsBySlug));
230
+ return issues;
23
231
  }
24
232
  //#endregion
25
- export { wikiSlugFromAbsPath };
233
+ export { __resetPageIndexCache, collectLintIssues, getPageIndex, loadWikiGraph, parseFrontmatter, parseFrontmatterTags, pickFuzzyMatch, readWikiIndex, readWikiLog, readWikiPage, resolvePagePath, wikiDirs, wikiSlugFromAbsPath };
26
234
 
27
235
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/wiki/server/paths.ts"],"sourcesContent":["// Node-only wiki-page helpers — anything that needs `node:path`\n// lives here so the rest of `@mulmoclaude/core/wiki` stays pure\n// (string-only, importable from both browser and Node bundles).\n// Frontend code MUST NOT import this file directly; use the pure\n// `@mulmoclaude/core/wiki` modules instead.\n\nimport path from \"node:path\";\nimport { isSafeSlug } from \"../slug.js\";\n\n/** Given an absolute path and the absolute `pagesDir`, return the\n * slug if `absPath` is a direct `.md` child of `pagesDir`, else\n * null. Pure path-string math — no fs IO, no symlink resolution.\n *\n * Caller responsibility: pass already-realpath'd values for both\n * arguments. Mixing a realpath'd `absPath` with a symlinked\n * `pagesDir` (or vice versa) silently mismatches because\n * `path.relative` is plain string arithmetic. The trap caused\n * #883 review-iter-1 — a symlinked workspace silently routed\n * wiki writes through the generic writer. */\nexport function wikiSlugFromAbsPath(absPath: string, pagesDir: string): string | null {\n const rel = path.relative(pagesDir, absPath);\n if (rel.length === 0) return null;\n if (path.isAbsolute(rel)) return null;\n // Direct child only — no nested layout today. Any separator\n // means the path either escapes (`../secret.md`) or descends\n // (`subdir/foo.md`). A literal page name like `..foo.md` is a\n // single segment without a separator and is allowed (codex\n // iter-3 #883 — the prior `startsWith(\"..\")` rule wrongly\n // rejected it).\n if (rel.includes(path.sep)) return null;\n if (!rel.endsWith(\".md\")) return null;\n const slug = rel.slice(0, -\".md\".length);\n if (!isSafeSlug(slug)) return null;\n return slug;\n}\n"],"mappings":";;;;;;;;;;;;;AAmBA,SAAgB,oBAAoB,SAAiB,UAAiC;CACpF,MAAM,MAAM,KAAK,SAAS,UAAU,OAAO;CAC3C,IAAI,IAAI,WAAW,GAAG,OAAO;CAC7B,IAAI,KAAK,WAAW,GAAG,GAAG,OAAO;CAOjC,IAAI,IAAI,SAAS,KAAK,GAAG,GAAG,OAAO;CACnC,IAAI,CAAC,IAAI,SAAS,KAAK,GAAG,OAAO;CACjC,MAAM,OAAO,IAAI,MAAM,GAAG,EAAa;CACvC,IAAI,CAAC,WAAW,IAAI,GAAG,OAAO;CAC9B,OAAO;AACT"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/wiki/server/fs.ts","../../../src/wiki/server/pageIndex.ts","../../../src/wiki/server/frontmatter.ts","../../../src/wiki/server/engine.ts"],"sourcesContent":["// Minimal ENOENT-swallowing fs wrappers for the wiki read-engine.\n// Self-contained (core can't reach the host's `server/utils/files/safe.ts`);\n// only the handful of helpers the wiki engine needs. Same \"return\n// null / [] on any error\" contract so callers branch on the value\n// instead of try/catch.\n\nimport type { Dirent, Stats } from \"node:fs\";\nimport { readFileSync } from \"node:fs\";\nimport { readFile, readdir, stat } from \"node:fs/promises\";\n\nexport async function readTextSafe(absPath: string): Promise<string | null> {\n try {\n return await readFile(absPath, \"utf-8\");\n } catch {\n return null;\n }\n}\n\nexport function readTextSafeSync(absPath: string): string | null {\n try {\n return readFileSync(absPath, \"utf-8\");\n } catch {\n return null;\n }\n}\n\nexport async function statSafeAsync(absPath: string): Promise<Stats | null> {\n try {\n return await stat(absPath);\n } catch {\n return null;\n }\n}\n\nexport async function readDirSafeAsync(absPath: string): Promise<Dirent[]> {\n try {\n return await readdir(absPath, { withFileTypes: true });\n } catch {\n return [];\n }\n}\n","// In-memory index of `<pagesDir>/*.md` keyed by slug (= filename\n// without the `.md` extension). Kept fresh via `pagesDir` mtime —\n// adding, removing, or renaming a file under `pagesDir` advances the\n// directory's mtime on every major filesystem we target (macOS APFS,\n// Linux ext4, Windows NTFS), so one cheap `stat()` per request is\n// enough to decide whether to rebuild.\n\nimport { readDirSafeAsync, statSafeAsync } from \"./fs.js\";\n\nexport interface PageIndex {\n mtimeMs: number;\n /** slug → filename (e.g. \"sakura-internet\" → \"sakura-internet.md\"). */\n slugs: Map<string, string>;\n}\n\n// Keyed by `pagesDir`: the engine is workspace-injected and may be\n// called with more than one pages directory in a single process. A\n// single global slot would let one workspace's slug map be returned\n// for another whenever their mtimes happen to tie (Codex P2 on #1876).\nconst cache = new Map<string, PageIndex>();\n\n/**\n * Get the page index for `pagesDir`. Returns a cached value as long as\n * THAT directory's mtime hasn't advanced; otherwise rebuilds. Safe to\n * call concurrently — racing builds produce the same result.\n */\nexport async function getPageIndex(pagesDir: string): Promise<PageIndex> {\n const stat = await statSafeAsync(pagesDir);\n if (!stat) {\n // Dir doesn't exist yet (never ingested). Return empty but don't\n // cache a stale-forever value — the next call re-stats.\n return { mtimeMs: 0, slugs: new Map() };\n }\n const cached = cache.get(pagesDir);\n if (cached && cached.mtimeMs >= stat.mtimeMs) return cached;\n const entries = await readDirSafeAsync(pagesDir);\n const slugs = new Map<string, string>();\n for (const entry of entries) {\n if (!entry.isFile()) continue;\n const { name } = entry;\n if (!name.endsWith(\".md\")) continue;\n slugs.set(name.slice(0, -\".md\".length), name);\n }\n const built: PageIndex = { mtimeMs: stat.mtimeMs, slugs };\n cache.set(pagesDir, built);\n return built;\n}\n\n/** Test-only: drop the module-level cache (all directories). */\nexport function __resetPageIndexCache(): void {\n cache.clear();\n}\n","// Read-side YAML frontmatter parsing for the wiki engine. Mirrors the\n// host's `server/utils/markdown/frontmatter.ts` parser (value-preserving\n// FAILSAFE_SCHEMA) so MulmoClaude and MulmoTerminal read `tags:` (and\n// any other field) identically. Only the READ path lives here — the\n// serialize/merge write side stays host-side until a host needs it.\n\nimport { FAILSAFE_SCHEMA, load as yamlLoad } from \"js-yaml\";\n\nconst FRONTMATTER_OPEN = /^---\\r?\\n/;\n// `(?:^|\\r?\\n)` lets the closing fence sit at the very start of\n// `afterOpen` — needed for the empty-envelope case `---\\n---\\n`.\nconst FRONTMATTER_CLOSE = /(?:^|\\r?\\n)---\\s*(?:\\r?\\n|$)/;\n\n/** Parse `---\\n…\\n---\\n` frontmatter. Never throws; malformed YAML in a\n * well-formed envelope yields `{ meta: {}, hasHeader: false }`. */\nexport function parseFrontmatter(raw: string): { meta: Record<string, unknown>; hasHeader: boolean } {\n if (!FRONTMATTER_OPEN.test(raw)) return { meta: {}, hasHeader: false };\n const afterOpen = raw.replace(FRONTMATTER_OPEN, \"\");\n const closeMatch = FRONTMATTER_CLOSE.exec(afterOpen);\n if (!closeMatch || closeMatch.index === undefined) return { meta: {}, hasHeader: false };\n const meta = safeYamlLoad(afterOpen.slice(0, closeMatch.index));\n if (meta === null) return { meta: {}, hasHeader: false };\n return { meta, hasHeader: true };\n}\n\nfunction safeYamlLoad(text: string): Record<string, unknown> | null {\n // js-yaml throws on empty/whitespace-only input; an empty header\n // (`---\\n---\\n`) means \"no metadata\", not \"malformed\".\n if (text.trim() === \"\") return {};\n try {\n // FAILSAFE_SCHEMA keeps every scalar a string (no YAML-1.1 date\n // coercion, no numeric-string truncation) — matches the host.\n const loaded = yamlLoad(text, { schema: FAILSAFE_SCHEMA });\n if (loaded === null || loaded === undefined) return {};\n if (typeof loaded !== \"object\" || Array.isArray(loaded)) return null;\n return loaded as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\nfunction cleanTagToken(token: string): string {\n return token\n .trim()\n .replace(/^['\"]|['\"]$/g, \"\")\n .replace(/^#/, \"\")\n .toLowerCase();\n}\n\n/** Narrow `tags:` reader. Handles flow (`tags: [a, b]`) and block-list\n * style; anything unparseable returns `[]` (best-effort, never throws). */\nexport function parseFrontmatterTags(content: string): string[] {\n const parsed = parseFrontmatter(content);\n if (!parsed.hasHeader) return [];\n const tagsValue = parsed.meta.tags;\n if (!Array.isArray(tagsValue)) return [];\n return tagsValue\n .filter((item): item is string => typeof item === \"string\")\n .map(cleanTagToken)\n .filter((token) => token.length > 0);\n}\n","// Workspace-injected wiki read-engine — the filesystem layer that feeds\n// the pure helpers in `@mulmoclaude/core/wiki`. Shared by every host so\n// the two apps reading the same `data/wiki/` can't disagree on slug\n// resolution, graph edges, or lint findings. Mirrors the bodies that\n// previously lived in MulmoClaude's `server/api/routes/wiki.ts`; the\n// hosts keep only their HTTP response shaping on top.\n//\n// The read/write WRITE side (writeWikiPage / snapshots) stays host-side\n// until a host needs it shared.\n\nimport path from \"node:path\";\nimport { parseWikiLink } from \"../link.js\";\nimport { wikiSlugify } from \"../slug.js\";\nimport { type WikiPageEntry, parseIndexEntries } from \"../index-parse.js\";\nimport { findBrokenLinksInPage, findMissingFiles, findOrphanPages, findTagDrift } from \"../lint.js\";\nimport { type WikiGraph, buildWikiGraph } from \"../graph.js\";\nimport { readTextSafe, readTextSafeSync } from \"./fs.js\";\nimport { getPageIndex } from \"./pageIndex.js\";\nimport { parseFrontmatterTags } from \"./frontmatter.js\";\nimport { wikiDirs } from \"./paths.js\";\n\n// Below this length the fuzzy `includes` step is skipped — CJK /\n// emoji-only / very-short page names slugify down to a short noise\n// tail that partial-matches almost anything; the index.md title-match\n// fallback still handles the legitimate non-ASCII case (#1194).\nconst MIN_FUZZY_SLUG_LEN = 6;\n\nfunction readFileOrEmpty(absPath: string): string {\n return readTextSafeSync(absPath) ?? \"\";\n}\n\n/** Walk every indexed slug for an `includes`-style match. Returns the\n * single best candidate, or null when the slug is too short OR several\n * candidates tie at the top score (ambiguous → defer to the caller's\n * title-match fallback). Score = min/max length, decoupled from Map\n * iteration order so resolution is deterministic across hosts. */\nexport function pickFuzzyMatch(slug: string, slugs: ReadonlyMap<string, string>): string | null {\n if (slug.length < MIN_FUZZY_SLUG_LEN) return null;\n let bestFile: string | null = null;\n let bestScore = 0;\n let bestIsTied = false;\n for (const [key, file] of slugs) {\n if (!slug.includes(key) && !key.includes(slug)) continue;\n const shorter = Math.min(slug.length, key.length);\n const longer = Math.max(slug.length, key.length);\n const score = shorter / longer;\n if (score > bestScore) {\n bestScore = score;\n bestFile = file;\n bestIsTied = false;\n } else if (score === bestScore) {\n bestIsTied = true;\n }\n }\n return bestIsTied ? null : bestFile;\n}\n\n/** Resolve a page name to an absolute `.md` path: exact slug → fuzzy →\n * index-title fallback (for non-ASCII names that slugify to empty).\n * `pageName` may carry the `[[target|display]]` form; `parseWikiLink`\n * strips the display half so the lookup uses just the target. */\nexport async function resolvePagePath(workspace: string, pageName: string): Promise<string | null> {\n const { pagesDir, indexFile } = wikiDirs(workspace);\n const { slugs } = await getPageIndex(pagesDir);\n if (slugs.size === 0) return null;\n\n const { target } = parseWikiLink(pageName);\n const slug = wikiSlugify(target);\n\n if (slug.length > 0) {\n const exact = slugs.get(slug);\n if (exact) return path.join(pagesDir, exact);\n const fuzzy = pickFuzzyMatch(slug, slugs);\n if (fuzzy) return path.join(pagesDir, fuzzy);\n }\n\n const entries = parseIndexEntries(readFileOrEmpty(indexFile));\n const titleMatch = entries.find((entry) => entry.title === target);\n if (titleMatch) {\n const file = slugs.get(titleMatch.slug);\n if (file) return path.join(pagesDir, file);\n }\n return null;\n}\n\n/** Raw `index.md` content + its parsed entries. */\nexport function readWikiIndex(workspace: string): { content: string; entries: WikiPageEntry[] } {\n const content = readFileOrEmpty(wikiDirs(workspace).indexFile);\n return { content, entries: parseIndexEntries(content) };\n}\n\n/** Raw `log.md` content (empty string if absent). */\nexport function readWikiLog(workspace: string): string {\n return readFileOrEmpty(wikiDirs(workspace).logFile);\n}\n\nexport interface WikiPageRead {\n /** Absolute path of the resolved file, or null when nothing matched. */\n filePath: string | null;\n /** File body (empty when missing OR when the file is an empty placeholder). */\n content: string;\n /** True iff a page file resolved (distinct from empty content). */\n exists: boolean;\n /** Title to display — the resolved filename stem, or the raw pageName. */\n resolvedTitle: string;\n}\n\n/** Resolve + read a page. Distinguishes missing (`exists: false`) from\n * empty-but-present (`exists: true`, `content: \"\"`). */\nexport async function readWikiPage(workspace: string, pageName: string): Promise<WikiPageRead> {\n const filePath = await resolvePagePath(workspace, pageName);\n const content = filePath ? readFileOrEmpty(filePath) : \"\";\n const resolvedTitle = filePath ? path.basename(filePath, \".md\") : pageName;\n return { filePath, content, exists: Boolean(filePath), resolvedTitle };\n}\n\n/** Read every page + the index and build the page→page link graph.\n * No cache: the graph is requested explicitly and a content edit does\n * not advance the pagesDir mtime the page index caches on. */\nexport async function loadWikiGraph(workspace: string): Promise<WikiGraph> {\n const { pagesDir, indexFile } = wikiDirs(workspace);\n const { slugs } = await getPageIndex(pagesDir);\n const fileEntries = [...slugs.entries()];\n const contents = await Promise.all(fileEntries.map(async ([, fileName]) => (await readTextSafe(path.join(pagesDir, fileName))) ?? \"\"));\n const pages = fileEntries.map(([slug], i) => ({ slug, content: contents[i] }));\n const indexEntries = parseIndexEntries(readFileOrEmpty(indexFile));\n return buildWikiGraph(pages, indexEntries);\n}\n\n/** Run every lint rule over the on-disk wiki, returning issue strings. */\nexport async function collectLintIssues(workspace: string): Promise<string[]> {\n const { pagesDir, indexFile } = wikiDirs(workspace);\n const { slugs } = await getPageIndex(pagesDir);\n if (slugs.size === 0) {\n return [\"- Wiki `pages/` directory does not exist yet. Start ingesting sources.\"];\n }\n const pageEntries = parseIndexEntries(readFileOrEmpty(indexFile));\n const indexedSlugs = new Set(pageEntries.map((entry) => entry.slug));\n const pageFiles = [...slugs.values()];\n const fileSlugs = new Set(slugs.keys());\n\n const issues: string[] = [];\n issues.push(...findOrphanPages(fileSlugs, indexedSlugs));\n issues.push(...findMissingFiles(pageEntries, fileSlugs));\n const contents = await Promise.all(pageFiles.map(async (fileName) => (await readTextSafe(path.join(pagesDir, fileName))) ?? \"\"));\n const frontmatterTagsBySlug = new Map<string, string[]>();\n for (let i = 0; i < pageFiles.length; i++) {\n issues.push(...findBrokenLinksInPage(pageFiles[i], contents[i], fileSlugs));\n // Lowercase the key so a `MyPage.md` filename matches an\n // `entry.slug` of `mypage`; `findTagDrift` lowercases the lookup.\n const slug = pageFiles[i].replace(/\\.md$/i, \"\").toLowerCase();\n frontmatterTagsBySlug.set(slug, parseFrontmatterTags(contents[i]));\n }\n issues.push(...findTagDrift(pageEntries, frontmatterTagsBySlug));\n return issues;\n}\n"],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ export interface PageIndex {
2
+ mtimeMs: number;
3
+ /** slug → filename (e.g. "sakura-internet" → "sakura-internet.md"). */
4
+ slugs: Map<string, string>;
5
+ }
6
+ /**
7
+ * Get the page index for `pagesDir`. Returns a cached value as long as
8
+ * THAT directory's mtime hasn't advanced; otherwise rebuilds. Safe to
9
+ * call concurrently — racing builds produce the same result.
10
+ */
11
+ export declare function getPageIndex(pagesDir: string): Promise<PageIndex>;
12
+ /** Test-only: drop the module-level cache (all directories). */
13
+ export declare function __resetPageIndexCache(): void;