@mulmoclaude/core 4.2.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/helps/collection-skills.md +17 -6
- package/assets/helps/custom-view-remote.md +28 -11
- package/assets/helps/error-recovery.md +73 -3
- package/dist/collection/registry/server/index.cjs +1 -1
- package/dist/collection/registry/server/index.js +1 -1
- package/dist/collection/server/index.cjs +2 -1
- package/dist/collection/server/index.js +2 -2
- package/dist/collection/server/manageTool.d.ts +24 -0
- package/dist/collection-watchers/index.cjs +1 -1
- package/dist/collection-watchers/index.js +1 -1
- package/dist/feeds/server/index.cjs +1 -1
- package/dist/feeds/server/index.js +1 -1
- package/dist/{graph-BZJxiv1E.cjs → graph-CURotEkH.cjs} +88 -37
- package/dist/graph-CURotEkH.cjs.map +1 -0
- package/dist/{graph-Ce7xsDTt.js → graph-DBxSOx3v.js} +77 -38
- package/dist/graph-DBxSOx3v.js.map +1 -0
- package/dist/{server-C-Ngd3g2.js → server-BvXO49Vj.js} +80 -13
- package/dist/{server-C-Ngd3g2.js.map → server-BvXO49Vj.js.map} +1 -1
- package/dist/{server-DZkN0GGh.cjs → server-CF1qllGV.cjs} +85 -12
- package/dist/{server-DZkN0GGh.cjs.map → server-CF1qllGV.cjs.map} +1 -1
- package/dist/{slug-CdN-pQX1.js → slug-BpjumQfe.js} +26 -5
- package/dist/{slug-CdN-pQX1.js.map → slug-BpjumQfe.js.map} +1 -1
- package/dist/{slug-DbwORN9z.cjs → slug-JxgB0ru9.cjs} +31 -4
- package/dist/{slug-DbwORN9z.cjs.map → slug-JxgB0ru9.cjs.map} +1 -1
- package/dist/wiki/graph.d.ts +0 -5
- package/dist/wiki/index.cjs +5 -2
- package/dist/wiki/index.d.ts +3 -2
- package/dist/wiki/index.js +3 -3
- package/dist/wiki/lint.d.ts +8 -9
- package/dist/wiki/paths.cjs +1 -1
- package/dist/wiki/paths.js +1 -1
- package/dist/wiki/resolve.d.ts +24 -0
- package/dist/wiki/server/engine.d.ts +12 -4
- package/dist/wiki/server/index.cjs +46 -34
- package/dist/wiki/server/index.cjs.map +1 -1
- package/dist/wiki/server/index.js +46 -34
- package/dist/wiki/server/index.js.map +1 -1
- package/dist/wiki/slug.d.ts +14 -3
- package/package.json +1 -1
- package/dist/graph-BZJxiv1E.cjs.map +0 -1
- package/dist/graph-Ce7xsDTt.js.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import {
|
|
1
|
+
import { a as findMissingFiles, b as parseWikiLink, g as matchWikiSlug, i as findBrokenLinksInPage, m as parseIndexEntries, o as findOrphanPages, s as findTagDrift, t as buildWikiGraph, v as slugByIndexTitle } from "../../graph-DBxSOx3v.js";
|
|
2
|
+
import { r as wikiSlugify } from "../../slug-BpjumQfe.js";
|
|
3
3
|
import { wikiDirs, wikiSlugFromAbsPath } from "../paths.js";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
@@ -110,28 +110,31 @@ function pickFuzzyMatch(slug, slugs) {
|
|
|
110
110
|
}
|
|
111
111
|
return bestIsTied ? null : bestFile;
|
|
112
112
|
}
|
|
113
|
-
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
|
|
113
|
+
/** File matching an index.md entry whose title equals `target` — the
|
|
114
|
+
* last resort for a link written as the display title rather than
|
|
115
|
+
* the page's own name. */
|
|
116
|
+
function fileByIndexTitle(indexFile, target, slugs) {
|
|
117
|
+
const entries = parseIndexEntries(readFileOrEmpty(indexFile));
|
|
118
|
+
const trimmed = target.trim();
|
|
119
|
+
const titleMatch = entries.find((entry) => entry.title === trimmed);
|
|
120
|
+
return titleMatch ? slugs.get(titleMatch.slug) : void 0;
|
|
121
|
+
}
|
|
122
|
+
/** Resolve a page name to an absolute `.md` path: known slug (literal
|
|
123
|
+
* or slugified) → fuzzy → index-title fallback. `pageName` may carry
|
|
124
|
+
* the `[[target|display]]` form; `parseWikiLink` strips the display
|
|
125
|
+
* half so the lookup uses just the target. */
|
|
117
126
|
async function resolvePagePath(workspace, pageName) {
|
|
118
127
|
const { pagesDir, indexFile } = wikiDirs(workspace);
|
|
119
128
|
const { slugs } = await getPageIndex(pagesDir);
|
|
120
129
|
if (slugs.size === 0) return null;
|
|
121
130
|
const { target } = parseWikiLink(pageName);
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const titleMatch = parseIndexEntries(readFileOrEmpty(indexFile)).find((entry) => entry.title === target);
|
|
130
|
-
if (titleMatch) {
|
|
131
|
-
const file = slugs.get(titleMatch.slug);
|
|
132
|
-
if (file) return path.join(pagesDir, file);
|
|
133
|
-
}
|
|
134
|
-
return null;
|
|
131
|
+
const matched = matchWikiSlug(target, slugs);
|
|
132
|
+
const matchedFile = matched === null ? void 0 : slugs.get(matched);
|
|
133
|
+
if (matchedFile) return path.join(pagesDir, matchedFile);
|
|
134
|
+
const fuzzy = pickFuzzyMatch(wikiSlugify(target), slugs);
|
|
135
|
+
if (fuzzy) return path.join(pagesDir, fuzzy);
|
|
136
|
+
const titleFile = fileByIndexTitle(indexFile, target, slugs);
|
|
137
|
+
return titleFile ? path.join(pagesDir, titleFile) : null;
|
|
135
138
|
}
|
|
136
139
|
/** Raw `index.md` content + its parsed entries. */
|
|
137
140
|
function readWikiIndex(workspace) {
|
|
@@ -175,28 +178,37 @@ async function loadWikiGraph(workspace) {
|
|
|
175
178
|
const indexEntries = parseIndexEntries(readFileOrEmpty(indexFile));
|
|
176
179
|
return buildWikiGraph(pages, indexEntries);
|
|
177
180
|
}
|
|
181
|
+
async function readPageBodies(pagesDir, fileNames) {
|
|
182
|
+
return Promise.all(fileNames.map(async (fileName) => ({
|
|
183
|
+
fileName,
|
|
184
|
+
content: await readPageBody(pagesDir, fileName)
|
|
185
|
+
})));
|
|
186
|
+
}
|
|
187
|
+
/** Frontmatter `tags:` per page, keyed for `findTagDrift`. Lowercased so
|
|
188
|
+
* a `MyPage.md` filename matches an `entry.slug` of `mypage`, which is
|
|
189
|
+
* what the drift rule looks up. */
|
|
190
|
+
function frontmatterTagIndex(bodies) {
|
|
191
|
+
const tagsBySlug = /* @__PURE__ */ new Map();
|
|
192
|
+
bodies.forEach(({ fileName, content }) => {
|
|
193
|
+
tagsBySlug.set(fileName.replace(/\.md$/i, "").toLowerCase(), parseFrontmatterTags(content));
|
|
194
|
+
});
|
|
195
|
+
return tagsBySlug;
|
|
196
|
+
}
|
|
178
197
|
/** Run every lint rule over the on-disk wiki, returning issue strings. */
|
|
179
198
|
async function collectLintIssues(workspace) {
|
|
180
199
|
const { pagesDir, indexFile } = wikiDirs(workspace);
|
|
181
200
|
const { slugs } = await getPageIndex(pagesDir);
|
|
182
201
|
if (slugs.size === 0) return ["- Wiki `pages/` directory does not exist yet. Start ingesting sources."];
|
|
183
202
|
const pageEntries = parseIndexEntries(readFileOrEmpty(indexFile));
|
|
184
|
-
const indexedSlugs = new Set(pageEntries.map((entry) => entry.slug));
|
|
185
203
|
const fileSlugs = new Set(slugs.keys());
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
for (const { fileName, content } of bodies) {
|
|
195
|
-
issues.push(...findBrokenLinksInPage(fileName, content, fileSlugs));
|
|
196
|
-
frontmatterTagsBySlug.set(fileName.replace(/\.md$/i, "").toLowerCase(), parseFrontmatterTags(content));
|
|
197
|
-
}
|
|
198
|
-
issues.push(...findTagDrift(pageEntries, frontmatterTagsBySlug));
|
|
199
|
-
return issues;
|
|
204
|
+
const slugByTitle = slugByIndexTitle(pageEntries);
|
|
205
|
+
const bodies = await readPageBodies(pagesDir, [...slugs.values()]);
|
|
206
|
+
return [
|
|
207
|
+
...findOrphanPages(fileSlugs, new Set(pageEntries.map((entry) => entry.slug))),
|
|
208
|
+
...findMissingFiles(pageEntries, fileSlugs),
|
|
209
|
+
...bodies.flatMap(({ fileName, content }) => findBrokenLinksInPage(fileName, content, fileSlugs, slugByTitle)),
|
|
210
|
+
...findTagDrift(pageEntries, frontmatterTagIndex(bodies))
|
|
211
|
+
];
|
|
200
212
|
}
|
|
201
213
|
//#endregion
|
|
202
214
|
export { __resetPageIndexCache, collectLintIssues, getPageIndex, loadWikiGraph, parseFrontmatter, parseFrontmatterTags, pickFuzzyMatch, readWikiIndex, readWikiLog, readWikiPage, resolvePagePath, wikiDirs, wikiSlugFromAbsPath };
|
|
@@ -1 +1 @@
|
|
|
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 frontmatter helpers for the wiki engine. The generic\n// `---\\n…\\n---\\n` envelope parsing is the canonical value-preserving\n// (FAILSAFE_SCHEMA) parser in `@mulmoclaude/markdown-utils`; only the\n// wiki-specific `tags:` reader stays here. Re-exporting `parseFrontmatter`\n// keeps the `@mulmoclaude/core/wiki/server` barrel surface stable — it now\n// returns the richer `{ meta, body, hasHeader }` shape.\n\nimport { parseFrontmatter } from \"@mulmoclaude/markdown-utils/markdown/frontmatter\";\n\nexport { parseFrontmatter };\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/** Page body, or \"\" when the file vanished between indexing and reading. */\nasync function readPageBody(pagesDir: string, fileName: string): Promise<string> {\n return (await readTextSafe(path.join(pagesDir, fileName))) ?? \"\";\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 pages = await Promise.all([...slugs.entries()].map(async ([slug, fileName]) => ({ slug, content: await readPageBody(pagesDir, fileName) })));\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 fileSlugs = new Set(slugs.keys());\n const bodies = await Promise.all([...slugs.values()].map(async (fileName) => ({ fileName, content: await readPageBody(pagesDir, fileName) })));\n\n const issues: string[] = [];\n issues.push(...findOrphanPages(fileSlugs, indexedSlugs));\n issues.push(...findMissingFiles(pageEntries, fileSlugs));\n const frontmatterTagsBySlug = new Map<string, string[]>();\n for (const { fileName, content } of bodies) {\n issues.push(...findBrokenLinksInPage(fileName, content, fileSlugs));\n // Lowercase the key so a `MyPage.md` filename matches an\n // `entry.slug` of `mypage`; `findTagDrift` lowercases the lookup.\n frontmatterTagsBySlug.set(fileName.replace(/\\.md$/i, \"\").toLowerCase(), parseFrontmatterTags(content));\n }\n issues.push(...findTagDrift(pageEntries, frontmatterTagsBySlug));\n return issues;\n}\n"],"mappings":"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"}
|
|
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 frontmatter helpers for the wiki engine. The generic\n// `---\\n…\\n---\\n` envelope parsing is the canonical value-preserving\n// (FAILSAFE_SCHEMA) parser in `@mulmoclaude/markdown-utils`; only the\n// wiki-specific `tags:` reader stays here. Re-exporting `parseFrontmatter`\n// keeps the `@mulmoclaude/core/wiki/server` barrel surface stable — it now\n// returns the richer `{ meta, body, hasHeader }` shape.\n\nimport { parseFrontmatter } from \"@mulmoclaude/markdown-utils/markdown/frontmatter\";\n\nexport { parseFrontmatter };\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 { matchWikiSlug, slugByIndexTitle } from \"../resolve.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/** File matching an index.md entry whose title equals `target` — the\n * last resort for a link written as the display title rather than\n * the page's own name. */\nfunction fileByIndexTitle(indexFile: string, target: string, slugs: ReadonlyMap<string, string>): string | undefined {\n const entries = parseIndexEntries(readFileOrEmpty(indexFile));\n const trimmed = target.trim();\n const titleMatch = entries.find((entry) => entry.title === trimmed);\n return titleMatch ? slugs.get(titleMatch.slug) : undefined;\n}\n\n/** Resolve a page name to an absolute `.md` path: known slug (literal\n * or slugified) → fuzzy → index-title fallback. `pageName` may carry\n * the `[[target|display]]` form; `parseWikiLink` strips the display\n * 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 matched = matchWikiSlug(target, slugs);\n const matchedFile = matched === null ? undefined : slugs.get(matched);\n if (matchedFile) return path.join(pagesDir, matchedFile);\n\n const fuzzy = pickFuzzyMatch(wikiSlugify(target), slugs);\n if (fuzzy) return path.join(pagesDir, fuzzy);\n\n const titleFile = fileByIndexTitle(indexFile, target, slugs);\n return titleFile ? path.join(pagesDir, titleFile) : 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/** Page body, or \"\" when the file vanished between indexing and reading. */\nasync function readPageBody(pagesDir: string, fileName: string): Promise<string> {\n return (await readTextSafe(path.join(pagesDir, fileName))) ?? \"\";\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 pages = await Promise.all([...slugs.entries()].map(async ([slug, fileName]) => ({ slug, content: await readPageBody(pagesDir, fileName) })));\n const indexEntries = parseIndexEntries(readFileOrEmpty(indexFile));\n return buildWikiGraph(pages, indexEntries);\n}\n\nexport interface PageBody {\n fileName: string;\n content: string;\n}\n\nasync function readPageBodies(pagesDir: string, fileNames: readonly string[]): Promise<PageBody[]> {\n return Promise.all(fileNames.map(async (fileName) => ({ fileName, content: await readPageBody(pagesDir, fileName) })));\n}\n\n/** Frontmatter `tags:` per page, keyed for `findTagDrift`. Lowercased so\n * a `MyPage.md` filename matches an `entry.slug` of `mypage`, which is\n * what the drift rule looks up. */\nexport function frontmatterTagIndex(bodies: readonly PageBody[]): Map<string, string[]> {\n const tagsBySlug = new Map<string, string[]>();\n bodies.forEach(({ fileName, content }) => {\n tagsBySlug.set(fileName.replace(/\\.md$/i, \"\").toLowerCase(), parseFrontmatterTags(content));\n });\n return tagsBySlug;\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 fileSlugs = new Set(slugs.keys());\n const slugByTitle = slugByIndexTitle(pageEntries);\n const bodies = await readPageBodies(pagesDir, [...slugs.values()]);\n return [\n ...findOrphanPages(fileSlugs, new Set(pageEntries.map((entry) => entry.slug))),\n ...findMissingFiles(pageEntries, fileSlugs),\n ...bodies.flatMap(({ fileName, content }) => findBrokenLinksInPage(fileName, content, fileSlugs, slugByTitle)),\n ...findTagDrift(pageEntries, frontmatterTagIndex(bodies)),\n ];\n}\n"],"mappings":"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"}
|
package/dist/wiki/slug.d.ts
CHANGED
|
@@ -18,7 +18,18 @@ export declare function isSafeSlug(slug: string): boolean;
|
|
|
18
18
|
* Pure: no normalisation, no transliteration — this is the
|
|
19
19
|
* same shape the index parser, page resolver, and frontend
|
|
20
20
|
* renderer all need to agree on. Non-ASCII titles (e.g.
|
|
21
|
-
* Japanese) collapse to an
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Japanese) collapse to an ASCII remnant here, so a caller
|
|
22
|
+
* matching against real page files must go through
|
|
23
|
+
* `matchWikiSlug`, which tries the untouched name first. */
|
|
24
24
|
export declare function wikiSlugify(text: string): string;
|
|
25
|
+
/** Filename stem to suggest when a page has to be created for `name`,
|
|
26
|
+
* or null when `name` cannot yield one.
|
|
27
|
+
*
|
|
28
|
+
* ASCII names keep the hyphenated slug convention. A non-ASCII name is
|
|
29
|
+
* suggested verbatim — the write chokepoint accepts any `isSafeSlug`
|
|
30
|
+
* filename, and slugifying it would propose a stripped remnant like
|
|
31
|
+
* `-4.md` (#2940). Null rather than a salvaged stem for a name the
|
|
32
|
+
* write guard would reject anyway: suggesting `wiki/pages/.md`, or
|
|
33
|
+
* `secrets.md` for `../secrets`, sends the caller to a file it did
|
|
34
|
+
* not ask for. */
|
|
35
|
+
export declare function wikiPageStem(name: string): string | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mulmoclaude/core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "Shared server-side core for MulmoClaude and MulmoTerminal — the always-shipped-together subsystems consolidated behind subpath exports so the two hosts can't drift. Server-only except the browser-safe ./artifacts, ./whisper/client, ./workspace-setup/slug, ./translation/client, ./remote-view, ./remote-host and ./plugin-vue entries. All host specifics are injected.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"graph-BZJxiv1E.cjs","names":[],"sources":["../src/wiki/link.ts","../src/wiki/index-parse.ts","../src/wiki/lint.ts","../src/wiki/graph.ts"],"sourcesContent":["// Pure helpers for `[[wiki-link]]` syntax. Used by the lint\n// (`server/api/routes/wiki.ts`), the page resolver, and the\n// frontend renderer (`src/plugins/wiki/helpers.ts`) — before this\n// module the three sites each had their own implementation and\n// none of them handled the `[[target|display]]` form, producing\n// false-positive \"broken link\" warnings on slug-aliased links\n// (issue #1297).\n//\n// All functions are pure string operations; safe to import from\n// browser bundles. No `node:*` imports anywhere in this file.\n\n/** Inline `[[...]]` link pattern. The capture group is the raw\n * text between the brackets — the caller is expected to feed it\n * through `parseWikiLink` to split off any `|display` suffix.\n *\n * Body length is capped at 200 chars to keep the regex linear:\n * catastrophic backtracking isn't possible (no nested\n * alternation), but a malicious page with thousands of\n * unmatched `[[` could still pin the CPU. 200 is well above the\n * longest legitimate title we've seen and matches the cap the\n * server-side `WIKI_LINK_PATTERN` constant has carried since the\n * original implementation in #951.\n *\n * Shared with the renderer's hand-rolled scanner so the two can't\n * drift: the renderer must reject the same newline-bearing and\n * over-length bodies, or a link would render clickable while the\n * graph / backlinks / lint (which use this pattern) never see it. */\nexport const WIKI_LINK_MAX_LEN = 200;\nexport const WIKI_LINK_PATTERN = new RegExp(`\\\\[\\\\[([^\\\\][\\\\r\\\\n]{1,${WIKI_LINK_MAX_LEN}})\\\\]\\\\]`, \"g\");\n\n/** The structural shape of an inline `[[target|display]]` link.\n * `target` is what the resolver / lint compares against page\n * slugs; `display` is what the renderer shows to the user.\n * When the user writes `[[foo]]` (no pipe), both fields hold\n * `foo`. */\nexport interface WikiLink {\n target: string;\n display: string;\n}\n\n/** Split the raw text inside `[[...]]` into target + display.\n *\n * - `[[foo|Bar Baz]]` → `{ target: \"foo\", display: \"Bar Baz\" }`\n * - `[[foo]]` → `{ target: \"foo\", display: \"foo\" }`\n * - `[[|empty target]]` → `{ target: \"\", display: \"empty target\" }`\n * (caller decides whether an empty target is meaningful;\n * typically lint flags it, renderer shows the display text raw)\n * - `[[foo|]]` → `{ target: \"foo\", display: \"\" }`\n * (same — caller decides)\n *\n * Whitespace around the pipe is preserved on the display side\n * (so the user can write `[[foo| ある記事 ]]` and get the\n * spacing intentionally) but TRIMMED on the target side, since\n * a slug-comparable target with leading/trailing whitespace is\n * always a typo.\n *\n * Only the FIRST pipe splits. `[[a|b|c]]` becomes\n * `{ target: \"a\", display: \"b|c\" }` so display strings can\n * legitimately contain a literal `|`. */\nexport function parseWikiLink(inner: string): WikiLink {\n const pipeIdx = inner.indexOf(\"|\");\n if (pipeIdx === -1) {\n return { target: inner, display: inner };\n }\n const target = inner.slice(0, pipeIdx).trim();\n const display = inner.slice(pipeIdx + 1);\n return { target, display };\n}\n","// Pure parsers for `data/wiki/index.md` content. Used by:\n// - server/api/routes/wiki.ts — page resolver + lint\n// - frontend lint preview (future) — same parser, no fork\n//\n// All exports here are pure string ops; no `node:*` imports allowed.\n// If a future caller needs to walk the filesystem, that wrapper goes\n// under `@mulmoclaude/core/wiki/server` (today only `server/paths.ts`\n// touches `node:path`; nothing here touches disk).\n\nimport { parseWikiLink } from \"./link.js\";\nimport { wikiSlugify } from \"./slug.js\";\n\nexport interface WikiPageEntry {\n title: string;\n slug: string;\n description: string;\n tags: string[];\n}\n\n// ── Patterns ──────────────────────────────────────────────────\n// Bullet patterns live here (not server/utils/regex.ts) because the\n// index parser is the only consumer and the regexes are part of the\n// public surface that callers may want to reason about in tests.\n// Linear in line length: every `[^x]+` runs over a fixed exclusion\n// set with a hard delimiter; the optional summary group's `(.*)` is\n// a single greedy run with no nested overlap.\n\n// eslint-disable-next-line security/detect-unsafe-regex -- bullet-link parser; bounded captures with hard delimiters\nexport const BULLET_LINK_PATTERN = /^[-*]\\s+\\[([^\\]]+)\\]\\(([^)]*)\\)(?:\\s*[—–-]\\s*(.*))?/;\n// eslint-disable-next-line security/detect-unsafe-regex -- same shape as BULLET_LINK_PATTERN\nexport const BULLET_WIKI_LINK_PATTERN = /^[-*]\\s+\\[\\[([^\\]]+)\\]\\](?:\\s*[—–-]\\s*(.*))?/;\n\nconst TABLE_SEPARATOR_PATTERN = /^\\|[\\s|:-]+\\|$/;\n\n// Unicode-aware tag body: any letter or number in any script (so\n// Japanese / Chinese / Korean tags like `#クラウド` or `#可視化` work),\n// plus `-` and `_` as internal joiners. First char is a letter or\n// number only — no leading punctuation.\nconst HASHTAG_PATTERN = /(?:^|\\s)#([\\p{L}\\p{N}][\\p{L}\\p{N}_-]*)/gu;\n\n// ── Tag extraction ────────────────────────────────────────────\n\n/** Extract `#tag` tokens from a bullet description, returning the\n * stripped description and a sorted, deduped, lowercased tag list.\n * Only matches at word boundaries so mid-word `#` (e.g. anchor\n * URLs) is left alone. */\nexport function extractHashTags(text: string): { description: string; tags: string[] } {\n HASHTAG_PATTERN.lastIndex = 0;\n const tags = [...text.matchAll(HASHTAG_PATTERN)].flatMap(([, tag]) => (tag ? [tag.toLowerCase()] : []));\n const description = text.replace(HASHTAG_PATTERN, \"\").replace(/\\s+/g, \" \").trim();\n const deduped = [...new Set(tags)].sort();\n return { description, tags: deduped };\n}\n\n/** Split a table Tags cell — tolerates comma, whitespace, or `#`\n * prefixes. Empty cell yields an empty list. */\nexport function parseTagsCell(cell: string): string[] {\n const tokens = cell\n .split(/[,\\s]+/)\n .map((token) => token.trim().replace(/^#/, \"\").toLowerCase())\n .filter((token) => token.length > 0);\n return [...new Set(tokens)].sort();\n}\n\n// ── Table parser ──────────────────────────────────────────────\n\n/** Map header cell names → column indices, case- and whitespace-\n * tolerant. Used by the row parser to locate the Tags column (and\n * any other named column) without assuming a fixed position, so\n * older 3- and 4-column tables keep working. */\nexport function buildTableColumnMap(headerRow: string): Map<string, number> {\n const cells = headerRow\n .split(\"|\")\n .slice(1, -1)\n // Mirror parseTableRow's cell-normalising: strip the surrounding\n // backticks that commonly wrap cell values in wiki tables.\n // Without this, a `| \\`tags\\` |` header maps to the key \"`tags`\"\n // and the subsequent `columnMap.get(\"tags\")` lookup silently\n // misses the column, falling back to `tags: []`.\n .map((cell) => cell.trim().replace(/^`|`$/g, \"\").toLowerCase());\n const map = new Map<string, number>();\n cells.forEach((cell, i) => {\n if (cell) map.set(cell, i);\n });\n return map;\n}\n\ninterface TableColumnIndices {\n slug: number;\n title: number;\n summary: number;\n /** Undefined when the table has no `tags` column — caller skips\n * the tags lookup entirely and the row gets `tags: []`. */\n tags: number | undefined;\n}\n\n/** Resolve the per-column indices the row parser needs. Falls back\n * to positional defaults (0/1/2) when the table has no header map.\n * \"summary\" is the canonical column name; \"description\" is accepted\n * as a legacy alias used by older fixtures. */\nfunction resolveTableColumnIndices(columnMap: Map<string, number> | null): TableColumnIndices {\n return {\n slug: columnMap?.get(\"slug\") ?? 0,\n title: columnMap?.get(\"title\") ?? 1,\n summary: columnMap?.get(\"summary\") ?? columnMap?.get(\"description\") ?? 2,\n tags: columnMap?.get(\"tags\"),\n };\n}\n\nfunction parseTableRow(trimmed: string, columnMap: Map<string, number> | null): WikiPageEntry | null {\n const cols = trimmed\n .split(\"|\")\n .slice(1, -1)\n .map((column) => column.trim().replace(/^`|`$/g, \"\"));\n if (cols.length < 2) return null;\n\n const idx = resolveTableColumnIndices(columnMap);\n const slug = cols[idx.slug] ?? \"\";\n const title = cols[idx.title] || slug;\n if (!slug || !title) return null;\n\n const description = cols[idx.summary] ?? \"\";\n const tags = idx.tags !== undefined ? parseTagsCell(cols[idx.tags] ?? \"\") : [];\n return { title, slug, description, tags };\n}\n\n// ── Bullet-row parsers ────────────────────────────────────────\n\n/** Extract the slug segment from a bullet link's href. Accepts the\n * canonical `pages/<slug>.md`, a bare `<slug>.md`, or just `<slug>`\n * — the three forms produced by different historical writers of\n * index.md. Returns \"\" for hrefs that don't look like a wiki page\n * reference (e.g. `https://example.com`) so the caller can fall\n * back to title-based slugification. */\nexport function extractSlugFromBulletHref(rawHref: string): string {\n const href = rawHref.trim();\n if (!href) return \"\";\n if (/^[a-z]+:\\/\\//i.test(href)) return \"\";\n const lastSegment = href.split(\"/\").pop() ?? href;\n return lastSegment.replace(/\\.md$/i, \"\");\n}\n\nfunction parseBulletLinkRow(trimmed: string): WikiPageEntry | null {\n const match = BULLET_LINK_PATTERN.exec(trimmed);\n if (!match) return null;\n const title = match[1]?.trim() ?? \"\";\n const href = match[2] ?? \"\";\n const raw = match[3]?.trim() ?? \"\";\n const { description, tags } = extractHashTags(raw);\n // Prefer the slug embedded in the href so non-ASCII titles keep\n // a navigable slug. Fall back to slugifying the title only when\n // the href has no recognisable slug (rare — usually means the\n // author put an external URL here).\n const slug = extractSlugFromBulletHref(href) || wikiSlugify(title);\n return { title, slug, description, tags };\n}\n\nfunction parseBulletWikiLinkRow(trimmed: string): WikiPageEntry | null {\n const match = BULLET_WIKI_LINK_PATTERN.exec(trimmed);\n if (!match) return null;\n // Split `[[target|display]]` so the bullet entry's slug derives\n // from the TARGET (which is already a slug-shaped identifier\n // when the author wrote the alias form) and the title shows\n // the DISPLAY half. Pre-#1297 the parser slugified the full\n // `target|display` body, which collapses `|` to \"\" and\n // produces a wrong slug — Codex review on PR #1312.\n const { target, display } = parseWikiLink(match[1] ?? \"\");\n const title = (display || target).trim();\n const slug = target ? wikiSlugify(target) : wikiSlugify(title);\n const raw = match[2]?.trim() ?? \"\";\n const { description, tags } = extractHashTags(raw);\n return { title, slug, description, tags };\n}\n\n// ── Top-level parser ──────────────────────────────────────────\n\n/** Parse entries from index.md. Supports three formats:\n * 1. Table: `| slug | Title | Summary | (Tags) |`\n * 2. Bullet link: `- [Title](pages/slug.md) — description`\n * 3. Wiki link: `- [[Title]] — description`\n *\n * Returns entries in source order. Any unrecognised line is\n * silently skipped — index.md is freeform markdown otherwise. */\nexport function parseIndexEntries(content: string): WikiPageEntry[] {\n const entries: WikiPageEntry[] = [];\n let inTable = false;\n let columnMap: Map<string, number> | null = null;\n\n for (const line of content.split(\"\\n\")) {\n const trimmed = line.trim();\n\n if (trimmed.startsWith(\"|\")) {\n if (TABLE_SEPARATOR_PATTERN.test(trimmed)) {\n inTable = true;\n continue;\n }\n if (!inTable) {\n // First `|`-line before the separator is the header. Capture\n // the column map so parseTableRow can locate the Tags\n // column (if any) by name rather than position.\n columnMap = buildTableColumnMap(trimmed);\n inTable = true;\n continue;\n }\n const entry = parseTableRow(trimmed, columnMap);\n if (entry) entries.push(entry);\n continue;\n }\n\n inTable = false;\n columnMap = null;\n\n const bullet = parseBulletLinkRow(trimmed) ?? parseBulletWikiLinkRow(trimmed);\n if (bullet) entries.push(bullet);\n }\n return entries;\n}\n","// Pure wiki lint rules.\n//\n// Each rule takes plain inputs (Sets, page-entry arrays, raw file\n// contents) so the whole pipeline can be unit-tested without\n// filesystem fixtures. The Express route at\n// `server/api/routes/wiki.ts` is a thin shell that reads the disk,\n// calls these rules, formats the result. A future frontend \"lint\n// preview before save\" would call the same functions over an\n// in-memory snapshot.\n\nimport type { WikiPageEntry } from \"./index-parse.js\";\nimport { WIKI_LINK_PATTERN, parseWikiLink } from \"./link.js\";\nimport { wikiSlugify } from \"./slug.js\";\n\n/** Files on disk that aren't referenced by index.md. */\nexport function findOrphanPages(fileSlugs: ReadonlySet<string>, indexedSlugs: ReadonlySet<string>): string[] {\n const issues: string[] = [];\n for (const slug of fileSlugs) {\n if (!indexedSlugs.has(slug)) {\n issues.push(`- **Orphan page**: \\`${slug}.md\\` exists but is missing from index.md`);\n }\n }\n return issues;\n}\n\n/** Slugs referenced by index.md that have no corresponding file. */\nexport function findMissingFiles(pageEntries: readonly WikiPageEntry[], fileSlugs: ReadonlySet<string>): string[] {\n const issues: string[] = [];\n for (const entry of pageEntries) {\n if (!fileSlugs.has(entry.slug)) {\n issues.push(`- **Missing file**: index.md references \\`${entry.slug}\\` but the file does not exist`);\n }\n }\n return issues;\n}\n\n/** Walk a page's body for `[[…]]` links and flag any whose\n * resolved slug doesn't exist in the file set.\n *\n * Critically: this routes through `parseWikiLink` so\n * `[[slug|display]]` is split correctly — the lint slugifies the\n * TARGET, not the full bracket body. Pre-#1297 the lint\n * slugified the entire content (`slug|display`), which collapsed\n * to a slug that always missed and produced ~168 false-positive\n * \"broken link\" warnings. */\nexport function findBrokenLinksInPage(fileName: string, content: string, fileSlugs: ReadonlySet<string>): string[] {\n const issues: string[] = [];\n const matches = [...content.matchAll(WIKI_LINK_PATTERN)];\n for (const [, body = \"\"] of matches) {\n const { target } = parseWikiLink(body);\n const linkSlug = wikiSlugify(target);\n // Empty target is its own diagnostic — `[[|display]]` or\n // `[[]]` slugifies to \"\" and would otherwise be flagged\n // identically to a real broken link. Keep the original raw\n // bracket body in the report so the user can grep their pages\n // for the malformed link.\n if (linkSlug.length === 0) {\n issues.push(`- **Broken link** in \\`${fileName}\\`: [[${body}]] → empty target`);\n continue;\n }\n if (!fileSlugs.has(linkSlug)) {\n issues.push(`- **Broken link** in \\`${fileName}\\`: [[${body}]] → \\`${linkSlug}.md\\` not found`);\n }\n }\n return issues;\n}\n\nfunction formatTagList(tags: readonly string[]): string {\n return `[${[...tags].sort().join(\", \")}]`;\n}\n\n/** Flag any slug whose index.md tags differ from the page's own\n * frontmatter `tags:` field. Comparison is set-based and order-\n * insensitive; both sides are lowercased at parse time. Slugs\n * missing from `frontmatterTagsBySlug` are ignored here — the\n * missing file itself is already reported by `findMissingFiles`. */\nexport function findTagDrift(pageEntries: readonly WikiPageEntry[], frontmatterTagsBySlug: ReadonlyMap<string, readonly string[]>): string[] {\n const issues: string[] = [];\n for (const entry of pageEntries) {\n // Lowercase on lookup — the caller keys the map with\n // lowercased slugs, so a `MyPage.md` filename still matches\n // an `entry.slug` of `mypage` produced by `wikiSlugify` on the\n // wiki-link parser path.\n const pageTags = frontmatterTagsBySlug.get(entry.slug.toLowerCase());\n if (pageTags === undefined) continue;\n const pageSet = new Set(pageTags);\n const indexSet = new Set(entry.tags);\n if (pageSet.size !== indexSet.size || [...pageSet].some((tag) => !indexSet.has(tag))) {\n issues.push(`- **Tag drift**: \\`${entry.slug}.md\\` frontmatter has ${formatTagList(pageTags)} but index.md has ${formatTagList(entry.tags)}`);\n }\n }\n return issues;\n}\n\n/** Render the final markdown report from the accumulated issues\n * list. Empty input yields the \"wiki is healthy\" sentinel so the\n * caller can write the same file every time without branching on\n * presence. */\nexport function formatLintReport(issues: readonly string[]): string {\n if (issues.length === 0) {\n return \"# Wiki Lint Report\\n\\n✓ No issues found. Wiki is healthy.\";\n }\n const noun = `issue${issues.length !== 1 ? \"s\" : \"\"}`;\n return `# Wiki Lint Report\\n\\n${issues.length} ${noun} found:\\n\\n${issues.join(\"\\n\")}`;\n}\n","// Pure builder for the wiki page→page link graph. Used by:\n// - server/api/routes/wiki.ts — the `graph` action endpoint\n// - src/plugins/wiki/View.vue — backlinks (\"linked references\") +\n// the Graph tab\n//\n// All functions are pure string / collection ops; no `node:*` imports,\n// so the frontend bundle can import them directly (same discipline as\n// the sibling `link.ts` / `lint.ts` / `index-parse.ts` modules).\n//\n// NOTE: this is page→page link structure, distinct from the\n// `server/workspace/wiki-backlinks/` module, which appends *session*\n// backlinks (a page → the chat that edited it, #109). Different concept\n// — do not conflate.\n\nimport { WIKI_LINK_PATTERN, parseWikiLink } from \"./link.js\";\nimport { wikiSlugify } from \"./slug.js\";\nimport type { WikiPageEntry } from \"./index-parse.js\";\n\nexport interface WikiGraphNode {\n slug: string;\n title: string;\n}\n\nexport interface WikiGraphEdge {\n from: string;\n to: string;\n}\n\nexport interface WikiGraph {\n nodes: WikiGraphNode[];\n edges: WikiGraphEdge[];\n}\n\n/** One page's raw body plus its canonical (file-derived) slug. */\nexport interface WikiPageContent {\n slug: string;\n content: string;\n}\n\n/** Resolve a raw `[[link]]` target to an existing page slug, or null.\n * Mirrors the route resolver's strategy: slugify first, then fall\n * back to matching an index entry title so non-ASCII targets like\n * `[[さくらインターネット]]` resolve to their ASCII file slug. */\nexport function resolveLinkTarget(target: string, fileSlugs: ReadonlySet<string>, slugByTitle: ReadonlyMap<string, string>): string | null {\n const slug = wikiSlugify(target);\n if (slug.length > 0 && fileSlugs.has(slug)) return slug;\n const byTitle = slugByTitle.get(target.trim());\n if (byTitle !== undefined && fileSlugs.has(byTitle)) return byTitle;\n return null;\n}\n\n/** Resolved, deduped outgoing slugs for one page body. Self-links and\n * links to non-existent pages are dropped (the latter are already a\n * lint \"broken link\", not a graph edge). */\nexport function pageOutgoingSlugs(fromSlug: string, content: string, fileSlugs: ReadonlySet<string>, slugByTitle: ReadonlyMap<string, string>): string[] {\n const out = new Set<string>();\n for (const [, body = \"\"] of content.matchAll(WIKI_LINK_PATTERN)) {\n const { target } = parseWikiLink(body);\n const resolved = resolveLinkTarget(target, fileSlugs, slugByTitle);\n if (resolved !== null && resolved !== fromSlug) out.add(resolved);\n }\n return [...out];\n}\n\nfunction buildTitleMaps(entries: readonly WikiPageEntry[]): { titleBySlug: Map<string, string>; slugByTitle: Map<string, string> } {\n const titleBySlug = new Map<string, string>();\n const slugByTitle = new Map<string, string>();\n for (const entry of entries) {\n if (!titleBySlug.has(entry.slug)) titleBySlug.set(entry.slug, entry.title);\n if (entry.title.length > 0 && !slugByTitle.has(entry.title)) slugByTitle.set(entry.title, entry.slug);\n }\n return { titleBySlug, slugByTitle };\n}\n\n/** Build the full page→page graph. Nodes are the existing page files\n * (titled from index.md, falling back to the slug for un-indexed\n * pages); edges are the resolved `[[links]]`, deduped per (from,to). */\nexport function buildWikiGraph(pages: readonly WikiPageContent[], entries: readonly WikiPageEntry[]): WikiGraph {\n const fileSlugs = new Set(pages.map((page) => page.slug));\n const { titleBySlug, slugByTitle } = buildTitleMaps(entries);\n const nodes: WikiGraphNode[] = pages.map((page) => ({ slug: page.slug, title: titleBySlug.get(page.slug) ?? page.slug }));\n const edges: WikiGraphEdge[] = [];\n const seen = new Set<string>();\n for (const page of pages) {\n for (const toSlug of pageOutgoingSlugs(page.slug, page.content, fileSlugs, slugByTitle)) {\n // Newline cannot appear in a wiki slug, so it is a safe pair key.\n const key = [page.slug, toSlug].join(\"\\n\");\n if (seen.has(key)) continue;\n seen.add(key);\n edges.push({ from: page.slug, to: toSlug });\n }\n }\n return { nodes, edges };\n}\n\n/** Pages that link TO `slug` (incoming edges), deduped, as nodes. */\nexport function incomingLinks(graph: WikiGraph, slug: string): WikiGraphNode[] {\n const nodeBySlug = new Map(graph.nodes.map((node) => [node.slug, node]));\n const result: WikiGraphNode[] = [];\n const seen = new Set<string>();\n for (const edge of graph.edges) {\n if (edge.to !== slug || seen.has(edge.from)) continue;\n seen.add(edge.from);\n const node = nodeBySlug.get(edge.from);\n if (node) result.push(node);\n }\n return result;\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"graph-Ce7xsDTt.js","names":[],"sources":["../src/wiki/link.ts","../src/wiki/index-parse.ts","../src/wiki/lint.ts","../src/wiki/graph.ts"],"sourcesContent":["// Pure helpers for `[[wiki-link]]` syntax. Used by the lint\n// (`server/api/routes/wiki.ts`), the page resolver, and the\n// frontend renderer (`src/plugins/wiki/helpers.ts`) — before this\n// module the three sites each had their own implementation and\n// none of them handled the `[[target|display]]` form, producing\n// false-positive \"broken link\" warnings on slug-aliased links\n// (issue #1297).\n//\n// All functions are pure string operations; safe to import from\n// browser bundles. No `node:*` imports anywhere in this file.\n\n/** Inline `[[...]]` link pattern. The capture group is the raw\n * text between the brackets — the caller is expected to feed it\n * through `parseWikiLink` to split off any `|display` suffix.\n *\n * Body length is capped at 200 chars to keep the regex linear:\n * catastrophic backtracking isn't possible (no nested\n * alternation), but a malicious page with thousands of\n * unmatched `[[` could still pin the CPU. 200 is well above the\n * longest legitimate title we've seen and matches the cap the\n * server-side `WIKI_LINK_PATTERN` constant has carried since the\n * original implementation in #951.\n *\n * Shared with the renderer's hand-rolled scanner so the two can't\n * drift: the renderer must reject the same newline-bearing and\n * over-length bodies, or a link would render clickable while the\n * graph / backlinks / lint (which use this pattern) never see it. */\nexport const WIKI_LINK_MAX_LEN = 200;\nexport const WIKI_LINK_PATTERN = new RegExp(`\\\\[\\\\[([^\\\\][\\\\r\\\\n]{1,${WIKI_LINK_MAX_LEN}})\\\\]\\\\]`, \"g\");\n\n/** The structural shape of an inline `[[target|display]]` link.\n * `target` is what the resolver / lint compares against page\n * slugs; `display` is what the renderer shows to the user.\n * When the user writes `[[foo]]` (no pipe), both fields hold\n * `foo`. */\nexport interface WikiLink {\n target: string;\n display: string;\n}\n\n/** Split the raw text inside `[[...]]` into target + display.\n *\n * - `[[foo|Bar Baz]]` → `{ target: \"foo\", display: \"Bar Baz\" }`\n * - `[[foo]]` → `{ target: \"foo\", display: \"foo\" }`\n * - `[[|empty target]]` → `{ target: \"\", display: \"empty target\" }`\n * (caller decides whether an empty target is meaningful;\n * typically lint flags it, renderer shows the display text raw)\n * - `[[foo|]]` → `{ target: \"foo\", display: \"\" }`\n * (same — caller decides)\n *\n * Whitespace around the pipe is preserved on the display side\n * (so the user can write `[[foo| ある記事 ]]` and get the\n * spacing intentionally) but TRIMMED on the target side, since\n * a slug-comparable target with leading/trailing whitespace is\n * always a typo.\n *\n * Only the FIRST pipe splits. `[[a|b|c]]` becomes\n * `{ target: \"a\", display: \"b|c\" }` so display strings can\n * legitimately contain a literal `|`. */\nexport function parseWikiLink(inner: string): WikiLink {\n const pipeIdx = inner.indexOf(\"|\");\n if (pipeIdx === -1) {\n return { target: inner, display: inner };\n }\n const target = inner.slice(0, pipeIdx).trim();\n const display = inner.slice(pipeIdx + 1);\n return { target, display };\n}\n","// Pure parsers for `data/wiki/index.md` content. Used by:\n// - server/api/routes/wiki.ts — page resolver + lint\n// - frontend lint preview (future) — same parser, no fork\n//\n// All exports here are pure string ops; no `node:*` imports allowed.\n// If a future caller needs to walk the filesystem, that wrapper goes\n// under `@mulmoclaude/core/wiki/server` (today only `server/paths.ts`\n// touches `node:path`; nothing here touches disk).\n\nimport { parseWikiLink } from \"./link.js\";\nimport { wikiSlugify } from \"./slug.js\";\n\nexport interface WikiPageEntry {\n title: string;\n slug: string;\n description: string;\n tags: string[];\n}\n\n// ── Patterns ──────────────────────────────────────────────────\n// Bullet patterns live here (not server/utils/regex.ts) because the\n// index parser is the only consumer and the regexes are part of the\n// public surface that callers may want to reason about in tests.\n// Linear in line length: every `[^x]+` runs over a fixed exclusion\n// set with a hard delimiter; the optional summary group's `(.*)` is\n// a single greedy run with no nested overlap.\n\n// eslint-disable-next-line security/detect-unsafe-regex -- bullet-link parser; bounded captures with hard delimiters\nexport const BULLET_LINK_PATTERN = /^[-*]\\s+\\[([^\\]]+)\\]\\(([^)]*)\\)(?:\\s*[—–-]\\s*(.*))?/;\n// eslint-disable-next-line security/detect-unsafe-regex -- same shape as BULLET_LINK_PATTERN\nexport const BULLET_WIKI_LINK_PATTERN = /^[-*]\\s+\\[\\[([^\\]]+)\\]\\](?:\\s*[—–-]\\s*(.*))?/;\n\nconst TABLE_SEPARATOR_PATTERN = /^\\|[\\s|:-]+\\|$/;\n\n// Unicode-aware tag body: any letter or number in any script (so\n// Japanese / Chinese / Korean tags like `#クラウド` or `#可視化` work),\n// plus `-` and `_` as internal joiners. First char is a letter or\n// number only — no leading punctuation.\nconst HASHTAG_PATTERN = /(?:^|\\s)#([\\p{L}\\p{N}][\\p{L}\\p{N}_-]*)/gu;\n\n// ── Tag extraction ────────────────────────────────────────────\n\n/** Extract `#tag` tokens from a bullet description, returning the\n * stripped description and a sorted, deduped, lowercased tag list.\n * Only matches at word boundaries so mid-word `#` (e.g. anchor\n * URLs) is left alone. */\nexport function extractHashTags(text: string): { description: string; tags: string[] } {\n HASHTAG_PATTERN.lastIndex = 0;\n const tags = [...text.matchAll(HASHTAG_PATTERN)].flatMap(([, tag]) => (tag ? [tag.toLowerCase()] : []));\n const description = text.replace(HASHTAG_PATTERN, \"\").replace(/\\s+/g, \" \").trim();\n const deduped = [...new Set(tags)].sort();\n return { description, tags: deduped };\n}\n\n/** Split a table Tags cell — tolerates comma, whitespace, or `#`\n * prefixes. Empty cell yields an empty list. */\nexport function parseTagsCell(cell: string): string[] {\n const tokens = cell\n .split(/[,\\s]+/)\n .map((token) => token.trim().replace(/^#/, \"\").toLowerCase())\n .filter((token) => token.length > 0);\n return [...new Set(tokens)].sort();\n}\n\n// ── Table parser ──────────────────────────────────────────────\n\n/** Map header cell names → column indices, case- and whitespace-\n * tolerant. Used by the row parser to locate the Tags column (and\n * any other named column) without assuming a fixed position, so\n * older 3- and 4-column tables keep working. */\nexport function buildTableColumnMap(headerRow: string): Map<string, number> {\n const cells = headerRow\n .split(\"|\")\n .slice(1, -1)\n // Mirror parseTableRow's cell-normalising: strip the surrounding\n // backticks that commonly wrap cell values in wiki tables.\n // Without this, a `| \\`tags\\` |` header maps to the key \"`tags`\"\n // and the subsequent `columnMap.get(\"tags\")` lookup silently\n // misses the column, falling back to `tags: []`.\n .map((cell) => cell.trim().replace(/^`|`$/g, \"\").toLowerCase());\n const map = new Map<string, number>();\n cells.forEach((cell, i) => {\n if (cell) map.set(cell, i);\n });\n return map;\n}\n\ninterface TableColumnIndices {\n slug: number;\n title: number;\n summary: number;\n /** Undefined when the table has no `tags` column — caller skips\n * the tags lookup entirely and the row gets `tags: []`. */\n tags: number | undefined;\n}\n\n/** Resolve the per-column indices the row parser needs. Falls back\n * to positional defaults (0/1/2) when the table has no header map.\n * \"summary\" is the canonical column name; \"description\" is accepted\n * as a legacy alias used by older fixtures. */\nfunction resolveTableColumnIndices(columnMap: Map<string, number> | null): TableColumnIndices {\n return {\n slug: columnMap?.get(\"slug\") ?? 0,\n title: columnMap?.get(\"title\") ?? 1,\n summary: columnMap?.get(\"summary\") ?? columnMap?.get(\"description\") ?? 2,\n tags: columnMap?.get(\"tags\"),\n };\n}\n\nfunction parseTableRow(trimmed: string, columnMap: Map<string, number> | null): WikiPageEntry | null {\n const cols = trimmed\n .split(\"|\")\n .slice(1, -1)\n .map((column) => column.trim().replace(/^`|`$/g, \"\"));\n if (cols.length < 2) return null;\n\n const idx = resolveTableColumnIndices(columnMap);\n const slug = cols[idx.slug] ?? \"\";\n const title = cols[idx.title] || slug;\n if (!slug || !title) return null;\n\n const description = cols[idx.summary] ?? \"\";\n const tags = idx.tags !== undefined ? parseTagsCell(cols[idx.tags] ?? \"\") : [];\n return { title, slug, description, tags };\n}\n\n// ── Bullet-row parsers ────────────────────────────────────────\n\n/** Extract the slug segment from a bullet link's href. Accepts the\n * canonical `pages/<slug>.md`, a bare `<slug>.md`, or just `<slug>`\n * — the three forms produced by different historical writers of\n * index.md. Returns \"\" for hrefs that don't look like a wiki page\n * reference (e.g. `https://example.com`) so the caller can fall\n * back to title-based slugification. */\nexport function extractSlugFromBulletHref(rawHref: string): string {\n const href = rawHref.trim();\n if (!href) return \"\";\n if (/^[a-z]+:\\/\\//i.test(href)) return \"\";\n const lastSegment = href.split(\"/\").pop() ?? href;\n return lastSegment.replace(/\\.md$/i, \"\");\n}\n\nfunction parseBulletLinkRow(trimmed: string): WikiPageEntry | null {\n const match = BULLET_LINK_PATTERN.exec(trimmed);\n if (!match) return null;\n const title = match[1]?.trim() ?? \"\";\n const href = match[2] ?? \"\";\n const raw = match[3]?.trim() ?? \"\";\n const { description, tags } = extractHashTags(raw);\n // Prefer the slug embedded in the href so non-ASCII titles keep\n // a navigable slug. Fall back to slugifying the title only when\n // the href has no recognisable slug (rare — usually means the\n // author put an external URL here).\n const slug = extractSlugFromBulletHref(href) || wikiSlugify(title);\n return { title, slug, description, tags };\n}\n\nfunction parseBulletWikiLinkRow(trimmed: string): WikiPageEntry | null {\n const match = BULLET_WIKI_LINK_PATTERN.exec(trimmed);\n if (!match) return null;\n // Split `[[target|display]]` so the bullet entry's slug derives\n // from the TARGET (which is already a slug-shaped identifier\n // when the author wrote the alias form) and the title shows\n // the DISPLAY half. Pre-#1297 the parser slugified the full\n // `target|display` body, which collapses `|` to \"\" and\n // produces a wrong slug — Codex review on PR #1312.\n const { target, display } = parseWikiLink(match[1] ?? \"\");\n const title = (display || target).trim();\n const slug = target ? wikiSlugify(target) : wikiSlugify(title);\n const raw = match[2]?.trim() ?? \"\";\n const { description, tags } = extractHashTags(raw);\n return { title, slug, description, tags };\n}\n\n// ── Top-level parser ──────────────────────────────────────────\n\n/** Parse entries from index.md. Supports three formats:\n * 1. Table: `| slug | Title | Summary | (Tags) |`\n * 2. Bullet link: `- [Title](pages/slug.md) — description`\n * 3. Wiki link: `- [[Title]] — description`\n *\n * Returns entries in source order. Any unrecognised line is\n * silently skipped — index.md is freeform markdown otherwise. */\nexport function parseIndexEntries(content: string): WikiPageEntry[] {\n const entries: WikiPageEntry[] = [];\n let inTable = false;\n let columnMap: Map<string, number> | null = null;\n\n for (const line of content.split(\"\\n\")) {\n const trimmed = line.trim();\n\n if (trimmed.startsWith(\"|\")) {\n if (TABLE_SEPARATOR_PATTERN.test(trimmed)) {\n inTable = true;\n continue;\n }\n if (!inTable) {\n // First `|`-line before the separator is the header. Capture\n // the column map so parseTableRow can locate the Tags\n // column (if any) by name rather than position.\n columnMap = buildTableColumnMap(trimmed);\n inTable = true;\n continue;\n }\n const entry = parseTableRow(trimmed, columnMap);\n if (entry) entries.push(entry);\n continue;\n }\n\n inTable = false;\n columnMap = null;\n\n const bullet = parseBulletLinkRow(trimmed) ?? parseBulletWikiLinkRow(trimmed);\n if (bullet) entries.push(bullet);\n }\n return entries;\n}\n","// Pure wiki lint rules.\n//\n// Each rule takes plain inputs (Sets, page-entry arrays, raw file\n// contents) so the whole pipeline can be unit-tested without\n// filesystem fixtures. The Express route at\n// `server/api/routes/wiki.ts` is a thin shell that reads the disk,\n// calls these rules, formats the result. A future frontend \"lint\n// preview before save\" would call the same functions over an\n// in-memory snapshot.\n\nimport type { WikiPageEntry } from \"./index-parse.js\";\nimport { WIKI_LINK_PATTERN, parseWikiLink } from \"./link.js\";\nimport { wikiSlugify } from \"./slug.js\";\n\n/** Files on disk that aren't referenced by index.md. */\nexport function findOrphanPages(fileSlugs: ReadonlySet<string>, indexedSlugs: ReadonlySet<string>): string[] {\n const issues: string[] = [];\n for (const slug of fileSlugs) {\n if (!indexedSlugs.has(slug)) {\n issues.push(`- **Orphan page**: \\`${slug}.md\\` exists but is missing from index.md`);\n }\n }\n return issues;\n}\n\n/** Slugs referenced by index.md that have no corresponding file. */\nexport function findMissingFiles(pageEntries: readonly WikiPageEntry[], fileSlugs: ReadonlySet<string>): string[] {\n const issues: string[] = [];\n for (const entry of pageEntries) {\n if (!fileSlugs.has(entry.slug)) {\n issues.push(`- **Missing file**: index.md references \\`${entry.slug}\\` but the file does not exist`);\n }\n }\n return issues;\n}\n\n/** Walk a page's body for `[[…]]` links and flag any whose\n * resolved slug doesn't exist in the file set.\n *\n * Critically: this routes through `parseWikiLink` so\n * `[[slug|display]]` is split correctly — the lint slugifies the\n * TARGET, not the full bracket body. Pre-#1297 the lint\n * slugified the entire content (`slug|display`), which collapsed\n * to a slug that always missed and produced ~168 false-positive\n * \"broken link\" warnings. */\nexport function findBrokenLinksInPage(fileName: string, content: string, fileSlugs: ReadonlySet<string>): string[] {\n const issues: string[] = [];\n const matches = [...content.matchAll(WIKI_LINK_PATTERN)];\n for (const [, body = \"\"] of matches) {\n const { target } = parseWikiLink(body);\n const linkSlug = wikiSlugify(target);\n // Empty target is its own diagnostic — `[[|display]]` or\n // `[[]]` slugifies to \"\" and would otherwise be flagged\n // identically to a real broken link. Keep the original raw\n // bracket body in the report so the user can grep their pages\n // for the malformed link.\n if (linkSlug.length === 0) {\n issues.push(`- **Broken link** in \\`${fileName}\\`: [[${body}]] → empty target`);\n continue;\n }\n if (!fileSlugs.has(linkSlug)) {\n issues.push(`- **Broken link** in \\`${fileName}\\`: [[${body}]] → \\`${linkSlug}.md\\` not found`);\n }\n }\n return issues;\n}\n\nfunction formatTagList(tags: readonly string[]): string {\n return `[${[...tags].sort().join(\", \")}]`;\n}\n\n/** Flag any slug whose index.md tags differ from the page's own\n * frontmatter `tags:` field. Comparison is set-based and order-\n * insensitive; both sides are lowercased at parse time. Slugs\n * missing from `frontmatterTagsBySlug` are ignored here — the\n * missing file itself is already reported by `findMissingFiles`. */\nexport function findTagDrift(pageEntries: readonly WikiPageEntry[], frontmatterTagsBySlug: ReadonlyMap<string, readonly string[]>): string[] {\n const issues: string[] = [];\n for (const entry of pageEntries) {\n // Lowercase on lookup — the caller keys the map with\n // lowercased slugs, so a `MyPage.md` filename still matches\n // an `entry.slug` of `mypage` produced by `wikiSlugify` on the\n // wiki-link parser path.\n const pageTags = frontmatterTagsBySlug.get(entry.slug.toLowerCase());\n if (pageTags === undefined) continue;\n const pageSet = new Set(pageTags);\n const indexSet = new Set(entry.tags);\n if (pageSet.size !== indexSet.size || [...pageSet].some((tag) => !indexSet.has(tag))) {\n issues.push(`- **Tag drift**: \\`${entry.slug}.md\\` frontmatter has ${formatTagList(pageTags)} but index.md has ${formatTagList(entry.tags)}`);\n }\n }\n return issues;\n}\n\n/** Render the final markdown report from the accumulated issues\n * list. Empty input yields the \"wiki is healthy\" sentinel so the\n * caller can write the same file every time without branching on\n * presence. */\nexport function formatLintReport(issues: readonly string[]): string {\n if (issues.length === 0) {\n return \"# Wiki Lint Report\\n\\n✓ No issues found. Wiki is healthy.\";\n }\n const noun = `issue${issues.length !== 1 ? \"s\" : \"\"}`;\n return `# Wiki Lint Report\\n\\n${issues.length} ${noun} found:\\n\\n${issues.join(\"\\n\")}`;\n}\n","// Pure builder for the wiki page→page link graph. Used by:\n// - server/api/routes/wiki.ts — the `graph` action endpoint\n// - src/plugins/wiki/View.vue — backlinks (\"linked references\") +\n// the Graph tab\n//\n// All functions are pure string / collection ops; no `node:*` imports,\n// so the frontend bundle can import them directly (same discipline as\n// the sibling `link.ts` / `lint.ts` / `index-parse.ts` modules).\n//\n// NOTE: this is page→page link structure, distinct from the\n// `server/workspace/wiki-backlinks/` module, which appends *session*\n// backlinks (a page → the chat that edited it, #109). Different concept\n// — do not conflate.\n\nimport { WIKI_LINK_PATTERN, parseWikiLink } from \"./link.js\";\nimport { wikiSlugify } from \"./slug.js\";\nimport type { WikiPageEntry } from \"./index-parse.js\";\n\nexport interface WikiGraphNode {\n slug: string;\n title: string;\n}\n\nexport interface WikiGraphEdge {\n from: string;\n to: string;\n}\n\nexport interface WikiGraph {\n nodes: WikiGraphNode[];\n edges: WikiGraphEdge[];\n}\n\n/** One page's raw body plus its canonical (file-derived) slug. */\nexport interface WikiPageContent {\n slug: string;\n content: string;\n}\n\n/** Resolve a raw `[[link]]` target to an existing page slug, or null.\n * Mirrors the route resolver's strategy: slugify first, then fall\n * back to matching an index entry title so non-ASCII targets like\n * `[[さくらインターネット]]` resolve to their ASCII file slug. */\nexport function resolveLinkTarget(target: string, fileSlugs: ReadonlySet<string>, slugByTitle: ReadonlyMap<string, string>): string | null {\n const slug = wikiSlugify(target);\n if (slug.length > 0 && fileSlugs.has(slug)) return slug;\n const byTitle = slugByTitle.get(target.trim());\n if (byTitle !== undefined && fileSlugs.has(byTitle)) return byTitle;\n return null;\n}\n\n/** Resolved, deduped outgoing slugs for one page body. Self-links and\n * links to non-existent pages are dropped (the latter are already a\n * lint \"broken link\", not a graph edge). */\nexport function pageOutgoingSlugs(fromSlug: string, content: string, fileSlugs: ReadonlySet<string>, slugByTitle: ReadonlyMap<string, string>): string[] {\n const out = new Set<string>();\n for (const [, body = \"\"] of content.matchAll(WIKI_LINK_PATTERN)) {\n const { target } = parseWikiLink(body);\n const resolved = resolveLinkTarget(target, fileSlugs, slugByTitle);\n if (resolved !== null && resolved !== fromSlug) out.add(resolved);\n }\n return [...out];\n}\n\nfunction buildTitleMaps(entries: readonly WikiPageEntry[]): { titleBySlug: Map<string, string>; slugByTitle: Map<string, string> } {\n const titleBySlug = new Map<string, string>();\n const slugByTitle = new Map<string, string>();\n for (const entry of entries) {\n if (!titleBySlug.has(entry.slug)) titleBySlug.set(entry.slug, entry.title);\n if (entry.title.length > 0 && !slugByTitle.has(entry.title)) slugByTitle.set(entry.title, entry.slug);\n }\n return { titleBySlug, slugByTitle };\n}\n\n/** Build the full page→page graph. Nodes are the existing page files\n * (titled from index.md, falling back to the slug for un-indexed\n * pages); edges are the resolved `[[links]]`, deduped per (from,to). */\nexport function buildWikiGraph(pages: readonly WikiPageContent[], entries: readonly WikiPageEntry[]): WikiGraph {\n const fileSlugs = new Set(pages.map((page) => page.slug));\n const { titleBySlug, slugByTitle } = buildTitleMaps(entries);\n const nodes: WikiGraphNode[] = pages.map((page) => ({ slug: page.slug, title: titleBySlug.get(page.slug) ?? page.slug }));\n const edges: WikiGraphEdge[] = [];\n const seen = new Set<string>();\n for (const page of pages) {\n for (const toSlug of pageOutgoingSlugs(page.slug, page.content, fileSlugs, slugByTitle)) {\n // Newline cannot appear in a wiki slug, so it is a safe pair key.\n const key = [page.slug, toSlug].join(\"\\n\");\n if (seen.has(key)) continue;\n seen.add(key);\n edges.push({ from: page.slug, to: toSlug });\n }\n }\n return { nodes, edges };\n}\n\n/** Pages that link TO `slug` (incoming edges), deduped, as nodes. */\nexport function incomingLinks(graph: WikiGraph, slug: string): WikiGraphNode[] {\n const nodeBySlug = new Map(graph.nodes.map((node) => [node.slug, node]));\n const result: WikiGraphNode[] = [];\n const seen = new Set<string>();\n for (const edge of graph.edges) {\n if (edge.to !== slug || seen.has(edge.from)) continue;\n seen.add(edge.from);\n const node = nodeBySlug.get(edge.from);\n if (node) result.push(node);\n }\n return result;\n}\n"],"mappings":";AA4BA,IAAa,oBAAoB,IAAI,OAAO,sCAAuD,GAAG;;;;;;;;;;;;;;;;;;;;AA+BtG,SAAgB,cAAc,OAAyB;CACrD,MAAM,UAAU,MAAM,QAAQ,GAAG;CACjC,IAAI,YAAY,IACd,OAAO;EAAE,QAAQ;EAAO,SAAS;CAAM;CAIzC,OAAO;EAAE,QAFM,MAAM,MAAM,GAAG,OAAO,CAAC,CAAC,KAE9B;EAAQ,SADD,MAAM,MAAM,UAAU,CACrB;CAAQ;AAC3B;;;ACvCA,IAAa,sBAAsB;AAEnC,IAAa,2BAA2B;AAExC,IAAM,0BAA0B;AAMhC,IAAM,kBAAkB;;;;;AAQxB,SAAgB,gBAAgB,MAAuD;CACrF,gBAAgB,YAAY;CAC5B,MAAM,OAAO,CAAC,GAAG,KAAK,SAAS,eAAe,CAAC,CAAC,CAAC,SAAS,GAAG,SAAU,MAAM,CAAC,IAAI,YAAY,CAAC,IAAI,CAAC,CAAE;CAGtG,OAAO;EAAE,aAFW,KAAK,QAAQ,iBAAiB,EAAE,CAAC,CAAC,QAAQ,QAAQ,GAAG,CAAC,CAAC,KAElE;EAAa,MADN,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,KACP;CAAQ;AACtC;;;AAIA,SAAgB,cAAc,MAAwB;CACpD,MAAM,SAAS,KACZ,MAAM,QAAQ,CAAC,CACf,KAAK,UAAU,MAAM,KAAK,CAAC,CAAC,QAAQ,MAAM,EAAE,CAAC,CAAC,YAAY,CAAC,CAAC,CAC5D,QAAQ,UAAU,MAAM,SAAS,CAAC;CACrC,OAAO,CAAC,GAAG,IAAI,IAAI,MAAM,CAAC,CAAC,CAAC,KAAK;AACnC;;;;;AAQA,SAAgB,oBAAoB,WAAwC;CAC1E,MAAM,QAAQ,UACX,MAAM,GAAG,CAAC,CACV,MAAM,GAAG,EAAE,CAAC,CAMZ,KAAK,SAAS,KAAK,KAAK,CAAC,CAAC,QAAQ,UAAU,EAAE,CAAC,CAAC,YAAY,CAAC;CAChE,MAAM,sBAAM,IAAI,IAAoB;CACpC,MAAM,SAAS,MAAM,MAAM;EACzB,IAAI,MAAM,IAAI,IAAI,MAAM,CAAC;CAC3B,CAAC;CACD,OAAO;AACT;;;;;AAeA,SAAS,0BAA0B,WAA2D;CAC5F,OAAO;EACL,MAAM,WAAW,IAAI,MAAM,KAAK;EAChC,OAAO,WAAW,IAAI,OAAO,KAAK;EAClC,SAAS,WAAW,IAAI,SAAS,KAAK,WAAW,IAAI,aAAa,KAAK;EACvE,MAAM,WAAW,IAAI,MAAM;CAC7B;AACF;AAEA,SAAS,cAAc,SAAiB,WAA6D;CACnG,MAAM,OAAO,QACV,MAAM,GAAG,CAAC,CACV,MAAM,GAAG,EAAE,CAAC,CACZ,KAAK,WAAW,OAAO,KAAK,CAAC,CAAC,QAAQ,UAAU,EAAE,CAAC;CACtD,IAAI,KAAK,SAAS,GAAG,OAAO;CAE5B,MAAM,MAAM,0BAA0B,SAAS;CAC/C,MAAM,OAAO,KAAK,IAAI,SAAS;CAC/B,MAAM,QAAQ,KAAK,IAAI,UAAU;CACjC,IAAI,CAAC,QAAQ,CAAC,OAAO,OAAO;CAI5B,OAAO;EAAE;EAAO;EAAM,aAFF,KAAK,IAAI,YAAY;EAEN,MADtB,IAAI,SAAS,KAAA,IAAY,cAAc,KAAK,IAAI,SAAS,EAAE,IAAI,CAAC;CACrC;AAC1C;;;;;;;AAUA,SAAgB,0BAA0B,SAAyB;CACjE,MAAM,OAAO,QAAQ,KAAK;CAC1B,IAAI,CAAC,MAAM,OAAO;CAClB,IAAI,gBAAgB,KAAK,IAAI,GAAG,OAAO;CAEvC,QADoB,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,KAAA,CAC1B,QAAQ,UAAU,EAAE;AACzC;AAEA,SAAS,mBAAmB,SAAuC;CACjE,MAAM,QAAQ,oBAAoB,KAAK,OAAO;CAC9C,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,QAAQ,MAAM,EAAE,EAAE,KAAK,KAAK;CAClC,MAAM,OAAO,MAAM,MAAM;CAEzB,MAAM,EAAE,aAAa,SAAS,gBADlB,MAAM,EAAE,EAAE,KAAK,KAAK,EACiB;CAMjD,OAAO;EAAE;EAAO,MADH,0BAA0B,IAAI,KAAK,YAAY,KAAK;EAC3C;EAAa;CAAK;AAC1C;AAEA,SAAS,uBAAuB,SAAuC;CACrE,MAAM,QAAQ,yBAAyB,KAAK,OAAO;CACnD,IAAI,CAAC,OAAO,OAAO;CAOnB,MAAM,EAAE,QAAQ,YAAY,cAAc,MAAM,MAAM,EAAE;CACxD,MAAM,SAAS,WAAW,OAAA,CAAQ,KAAK;CACvC,MAAM,OAAO,SAAS,YAAY,MAAM,IAAI,YAAY,KAAK;CAE7D,MAAM,EAAE,aAAa,SAAS,gBADlB,MAAM,EAAE,EAAE,KAAK,KAAK,EACiB;CACjD,OAAO;EAAE;EAAO;EAAM;EAAa;CAAK;AAC1C;;;;;;;;AAWA,SAAgB,kBAAkB,SAAkC;CAClE,MAAM,UAA2B,CAAC;CAClC,IAAI,UAAU;CACd,IAAI,YAAwC;CAE5C,KAAK,MAAM,QAAQ,QAAQ,MAAM,IAAI,GAAG;EACtC,MAAM,UAAU,KAAK,KAAK;EAE1B,IAAI,QAAQ,WAAW,GAAG,GAAG;GAC3B,IAAI,wBAAwB,KAAK,OAAO,GAAG;IACzC,UAAU;IACV;GACF;GACA,IAAI,CAAC,SAAS;IAIZ,YAAY,oBAAoB,OAAO;IACvC,UAAU;IACV;GACF;GACA,MAAM,QAAQ,cAAc,SAAS,SAAS;GAC9C,IAAI,OAAO,QAAQ,KAAK,KAAK;GAC7B;EACF;EAEA,UAAU;EACV,YAAY;EAEZ,MAAM,SAAS,mBAAmB,OAAO,KAAK,uBAAuB,OAAO;EAC5E,IAAI,QAAQ,QAAQ,KAAK,MAAM;CACjC;CACA,OAAO;AACT;;;;ACzMA,SAAgB,gBAAgB,WAAgC,cAA6C;CAC3G,MAAM,SAAmB,CAAC;CAC1B,KAAK,MAAM,QAAQ,WACjB,IAAI,CAAC,aAAa,IAAI,IAAI,GACxB,OAAO,KAAK,wBAAwB,KAAK,0CAA0C;CAGvF,OAAO;AACT;;AAGA,SAAgB,iBAAiB,aAAuC,WAA0C;CAChH,MAAM,SAAmB,CAAC;CAC1B,KAAK,MAAM,SAAS,aAClB,IAAI,CAAC,UAAU,IAAI,MAAM,IAAI,GAC3B,OAAO,KAAK,6CAA6C,MAAM,KAAK,+BAA+B;CAGvG,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,sBAAsB,UAAkB,SAAiB,WAA0C;CACjH,MAAM,SAAmB,CAAC;CAC1B,MAAM,UAAU,CAAC,GAAG,QAAQ,SAAS,iBAAiB,CAAC;CACvD,KAAK,MAAM,GAAG,OAAO,OAAO,SAAS;EACnC,MAAM,EAAE,WAAW,cAAc,IAAI;EACrC,MAAM,WAAW,YAAY,MAAM;EAMnC,IAAI,SAAS,WAAW,GAAG;GACzB,OAAO,KAAK,0BAA0B,SAAS,QAAQ,KAAK,kBAAkB;GAC9E;EACF;EACA,IAAI,CAAC,UAAU,IAAI,QAAQ,GACzB,OAAO,KAAK,0BAA0B,SAAS,QAAQ,KAAK,SAAS,SAAS,gBAAgB;CAElG;CACA,OAAO;AACT;AAEA,SAAS,cAAc,MAAiC;CACtD,OAAO,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,IAAI,EAAE;AACzC;;;;;;AAOA,SAAgB,aAAa,aAAuC,uBAAyE;CAC3I,MAAM,SAAmB,CAAC;CAC1B,KAAK,MAAM,SAAS,aAAa;EAK/B,MAAM,WAAW,sBAAsB,IAAI,MAAM,KAAK,YAAY,CAAC;EACnE,IAAI,aAAa,KAAA,GAAW;EAC5B,MAAM,UAAU,IAAI,IAAI,QAAQ;EAChC,MAAM,WAAW,IAAI,IAAI,MAAM,IAAI;EACnC,IAAI,QAAQ,SAAS,SAAS,QAAQ,CAAC,GAAG,OAAO,CAAC,CAAC,MAAM,QAAQ,CAAC,SAAS,IAAI,GAAG,CAAC,GACjF,OAAO,KAAK,sBAAsB,MAAM,KAAK,wBAAwB,cAAc,QAAQ,EAAE,oBAAoB,cAAc,MAAM,IAAI,GAAG;CAEhJ;CACA,OAAO;AACT;;;;;AAMA,SAAgB,iBAAiB,QAAmC;CAClE,IAAI,OAAO,WAAW,GACpB,OAAO;CAET,MAAM,OAAO,QAAQ,OAAO,WAAW,IAAI,MAAM;CACjD,OAAO,yBAAyB,OAAO,OAAO,GAAG,KAAK,aAAa,OAAO,KAAK,IAAI;AACrF;;;;;;;AC7DA,SAAgB,kBAAkB,QAAgB,WAAgC,aAAyD;CACzI,MAAM,OAAO,YAAY,MAAM;CAC/B,IAAI,KAAK,SAAS,KAAK,UAAU,IAAI,IAAI,GAAG,OAAO;CACnD,MAAM,UAAU,YAAY,IAAI,OAAO,KAAK,CAAC;CAC7C,IAAI,YAAY,KAAA,KAAa,UAAU,IAAI,OAAO,GAAG,OAAO;CAC5D,OAAO;AACT;;;;AAKA,SAAgB,kBAAkB,UAAkB,SAAiB,WAAgC,aAAoD;CACvJ,MAAM,sBAAM,IAAI,IAAY;CAC5B,KAAK,MAAM,GAAG,OAAO,OAAO,QAAQ,SAAS,iBAAiB,GAAG;EAC/D,MAAM,EAAE,WAAW,cAAc,IAAI;EACrC,MAAM,WAAW,kBAAkB,QAAQ,WAAW,WAAW;EACjE,IAAI,aAAa,QAAQ,aAAa,UAAU,IAAI,IAAI,QAAQ;CAClE;CACA,OAAO,CAAC,GAAG,GAAG;AAChB;AAEA,SAAS,eAAe,SAA2G;CACjI,MAAM,8BAAc,IAAI,IAAoB;CAC5C,MAAM,8BAAc,IAAI,IAAoB;CAC5C,KAAK,MAAM,SAAS,SAAS;EAC3B,IAAI,CAAC,YAAY,IAAI,MAAM,IAAI,GAAG,YAAY,IAAI,MAAM,MAAM,MAAM,KAAK;EACzE,IAAI,MAAM,MAAM,SAAS,KAAK,CAAC,YAAY,IAAI,MAAM,KAAK,GAAG,YAAY,IAAI,MAAM,OAAO,MAAM,IAAI;CACtG;CACA,OAAO;EAAE;EAAa;CAAY;AACpC;;;;AAKA,SAAgB,eAAe,OAAmC,SAA8C;CAC9G,MAAM,YAAY,IAAI,IAAI,MAAM,KAAK,SAAS,KAAK,IAAI,CAAC;CACxD,MAAM,EAAE,aAAa,gBAAgB,eAAe,OAAO;CAC3D,MAAM,QAAyB,MAAM,KAAK,UAAU;EAAE,MAAM,KAAK;EAAM,OAAO,YAAY,IAAI,KAAK,IAAI,KAAK,KAAK;CAAK,EAAE;CACxH,MAAM,QAAyB,CAAC;CAChC,MAAM,uBAAO,IAAI,IAAY;CAC7B,KAAK,MAAM,QAAQ,OACjB,KAAK,MAAM,UAAU,kBAAkB,KAAK,MAAM,KAAK,SAAS,WAAW,WAAW,GAAG;EAEvF,MAAM,MAAM,CAAC,KAAK,MAAM,MAAM,CAAC,CAAC,KAAK,IAAI;EACzC,IAAI,KAAK,IAAI,GAAG,GAAG;EACnB,KAAK,IAAI,GAAG;EACZ,MAAM,KAAK;GAAE,MAAM,KAAK;GAAM,IAAI;EAAO,CAAC;CAC5C;CAEF,OAAO;EAAE;EAAO;CAAM;AACxB;;AAGA,SAAgB,cAAc,OAAkB,MAA+B;CAC7E,MAAM,aAAa,IAAI,IAAI,MAAM,MAAM,KAAK,SAAS,CAAC,KAAK,MAAM,IAAI,CAAC,CAAC;CACvE,MAAM,SAA0B,CAAC;CACjC,MAAM,uBAAO,IAAI,IAAY;CAC7B,KAAK,MAAM,QAAQ,MAAM,OAAO;EAC9B,IAAI,KAAK,OAAO,QAAQ,KAAK,IAAI,KAAK,IAAI,GAAG;EAC7C,KAAK,IAAI,KAAK,IAAI;EAClB,MAAM,OAAO,WAAW,IAAI,KAAK,IAAI;EACrC,IAAI,MAAM,OAAO,KAAK,IAAI;CAC5B;CACA,OAAO;AACT"}
|