@mulmoclaude/core 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{atomic-C_7YpMiM.cjs → atomic-DhUk8uiM.cjs} +2 -2
- package/dist/{atomic-C_7YpMiM.cjs.map → atomic-DhUk8uiM.cjs.map} +1 -1
- package/dist/collection/registry/server/index.cjs +21 -26
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +18 -23
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/index.cjs +2 -2
- package/dist/collection/server/index.js +2 -2
- package/dist/collection-watchers/index.cjs +3 -3
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +3 -3
- package/dist/{discovery-DSLgRQhN.cjs → discovery-B_sHig7n.cjs} +5 -5
- package/dist/{discovery-DSLgRQhN.cjs.map → discovery-B_sHig7n.cjs.map} +1 -1
- package/dist/{discovery-BSedPW8A.js → discovery-BvnXyUel.js} +3 -3
- package/dist/{discovery-BSedPW8A.js.map → discovery-BvnXyUel.js.map} +1 -1
- package/dist/dist-CsgSfWwR.cjs.map +1 -1
- package/dist/dist-D8zokgGo.js.map +1 -1
- package/dist/feeds/paths.cjs +1 -1
- package/dist/feeds/server/index.cjs +5 -5
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +4 -4
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/file-change/index.cjs +1 -1
- package/dist/file-change/index.cjs.map +1 -1
- package/dist/files/index.cjs +3 -3
- package/dist/files/index.cjs.map +1 -1
- package/dist/google/index.cjs +7 -6
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.js +5 -4
- package/dist/google/index.js.map +1 -1
- package/dist/notifier/index.cjs +1 -1
- package/dist/notifier/index.js +1 -1
- package/dist/{notifier-CKFVVwDP.cjs → notifier-DZJj0sOh.cjs} +2 -2
- package/dist/{notifier-CKFVVwDP.cjs.map → notifier-DZJj0sOh.cjs.map} +1 -1
- package/dist/{notifier-CQD8NljP.js → notifier-DhnwR82U.js} +2 -2
- package/dist/{notifier-CQD8NljP.js.map → notifier-DhnwR82U.js.map} +1 -1
- package/dist/plugin-vue/i18n.cjs.map +1 -1
- package/dist/plugin-vue/index.cjs +39 -10
- package/dist/plugin-vue/index.cjs.map +1 -1
- package/dist/plugin-vue/index.js +39 -10
- package/dist/plugin-vue/index.js.map +1 -1
- package/dist/{relPath-CgGi4-Nv.cjs → relPath-CTAjGdCL.cjs} +2 -2
- package/dist/{relPath-CgGi4-Nv.cjs.map → relPath-CTAjGdCL.cjs.map} +1 -1
- package/dist/remote-host/server/index.cjs +3 -2
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +3 -2
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/remote-host-CtQjagPt.cjs.map +1 -1
- package/dist/{rolldown-runtime-D6vf50IK.cjs → rolldown-runtime-VH7oDXx4.cjs} +1 -1
- package/dist/scheduler/index.cjs +6 -4
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.js +5 -3
- package/dist/scheduler/index.js.map +1 -1
- package/dist/{server-B78f_bnY.js → server-DJt6USBQ.js} +11 -7
- package/dist/{server-B78f_bnY.js.map → server-DJt6USBQ.js.map} +1 -1
- package/dist/{server-By4oSql2.cjs → server-DoyGZMPS.cjs} +14 -10
- package/dist/{server-By4oSql2.cjs.map → server-DoyGZMPS.cjs.map} +1 -1
- package/dist/skill-bridge/index.cjs +1 -1
- package/dist/skill-bridge/index.cjs.map +1 -1
- package/dist/utils/fetch.cjs +1 -1
- package/dist/utils/fetch.cjs.map +1 -1
- package/dist/utils/fetch.js +1 -1
- package/dist/utils/fetch.js.map +1 -1
- package/dist/whisper/client.cjs.map +1 -1
- package/dist/whisper/client.js.map +1 -1
- package/dist/whisper/index.cjs +1 -1
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/wiki/index.cjs +2 -1
- package/dist/wiki/index.cjs.map +1 -1
- package/dist/wiki/index.js +2 -1
- package/dist/wiki/index.js.map +1 -1
- package/dist/wiki/paths.cjs +1 -1
- package/dist/wiki/server/index.cjs +5 -3
- package/dist/wiki/server/index.cjs.map +1 -1
- package/dist/wiki/server/index.js +4 -2
- package/dist/wiki/server/index.js.map +1 -1
- package/dist/workspace-setup/index.js.map +1 -1
- package/package.json +6 -6
package/dist/wiki/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":[],"sources":["../../src/wiki/route.ts","../../src/wiki/render.ts"],"sourcesContent":["// Pure helpers for reading, building, and validating wiki route\n// params. Kept free of Vue / vue-router imports so it can be shared\n// across hosts (MulmoClaude + MulmoTerminal) from\n// `@mulmoclaude/core/wiki`:\n//\n// - the host router guard (synchronous validation at navigation time)\n// - the wiki View (watcher + push helpers)\n// - the workspace-link click handler\n// - unit tests\n//\n// Mirrors the \"one file owns the URL ↔ domain mapping\" pattern so the\n// literals don't drift across the router definition, guards, views,\n// and tests.\n\nimport { isRecord } from \"@mulmoclaude/common\";\nimport { isSafeSlug } from \"./slug.js\";\n\n// URL segment used in `/wiki/:section/...`. Closed enum — the router\n// regex `(pages|log|lint-report)` rejects anything else.\nexport const WIKI_ROUTE_SECTION = {\n pages: \"pages\",\n log: \"log\",\n lintReport: \"lint-report\",\n graph: \"graph\",\n} as const;\n\nexport type WikiRouteSection = (typeof WIKI_ROUTE_SECTION)[keyof typeof WIKI_ROUTE_SECTION];\n\n// Internal action name sent to the server / shown in the View. Diverges\n// from the URL segment in one place only: `lint-report` (URL) vs\n// `lint_report` (action), because the server API still speaks the\n// underscore form.\nexport const WIKI_ACTION = {\n index: \"index\",\n page: \"page\",\n log: \"log\",\n lintReport: \"lint_report\",\n graph: \"graph\",\n save: \"save\",\n} as const;\n\nexport type WikiAction = (typeof WIKI_ACTION)[keyof typeof WIKI_ACTION];\n\n// Route-level representation. `pushWiki(target)` and\n// `readWikiRouteTarget(params)` both speak this so the watcher, the\n// button handlers, and the router guard agree on the same shape.\nexport type WikiTarget = { kind: \"index\" } | { kind: \"page\"; slug: string } | { kind: \"log\" } | { kind: \"lint_report\" } | { kind: \"graph\" };\n\n// Reject anything that could escape `data/wiki/pages/` or collide\n// with a different page. Vue Router decodes `%2F` back to `/` in\n// `route.params.slug`, so `/wiki/pages/..%2Fsecrets` lands here as\n// `slug === \"../secrets\"` — this check is the last line of defence\n// before the slug is passed to the server's page resolver. Non-\n// ASCII characters (e.g. Japanese page titles) are allowed; only\n// separators and the literal `.` / `..` are blocked.\n//\n// Delegates the actual rule to `isSafeSlug` in\n// `src/lib/wiki-page/slug.ts` (imported at the top of this file)\n// so server and frontend share one implementation (#1297). The\n// wrapper adds the type-guard / non-string-input handling the\n// router needs.\nexport function isSafeWikiSlug(value: unknown): value is string {\n return typeof value === \"string\" && isSafeSlug(value);\n}\n\n// Read `route.params` from the wiki route and normalise to a\n// `WikiTarget`. Returns `null` when the params describe an invalid\n// state (unknown section, missing slug for a page view, unsafe slug)\n// so the caller can decide what to do — the router guard redirects to\n// `/wiki`, the view watcher treats it as \"render the index\".\nexport function readWikiRouteTarget(params: unknown): WikiTarget | null {\n if (!isRecord(params)) return null;\n const { section, slug } = params;\n\n if (section === undefined || section === \"\") return { kind: \"index\" };\n\n if (section === WIKI_ROUTE_SECTION.pages) {\n if (!isSafeWikiSlug(slug)) return null;\n return { kind: \"page\", slug };\n }\n if (section === WIKI_ROUTE_SECTION.log) return { kind: \"log\" };\n if (section === WIKI_ROUTE_SECTION.lintReport) return { kind: \"lint_report\" };\n if (section === WIKI_ROUTE_SECTION.graph) return { kind: \"graph\" };\n\n return null;\n}\n\n// Inverse of `readWikiRouteTarget`: given a target, produce the\n// `{ section, slug }` params object that `router.push({ name: \"wiki\",\n// params })` needs.\n//\n// Optional route params are NOT cleared by named-route navigation\n// unless explicitly set — returning `{}` for `kind: \"index\"` would\n// leak the previous `section`/`slug` into the URL when navigating\n// from `/wiki/pages/foo` back to the index, leaving the user on\n// `/wiki/pages/foo` with the View believing it's the index. Pass\n// empty strings so the router writes out `/wiki` cleanly. The\n// readWikiRouteTarget() branch for `section === \"\"` already\n// normalises those back to `{ kind: \"index\" }`.\nexport function buildWikiRouteParams(target: WikiTarget): Record<string, string> {\n switch (target.kind) {\n case \"index\":\n return { section: \"\", slug: \"\" };\n case \"page\":\n return { section: WIKI_ROUTE_SECTION.pages, slug: target.slug };\n case \"log\":\n return { section: WIKI_ROUTE_SECTION.log, slug: \"\" };\n case \"lint_report\":\n return { section: WIKI_ROUTE_SECTION.lintReport, slug: \"\" };\n case \"graph\":\n return { section: WIKI_ROUTE_SECTION.graph, slug: \"\" };\n default: {\n const exhaustive: never = target;\n throw new Error(`unreachable WikiTarget kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n\n// Resolve a target to the action name the server expects. Centralises\n// the one place URL-shape and action-shape diverge (`lint-report` ↔\n// `lint_report`).\nexport function wikiActionFor(target: WikiTarget): WikiAction {\n switch (target.kind) {\n case \"index\":\n return WIKI_ACTION.index;\n case \"page\":\n return WIKI_ACTION.page;\n case \"log\":\n return WIKI_ACTION.log;\n case \"lint_report\":\n return WIKI_ACTION.lintReport;\n case \"graph\":\n return WIKI_ACTION.graph;\n default: {\n const exhaustive: never = target;\n throw new Error(`unreachable WikiTarget kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n","// Pure `[[wiki-link]]` → HTML renderer, shared across hosts via\n// `@mulmoclaude/core/wiki`. Extracted from the MulmoClaude host's\n// `src/plugins/wiki/helpers.ts` so MulmoTerminal can render the same\n// internal-link markup without forking the walker.\n//\n// String-only, no `marked` / DOM / Node deps — the host pipeline\n// (image-ref rewrite, marked.parse, task-interactive) wraps this.\n\nimport { escapeHtml } from \"@mulmoclaude/common\";\nimport { parseWikiLink, WIKI_LINK_MAX_LEN } from \"./link.js\";\n\n// `escapeHtml` moved down to the zero-dep leaf `@mulmoclaude/common` (#2483) so\n// `@mulmoclaude/markdown-utils` — a leaf THIS package depends on, hence unable\n// to import back up — can share it. Re-exported here because the wiki\n// consumers (collection-plugin's graph view, the host's wiki embeds and\n// spreadsheet view) already import it from `@mulmoclaude/core/wiki`.\nexport { escapeHtml };\n\n/**\n * Replace every `[[page name]]` occurrence in `content` with a\n * `<span class=\"wiki-link\" data-page=\"…\">…</span>` element. The\n * page name may not contain `[` or `]`; an opening `[[` that is\n * not followed later by `]]` (with no bare `[` or `]` in between)\n * is left untouched so malformed text renders as-is — matching the\n * previous regex's non-match behaviour.\n *\n * `[[target|display]]` is split via the shared `parseWikiLink`\n * helper so `data-page` carries only the target slug while the\n * visible text shows the display half (#1297). Both halves are\n * HTML-escaped before interpolation — `parseWikiLink` runs BEFORE\n * the host's `marked.parse`, so escaping has to happen here.\n */\nexport function renderWikiLinks(content: string): string {\n const out: string[] = [];\n let i = 0;\n while (i < content.length) {\n if (content[i] === \"[\" && content[i + 1] === \"[\") {\n const closeStart = findNextCloseBrackets(content, i + 2);\n if (closeStart !== -1) {\n const inner = content.slice(i + 2, closeStart);\n const { target, display } = parseWikiLink(inner);\n out.push(`<span class=\"wiki-link\" data-page=\"${escapeHtml(target)}\">${escapeHtml(display)}</span>`);\n i = closeStart + 2;\n continue;\n }\n }\n out.push(content.charAt(i));\n i++;\n }\n return out.join(\"\");\n}\n\n/**\n * Starting at `from`, scan forward for a `]]` sequence. Returns\n * the index of the first `]` of that pair, or -1 if the span isn't\n * a valid wiki-link body — mirroring `WIKI_LINK_PATTERN` exactly so\n * the renderer can't accept a link the graph / backlinks / lint\n * would reject. The pattern's body class is `[^\\][\\r\\n]`, so it\n * rejects `[` as well as `]`. Bails (-1) on:\n * - a bare `]` (regex `[^\\]]`),\n * - a `[` inside the body (regex `[^\\[]` — a nested `[[` opener\n * never belongs in a page name, and the pattern would not match),\n * - a `\\r` or `\\n` (regex `[^\\r\\n]` — a newline-bearing link must\n * not render clickable, and its slug must not reach the URL),\n * - a body longer than WIKI_LINK_MAX_LEN (regex `{1,200}`),\n * - a zero-length body (regex `{1,}` minimum),\n * - reaching end-of-input with no `]]`.\n */\nfunction findNextCloseBrackets(str: string, from: number): number {\n let j = from;\n while (j < str.length) {\n const char = str[j];\n if (char === \"]\") {\n if (str[j + 1] === \"]\" && j > from) return j;\n // Bare `]` inside the page-name span — regex would not match.\n return -1;\n }\n // A `[`, a newline, or a span past the cap: none match the pattern's\n // `[^\\][\\r\\n]{1,200}` body, so bail and let the caller emit `[[` literally.\n if (char === \"[\" || char === \"\\n\" || char === \"\\r\" || j - from >= WIKI_LINK_MAX_LEN) return -1;\n j++;\n }\n return -1;\n}\n"],"mappings":";;;;;AAmBA,IAAa,qBAAqB;CAChC,OAAO;CACP,KAAK;CACL,YAAY;CACZ,OAAO;AACT;AAQA,IAAa,cAAc;CACzB,OAAO;CACP,MAAM;CACN,KAAK;CACL,YAAY;CACZ,OAAO;CACP,MAAM;AACR;AAsBA,SAAgB,eAAe,OAAiC;CAC9D,OAAO,OAAO,UAAU,YAAY,aAAA,WAAW,KAAK;AACtD;AAOA,SAAgB,oBAAoB,QAAoC;CACtE,IAAI,CAAC,aAAA,SAAS,MAAM,GAAG,OAAO;CAC9B,MAAM,EAAE,SAAS,SAAS;CAE1B,IAAI,YAAY,KAAA,KAAa,YAAY,IAAI,OAAO,EAAE,MAAM,QAAQ;CAEpE,IAAI,YAAY,mBAAmB,OAAO;EACxC,IAAI,CAAC,eAAe,IAAI,GAAG,OAAO;EAClC,OAAO;GAAE,MAAM;GAAQ;EAAK;CAC9B;CACA,IAAI,YAAY,mBAAmB,KAAK,OAAO,EAAE,MAAM,MAAM;CAC7D,IAAI,YAAY,mBAAmB,YAAY,OAAO,EAAE,MAAM,cAAc;CAC5E,IAAI,YAAY,mBAAmB,OAAO,OAAO,EAAE,MAAM,QAAQ;CAEjE,OAAO;AACT;AAcA,SAAgB,qBAAqB,QAA4C;CAC/E,QAAQ,OAAO,MAAf;EACE,KAAK,SACH,OAAO;GAAE,SAAS;GAAI,MAAM;EAAG;EACjC,KAAK,QACH,OAAO;GAAE,SAAS,mBAAmB;GAAO,MAAM,OAAO;EAAK;EAChE,KAAK,OACH,OAAO;GAAE,SAAS,mBAAmB;GAAK,MAAM;EAAG;EACrD,KAAK,eACH,OAAO;GAAE,SAAS,mBAAmB;GAAY,MAAM;EAAG;EAC5D,KAAK,SACH,OAAO;GAAE,SAAS,mBAAmB;GAAO,MAAM;EAAG;EACvD,SAEE,MAAM,IAAI,MAAM,gCAAgC,KAAK,UAAU,MAAU,GAAG;CAEhF;AACF;AAKA,SAAgB,cAAc,QAAgC;CAC5D,QAAQ,OAAO,MAAf;EACE,KAAK,SACH,OAAO,YAAY;EACrB,KAAK,QACH,OAAO,YAAY;EACrB,KAAK,OACH,OAAO,YAAY;EACrB,KAAK,eACH,OAAO,YAAY;EACrB,KAAK,SACH,OAAO,YAAY;EACrB,SAEE,MAAM,IAAI,MAAM,gCAAgC,KAAK,UAAU,MAAU,GAAG;CAEhF;AACF;;;;;;;;;;;;;;;;;AC1GA,SAAgB,gBAAgB,SAAyB;CACvD,MAAM,MAAgB,CAAC;CACvB,IAAI,IAAI;CACR,OAAO,IAAI,QAAQ,QAAQ;EACzB,IAAI,QAAQ,OAAO,OAAO,QAAQ,IAAI,OAAO,KAAK;GAChD,MAAM,aAAa,sBAAsB,SAAS,IAAI,CAAC;GACvD,IAAI,eAAe,IAAI;IAErB,MAAM,EAAE,QAAQ,YAAY,cAAA,cADd,QAAQ,MAAM,IAAI,GAAG,UACO,CAAK;IAC/C,IAAI,KAAK,sCAAsC,aAAA,WAAW,MAAM,EAAE,IAAI,aAAA,WAAW,OAAO,EAAE,QAAQ;IAClG,IAAI,aAAa;IACjB;GACF;EACF;EACA,IAAI,KAAK,QAAQ,OAAO,CAAC,CAAC;EAC1B;CACF;CACA,OAAO,IAAI,KAAK,EAAE;AACpB;;;;;;;;;;;;;;;;;AAkBA,SAAS,sBAAsB,KAAa,MAAsB;CAChE,IAAI,IAAI;CACR,OAAO,IAAI,IAAI,QAAQ;EACrB,MAAM,OAAO,IAAI;EACjB,IAAI,SAAS,KAAK;GAChB,IAAI,IAAI,IAAI,OAAO,OAAO,IAAI,MAAM,OAAO;GAE3C,OAAO;EACT;EAGA,IAAI,SAAS,OAAO,SAAS,QAAQ,SAAS,QAAQ,IAAI,QAAA,KAA2B,OAAO;EAC5F;CACF;CACA,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../../src/wiki/route.ts","../../src/wiki/render.ts"],"sourcesContent":["// Pure helpers for reading, building, and validating wiki route\n// params. Kept free of Vue / vue-router imports so it can be shared\n// across hosts (MulmoClaude + MulmoTerminal) from\n// `@mulmoclaude/core/wiki`:\n//\n// - the host router guard (synchronous validation at navigation time)\n// - the wiki View (watcher + push helpers)\n// - the workspace-link click handler\n// - unit tests\n//\n// Mirrors the \"one file owns the URL ↔ domain mapping\" pattern so the\n// literals don't drift across the router definition, guards, views,\n// and tests.\n\nimport { isRecord } from \"@mulmoclaude/common\";\nimport { isSafeSlug } from \"./slug.js\";\n\n// URL segment used in `/wiki/:section/...`. Closed enum — the router\n// regex `(pages|log|lint-report)` rejects anything else.\nexport const WIKI_ROUTE_SECTION = {\n pages: \"pages\",\n log: \"log\",\n lintReport: \"lint-report\",\n graph: \"graph\",\n} as const;\n\nexport type WikiRouteSection = (typeof WIKI_ROUTE_SECTION)[keyof typeof WIKI_ROUTE_SECTION];\n\n// Internal action name sent to the server / shown in the View. Diverges\n// from the URL segment in one place only: `lint-report` (URL) vs\n// `lint_report` (action), because the server API still speaks the\n// underscore form.\nexport const WIKI_ACTION = {\n index: \"index\",\n page: \"page\",\n log: \"log\",\n lintReport: \"lint_report\",\n graph: \"graph\",\n save: \"save\",\n} as const;\n\nexport type WikiAction = (typeof WIKI_ACTION)[keyof typeof WIKI_ACTION];\n\n// Route-level representation. `pushWiki(target)` and\n// `readWikiRouteTarget(params)` both speak this so the watcher, the\n// button handlers, and the router guard agree on the same shape.\nexport type WikiTarget = { kind: \"index\" } | { kind: \"page\"; slug: string } | { kind: \"log\" } | { kind: \"lint_report\" } | { kind: \"graph\" };\n\n// Reject anything that could escape `data/wiki/pages/` or collide\n// with a different page. Vue Router decodes `%2F` back to `/` in\n// `route.params.slug`, so `/wiki/pages/..%2Fsecrets` lands here as\n// `slug === \"../secrets\"` — this check is the last line of defence\n// before the slug is passed to the server's page resolver. Non-\n// ASCII characters (e.g. Japanese page titles) are allowed; only\n// separators and the literal `.` / `..` are blocked.\n//\n// Delegates the actual rule to `isSafeSlug` in\n// `src/lib/wiki-page/slug.ts` (imported at the top of this file)\n// so server and frontend share one implementation (#1297). The\n// wrapper adds the type-guard / non-string-input handling the\n// router needs.\nexport function isSafeWikiSlug(value: unknown): value is string {\n return typeof value === \"string\" && isSafeSlug(value);\n}\n\n// Read `route.params` from the wiki route and normalise to a\n// `WikiTarget`. Returns `null` when the params describe an invalid\n// state (unknown section, missing slug for a page view, unsafe slug)\n// so the caller can decide what to do — the router guard redirects to\n// `/wiki`, the view watcher treats it as \"render the index\".\nexport function readWikiRouteTarget(params: unknown): WikiTarget | null {\n if (!isRecord(params)) return null;\n const { section, slug } = params;\n\n if (section === undefined || section === \"\") return { kind: \"index\" };\n\n if (section === WIKI_ROUTE_SECTION.pages) {\n if (!isSafeWikiSlug(slug)) return null;\n return { kind: \"page\", slug };\n }\n if (section === WIKI_ROUTE_SECTION.log) return { kind: \"log\" };\n if (section === WIKI_ROUTE_SECTION.lintReport) return { kind: \"lint_report\" };\n if (section === WIKI_ROUTE_SECTION.graph) return { kind: \"graph\" };\n\n return null;\n}\n\n// Inverse of `readWikiRouteTarget`: given a target, produce the\n// `{ section, slug }` params object that `router.push({ name: \"wiki\",\n// params })` needs.\n//\n// Optional route params are NOT cleared by named-route navigation\n// unless explicitly set — returning `{}` for `kind: \"index\"` would\n// leak the previous `section`/`slug` into the URL when navigating\n// from `/wiki/pages/foo` back to the index, leaving the user on\n// `/wiki/pages/foo` with the View believing it's the index. Pass\n// empty strings so the router writes out `/wiki` cleanly. The\n// readWikiRouteTarget() branch for `section === \"\"` already\n// normalises those back to `{ kind: \"index\" }`.\nexport function buildWikiRouteParams(target: WikiTarget): Record<string, string> {\n switch (target.kind) {\n case \"index\":\n return { section: \"\", slug: \"\" };\n case \"page\":\n return { section: WIKI_ROUTE_SECTION.pages, slug: target.slug };\n case \"log\":\n return { section: WIKI_ROUTE_SECTION.log, slug: \"\" };\n case \"lint_report\":\n return { section: WIKI_ROUTE_SECTION.lintReport, slug: \"\" };\n case \"graph\":\n return { section: WIKI_ROUTE_SECTION.graph, slug: \"\" };\n default: {\n const exhaustive: never = target;\n throw new Error(`unreachable WikiTarget kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n\n// Resolve a target to the action name the server expects. Centralises\n// the one place URL-shape and action-shape diverge (`lint-report` ↔\n// `lint_report`).\nexport function wikiActionFor(target: WikiTarget): WikiAction {\n switch (target.kind) {\n case \"index\":\n return WIKI_ACTION.index;\n case \"page\":\n return WIKI_ACTION.page;\n case \"log\":\n return WIKI_ACTION.log;\n case \"lint_report\":\n return WIKI_ACTION.lintReport;\n case \"graph\":\n return WIKI_ACTION.graph;\n default: {\n const exhaustive: never = target;\n throw new Error(`unreachable WikiTarget kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n","// Pure `[[wiki-link]]` → HTML renderer, shared across hosts via\n// `@mulmoclaude/core/wiki`. Extracted from the MulmoClaude host's\n// `src/plugins/wiki/helpers.ts` so MulmoTerminal can render the same\n// internal-link markup without forking the walker.\n//\n// String-only, no `marked` / DOM / Node deps — the host pipeline\n// (image-ref rewrite, marked.parse, task-interactive) wraps this.\n\nimport { escapeHtml } from \"@mulmoclaude/common\";\nimport { parseWikiLink, WIKI_LINK_MAX_LEN } from \"./link.js\";\n\n// `escapeHtml` moved down to the zero-dep leaf `@mulmoclaude/common` (#2483) so\n// `@mulmoclaude/markdown-utils` — a leaf THIS package depends on, hence unable\n// to import back up — can share it. Re-exported here because the wiki\n// consumers (collection-plugin's graph view, the host's wiki embeds and\n// spreadsheet view) already import it from `@mulmoclaude/core/wiki`.\nexport { escapeHtml };\n\n/**\n * Replace every `[[page name]]` occurrence in `content` with a\n * `<span class=\"wiki-link\" data-page=\"…\">…</span>` element. The\n * page name may not contain `[` or `]`; an opening `[[` that is\n * not followed later by `]]` (with no bare `[` or `]` in between)\n * is left untouched so malformed text renders as-is — matching the\n * previous regex's non-match behaviour.\n *\n * `[[target|display]]` is split via the shared `parseWikiLink`\n * helper so `data-page` carries only the target slug while the\n * visible text shows the display half (#1297). Both halves are\n * HTML-escaped before interpolation — `parseWikiLink` runs BEFORE\n * the host's `marked.parse`, so escaping has to happen here.\n */\nexport function renderWikiLinks(content: string): string {\n const out: string[] = [];\n let i = 0;\n while (i < content.length) {\n if (content[i] === \"[\" && content[i + 1] === \"[\") {\n const closeStart = findNextCloseBrackets(content, i + 2);\n if (closeStart !== -1) {\n const inner = content.slice(i + 2, closeStart);\n const { target, display } = parseWikiLink(inner);\n out.push(`<span class=\"wiki-link\" data-page=\"${escapeHtml(target)}\">${escapeHtml(display)}</span>`);\n i = closeStart + 2;\n continue;\n }\n }\n out.push(content.charAt(i));\n i++;\n }\n return out.join(\"\");\n}\n\n/**\n * Starting at `from`, scan forward for a `]]` sequence. Returns\n * the index of the first `]` of that pair, or -1 if the span isn't\n * a valid wiki-link body — mirroring `WIKI_LINK_PATTERN` exactly so\n * the renderer can't accept a link the graph / backlinks / lint\n * would reject. The pattern's body class is `[^\\][\\r\\n]`, so it\n * rejects `[` as well as `]`. Bails (-1) on:\n * - a bare `]` (regex `[^\\]]`),\n * - a `[` inside the body (regex `[^\\[]` — a nested `[[` opener\n * never belongs in a page name, and the pattern would not match),\n * - a `\\r` or `\\n` (regex `[^\\r\\n]` — a newline-bearing link must\n * not render clickable, and its slug must not reach the URL),\n * - a body longer than WIKI_LINK_MAX_LEN (regex `{1,200}`),\n * - a zero-length body (regex `{1,}` minimum),\n * - reaching end-of-input with no `]]`.\n */\nfunction findNextCloseBrackets(str: string, from: number): number {\n let j = from;\n while (j < str.length) {\n const char = str[j];\n if (char === \"]\") {\n if (str[j + 1] === \"]\" && j > from) return j;\n // Bare `]` inside the page-name span — regex would not match.\n return -1;\n }\n // A `[`, a newline, or a span past the cap: none match the pattern's\n // `[^\\][\\r\\n]{1,200}` body, so bail and let the caller emit `[[` literally.\n if (char === \"[\" || char === \"\\n\" || char === \"\\r\" || j - from >= WIKI_LINK_MAX_LEN) return -1;\n j++;\n }\n return -1;\n}\n"],"mappings":";;;;;AAmBA,IAAa,qBAAqB;CAChC,OAAO;CACP,KAAK;CACL,YAAY;CACZ,OAAO;AACT;AAQA,IAAa,cAAc;CACzB,OAAO;CACP,MAAM;CACN,KAAK;CACL,YAAY;CACZ,OAAO;CACP,MAAM;AACR;AAsBA,SAAgB,eAAe,OAAiC;CAC9D,OAAO,OAAO,UAAU,YAAY,aAAA,WAAW,KAAK;AACtD;AAOA,SAAgB,oBAAoB,QAAoC;CACtE,IAAI,CAAC,aAAA,SAAS,MAAM,GAAG,OAAO;CAC9B,MAAM,EAAE,SAAS,SAAS;CAE1B,IAAI,YAAY,KAAA,KAAa,YAAY,IAAI,OAAO,EAAE,MAAM,QAAQ;CAEpE,IAAI,YAAY,mBAAmB,OAAO;EACxC,IAAI,CAAC,eAAe,IAAI,GAAG,OAAO;EAClC,OAAO;GAAE,MAAM;GAAQ;EAAK;CAC9B;CACA,IAAI,YAAY,mBAAmB,KAAK,OAAO,EAAE,MAAM,MAAM;CAC7D,IAAI,YAAY,mBAAmB,YAAY,OAAO,EAAE,MAAM,cAAc;CAC5E,IAAI,YAAY,mBAAmB,OAAO,OAAO,EAAE,MAAM,QAAQ;CAEjE,OAAO;AACT;AAcA,SAAgB,qBAAqB,QAA4C;CAC/E,QAAQ,OAAO,MAAf;EACE,KAAK,SACH,OAAO;GAAE,SAAS;GAAI,MAAM;EAAG;EACjC,KAAK,QACH,OAAO;GAAE,SAAS,mBAAmB;GAAO,MAAM,OAAO;EAAK;EAChE,KAAK,OACH,OAAO;GAAE,SAAS,mBAAmB;GAAK,MAAM;EAAG;EACrD,KAAK,eACH,OAAO;GAAE,SAAS,mBAAmB;GAAY,MAAM;EAAG;EAC5D,KAAK,SACH,OAAO;GAAE,SAAS,mBAAmB;GAAO,MAAM;EAAG;EACvD,SAEE,MAAM,IAAI,MAAM,gCAAgC,KAAK,UAAU,MAAU,GAAG;CAEhF;AACF;AAKA,SAAgB,cAAc,QAAgC;CAC5D,QAAQ,OAAO,MAAf;EACE,KAAK,SACH,OAAO,YAAY;EACrB,KAAK,QACH,OAAO,YAAY;EACrB,KAAK,OACH,OAAO,YAAY;EACrB,KAAK,eACH,OAAO,YAAY;EACrB,KAAK,SACH,OAAO,YAAY;EACrB,SAEE,MAAM,IAAI,MAAM,gCAAgC,KAAK,UAAU,MAAU,GAAG;CAEhF;AACF;;;;;;;;;;;;;;;;;AC1GA,SAAgB,gBAAgB,SAAyB;CACvD,MAAM,MAAgB,CAAC;CACvB,IAAI,IAAI;CACR,OAAO,IAAI,QAAQ,QAAQ;EACzB,IAAI,QAAQ,OAAO,OAAO,QAAQ,IAAI,OAAO,KAAK;GAChD,MAAM,aAAa,sBAAsB,SAAS,IAAI,CAAC;GACvD,IAAI,eAAe,IAAI;IACrB,MAAM,QAAQ,QAAQ,MAAM,IAAI,GAAG,UAAU;IAC7C,MAAM,EAAE,QAAQ,YAAY,cAAA,cAAc,KAAK;IAC/C,IAAI,KAAK,sCAAsC,aAAA,WAAW,MAAM,EAAE,IAAI,aAAA,WAAW,OAAO,EAAE,QAAQ;IAClG,IAAI,aAAa;IACjB;GACF;EACF;EACA,IAAI,KAAK,QAAQ,OAAO,CAAC,CAAC;EAC1B;CACF;CACA,OAAO,IAAI,KAAK,EAAE;AACpB;;;;;;;;;;;;;;;;;AAkBA,SAAS,sBAAsB,KAAa,MAAsB;CAChE,IAAI,IAAI;CACR,OAAO,IAAI,IAAI,QAAQ;EACrB,MAAM,OAAO,IAAI;EACjB,IAAI,SAAS,KAAK;GAChB,IAAI,IAAI,IAAI,OAAO,OAAO,IAAI,MAAM,OAAO;GAE3C,OAAO;EACT;EAGA,IAAI,SAAS,OAAO,SAAS,QAAQ,SAAS,QAAQ,IAAI,QAAA,KAA2B,OAAO;EAC5F;CACF;CACA,OAAO;AACT"}
|
package/dist/wiki/index.js
CHANGED
|
@@ -93,7 +93,8 @@ function renderWikiLinks(content) {
|
|
|
93
93
|
if (content[i] === "[" && content[i + 1] === "[") {
|
|
94
94
|
const closeStart = findNextCloseBrackets(content, i + 2);
|
|
95
95
|
if (closeStart !== -1) {
|
|
96
|
-
const
|
|
96
|
+
const inner = content.slice(i + 2, closeStart);
|
|
97
|
+
const { target, display } = parseWikiLink(inner);
|
|
97
98
|
out.push(`<span class="wiki-link" data-page="${escapeHtml(target)}">${escapeHtml(display)}</span>`);
|
|
98
99
|
i = closeStart + 2;
|
|
99
100
|
continue;
|
package/dist/wiki/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/wiki/route.ts","../../src/wiki/render.ts"],"sourcesContent":["// Pure helpers for reading, building, and validating wiki route\n// params. Kept free of Vue / vue-router imports so it can be shared\n// across hosts (MulmoClaude + MulmoTerminal) from\n// `@mulmoclaude/core/wiki`:\n//\n// - the host router guard (synchronous validation at navigation time)\n// - the wiki View (watcher + push helpers)\n// - the workspace-link click handler\n// - unit tests\n//\n// Mirrors the \"one file owns the URL ↔ domain mapping\" pattern so the\n// literals don't drift across the router definition, guards, views,\n// and tests.\n\nimport { isRecord } from \"@mulmoclaude/common\";\nimport { isSafeSlug } from \"./slug.js\";\n\n// URL segment used in `/wiki/:section/...`. Closed enum — the router\n// regex `(pages|log|lint-report)` rejects anything else.\nexport const WIKI_ROUTE_SECTION = {\n pages: \"pages\",\n log: \"log\",\n lintReport: \"lint-report\",\n graph: \"graph\",\n} as const;\n\nexport type WikiRouteSection = (typeof WIKI_ROUTE_SECTION)[keyof typeof WIKI_ROUTE_SECTION];\n\n// Internal action name sent to the server / shown in the View. Diverges\n// from the URL segment in one place only: `lint-report` (URL) vs\n// `lint_report` (action), because the server API still speaks the\n// underscore form.\nexport const WIKI_ACTION = {\n index: \"index\",\n page: \"page\",\n log: \"log\",\n lintReport: \"lint_report\",\n graph: \"graph\",\n save: \"save\",\n} as const;\n\nexport type WikiAction = (typeof WIKI_ACTION)[keyof typeof WIKI_ACTION];\n\n// Route-level representation. `pushWiki(target)` and\n// `readWikiRouteTarget(params)` both speak this so the watcher, the\n// button handlers, and the router guard agree on the same shape.\nexport type WikiTarget = { kind: \"index\" } | { kind: \"page\"; slug: string } | { kind: \"log\" } | { kind: \"lint_report\" } | { kind: \"graph\" };\n\n// Reject anything that could escape `data/wiki/pages/` or collide\n// with a different page. Vue Router decodes `%2F` back to `/` in\n// `route.params.slug`, so `/wiki/pages/..%2Fsecrets` lands here as\n// `slug === \"../secrets\"` — this check is the last line of defence\n// before the slug is passed to the server's page resolver. Non-\n// ASCII characters (e.g. Japanese page titles) are allowed; only\n// separators and the literal `.` / `..` are blocked.\n//\n// Delegates the actual rule to `isSafeSlug` in\n// `src/lib/wiki-page/slug.ts` (imported at the top of this file)\n// so server and frontend share one implementation (#1297). The\n// wrapper adds the type-guard / non-string-input handling the\n// router needs.\nexport function isSafeWikiSlug(value: unknown): value is string {\n return typeof value === \"string\" && isSafeSlug(value);\n}\n\n// Read `route.params` from the wiki route and normalise to a\n// `WikiTarget`. Returns `null` when the params describe an invalid\n// state (unknown section, missing slug for a page view, unsafe slug)\n// so the caller can decide what to do — the router guard redirects to\n// `/wiki`, the view watcher treats it as \"render the index\".\nexport function readWikiRouteTarget(params: unknown): WikiTarget | null {\n if (!isRecord(params)) return null;\n const { section, slug } = params;\n\n if (section === undefined || section === \"\") return { kind: \"index\" };\n\n if (section === WIKI_ROUTE_SECTION.pages) {\n if (!isSafeWikiSlug(slug)) return null;\n return { kind: \"page\", slug };\n }\n if (section === WIKI_ROUTE_SECTION.log) return { kind: \"log\" };\n if (section === WIKI_ROUTE_SECTION.lintReport) return { kind: \"lint_report\" };\n if (section === WIKI_ROUTE_SECTION.graph) return { kind: \"graph\" };\n\n return null;\n}\n\n// Inverse of `readWikiRouteTarget`: given a target, produce the\n// `{ section, slug }` params object that `router.push({ name: \"wiki\",\n// params })` needs.\n//\n// Optional route params are NOT cleared by named-route navigation\n// unless explicitly set — returning `{}` for `kind: \"index\"` would\n// leak the previous `section`/`slug` into the URL when navigating\n// from `/wiki/pages/foo` back to the index, leaving the user on\n// `/wiki/pages/foo` with the View believing it's the index. Pass\n// empty strings so the router writes out `/wiki` cleanly. The\n// readWikiRouteTarget() branch for `section === \"\"` already\n// normalises those back to `{ kind: \"index\" }`.\nexport function buildWikiRouteParams(target: WikiTarget): Record<string, string> {\n switch (target.kind) {\n case \"index\":\n return { section: \"\", slug: \"\" };\n case \"page\":\n return { section: WIKI_ROUTE_SECTION.pages, slug: target.slug };\n case \"log\":\n return { section: WIKI_ROUTE_SECTION.log, slug: \"\" };\n case \"lint_report\":\n return { section: WIKI_ROUTE_SECTION.lintReport, slug: \"\" };\n case \"graph\":\n return { section: WIKI_ROUTE_SECTION.graph, slug: \"\" };\n default: {\n const exhaustive: never = target;\n throw new Error(`unreachable WikiTarget kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n\n// Resolve a target to the action name the server expects. Centralises\n// the one place URL-shape and action-shape diverge (`lint-report` ↔\n// `lint_report`).\nexport function wikiActionFor(target: WikiTarget): WikiAction {\n switch (target.kind) {\n case \"index\":\n return WIKI_ACTION.index;\n case \"page\":\n return WIKI_ACTION.page;\n case \"log\":\n return WIKI_ACTION.log;\n case \"lint_report\":\n return WIKI_ACTION.lintReport;\n case \"graph\":\n return WIKI_ACTION.graph;\n default: {\n const exhaustive: never = target;\n throw new Error(`unreachable WikiTarget kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n","// Pure `[[wiki-link]]` → HTML renderer, shared across hosts via\n// `@mulmoclaude/core/wiki`. Extracted from the MulmoClaude host's\n// `src/plugins/wiki/helpers.ts` so MulmoTerminal can render the same\n// internal-link markup without forking the walker.\n//\n// String-only, no `marked` / DOM / Node deps — the host pipeline\n// (image-ref rewrite, marked.parse, task-interactive) wraps this.\n\nimport { escapeHtml } from \"@mulmoclaude/common\";\nimport { parseWikiLink, WIKI_LINK_MAX_LEN } from \"./link.js\";\n\n// `escapeHtml` moved down to the zero-dep leaf `@mulmoclaude/common` (#2483) so\n// `@mulmoclaude/markdown-utils` — a leaf THIS package depends on, hence unable\n// to import back up — can share it. Re-exported here because the wiki\n// consumers (collection-plugin's graph view, the host's wiki embeds and\n// spreadsheet view) already import it from `@mulmoclaude/core/wiki`.\nexport { escapeHtml };\n\n/**\n * Replace every `[[page name]]` occurrence in `content` with a\n * `<span class=\"wiki-link\" data-page=\"…\">…</span>` element. The\n * page name may not contain `[` or `]`; an opening `[[` that is\n * not followed later by `]]` (with no bare `[` or `]` in between)\n * is left untouched so malformed text renders as-is — matching the\n * previous regex's non-match behaviour.\n *\n * `[[target|display]]` is split via the shared `parseWikiLink`\n * helper so `data-page` carries only the target slug while the\n * visible text shows the display half (#1297). Both halves are\n * HTML-escaped before interpolation — `parseWikiLink` runs BEFORE\n * the host's `marked.parse`, so escaping has to happen here.\n */\nexport function renderWikiLinks(content: string): string {\n const out: string[] = [];\n let i = 0;\n while (i < content.length) {\n if (content[i] === \"[\" && content[i + 1] === \"[\") {\n const closeStart = findNextCloseBrackets(content, i + 2);\n if (closeStart !== -1) {\n const inner = content.slice(i + 2, closeStart);\n const { target, display } = parseWikiLink(inner);\n out.push(`<span class=\"wiki-link\" data-page=\"${escapeHtml(target)}\">${escapeHtml(display)}</span>`);\n i = closeStart + 2;\n continue;\n }\n }\n out.push(content.charAt(i));\n i++;\n }\n return out.join(\"\");\n}\n\n/**\n * Starting at `from`, scan forward for a `]]` sequence. Returns\n * the index of the first `]` of that pair, or -1 if the span isn't\n * a valid wiki-link body — mirroring `WIKI_LINK_PATTERN` exactly so\n * the renderer can't accept a link the graph / backlinks / lint\n * would reject. The pattern's body class is `[^\\][\\r\\n]`, so it\n * rejects `[` as well as `]`. Bails (-1) on:\n * - a bare `]` (regex `[^\\]]`),\n * - a `[` inside the body (regex `[^\\[]` — a nested `[[` opener\n * never belongs in a page name, and the pattern would not match),\n * - a `\\r` or `\\n` (regex `[^\\r\\n]` — a newline-bearing link must\n * not render clickable, and its slug must not reach the URL),\n * - a body longer than WIKI_LINK_MAX_LEN (regex `{1,200}`),\n * - a zero-length body (regex `{1,}` minimum),\n * - reaching end-of-input with no `]]`.\n */\nfunction findNextCloseBrackets(str: string, from: number): number {\n let j = from;\n while (j < str.length) {\n const char = str[j];\n if (char === \"]\") {\n if (str[j + 1] === \"]\" && j > from) return j;\n // Bare `]` inside the page-name span — regex would not match.\n return -1;\n }\n // A `[`, a newline, or a span past the cap: none match the pattern's\n // `[^\\][\\r\\n]{1,200}` body, so bail and let the caller emit `[[` literally.\n if (char === \"[\" || char === \"\\n\" || char === \"\\r\" || j - from >= WIKI_LINK_MAX_LEN) return -1;\n j++;\n }\n return -1;\n}\n"],"mappings":";;;;AAmBA,IAAa,qBAAqB;CAChC,OAAO;CACP,KAAK;CACL,YAAY;CACZ,OAAO;AACT;AAQA,IAAa,cAAc;CACzB,OAAO;CACP,MAAM;CACN,KAAK;CACL,YAAY;CACZ,OAAO;CACP,MAAM;AACR;AAsBA,SAAgB,eAAe,OAAiC;CAC9D,OAAO,OAAO,UAAU,YAAY,WAAW,KAAK;AACtD;AAOA,SAAgB,oBAAoB,QAAoC;CACtE,IAAI,CAAC,SAAS,MAAM,GAAG,OAAO;CAC9B,MAAM,EAAE,SAAS,SAAS;CAE1B,IAAI,YAAY,KAAA,KAAa,YAAY,IAAI,OAAO,EAAE,MAAM,QAAQ;CAEpE,IAAI,YAAY,mBAAmB,OAAO;EACxC,IAAI,CAAC,eAAe,IAAI,GAAG,OAAO;EAClC,OAAO;GAAE,MAAM;GAAQ;EAAK;CAC9B;CACA,IAAI,YAAY,mBAAmB,KAAK,OAAO,EAAE,MAAM,MAAM;CAC7D,IAAI,YAAY,mBAAmB,YAAY,OAAO,EAAE,MAAM,cAAc;CAC5E,IAAI,YAAY,mBAAmB,OAAO,OAAO,EAAE,MAAM,QAAQ;CAEjE,OAAO;AACT;AAcA,SAAgB,qBAAqB,QAA4C;CAC/E,QAAQ,OAAO,MAAf;EACE,KAAK,SACH,OAAO;GAAE,SAAS;GAAI,MAAM;EAAG;EACjC,KAAK,QACH,OAAO;GAAE,SAAS,mBAAmB;GAAO,MAAM,OAAO;EAAK;EAChE,KAAK,OACH,OAAO;GAAE,SAAS,mBAAmB;GAAK,MAAM;EAAG;EACrD,KAAK,eACH,OAAO;GAAE,SAAS,mBAAmB;GAAY,MAAM;EAAG;EAC5D,KAAK,SACH,OAAO;GAAE,SAAS,mBAAmB;GAAO,MAAM;EAAG;EACvD,SAEE,MAAM,IAAI,MAAM,gCAAgC,KAAK,UAAU,MAAU,GAAG;CAEhF;AACF;AAKA,SAAgB,cAAc,QAAgC;CAC5D,QAAQ,OAAO,MAAf;EACE,KAAK,SACH,OAAO,YAAY;EACrB,KAAK,QACH,OAAO,YAAY;EACrB,KAAK,OACH,OAAO,YAAY;EACrB,KAAK,eACH,OAAO,YAAY;EACrB,KAAK,SACH,OAAO,YAAY;EACrB,SAEE,MAAM,IAAI,MAAM,gCAAgC,KAAK,UAAU,MAAU,GAAG;CAEhF;AACF;;;;;;;;;;;;;;;;;AC1GA,SAAgB,gBAAgB,SAAyB;CACvD,MAAM,MAAgB,CAAC;CACvB,IAAI,IAAI;CACR,OAAO,IAAI,QAAQ,QAAQ;EACzB,IAAI,QAAQ,OAAO,OAAO,QAAQ,IAAI,OAAO,KAAK;GAChD,MAAM,aAAa,sBAAsB,SAAS,IAAI,CAAC;GACvD,IAAI,eAAe,IAAI;IAErB,MAAM,EAAE,QAAQ,YAAY,cADd,QAAQ,MAAM,IAAI,GAAG,UACO,CAAK;IAC/C,IAAI,KAAK,sCAAsC,WAAW,MAAM,EAAE,IAAI,WAAW,OAAO,EAAE,QAAQ;IAClG,IAAI,aAAa;IACjB;GACF;EACF;EACA,IAAI,KAAK,QAAQ,OAAO,CAAC,CAAC;EAC1B;CACF;CACA,OAAO,IAAI,KAAK,EAAE;AACpB;;;;;;;;;;;;;;;;;AAkBA,SAAS,sBAAsB,KAAa,MAAsB;CAChE,IAAI,IAAI;CACR,OAAO,IAAI,IAAI,QAAQ;EACrB,MAAM,OAAO,IAAI;EACjB,IAAI,SAAS,KAAK;GAChB,IAAI,IAAI,IAAI,OAAO,OAAO,IAAI,MAAM,OAAO;GAE3C,OAAO;EACT;EAGA,IAAI,SAAS,OAAO,SAAS,QAAQ,SAAS,QAAQ,IAAI,QAAA,KAA2B,OAAO;EAC5F;CACF;CACA,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/wiki/route.ts","../../src/wiki/render.ts"],"sourcesContent":["// Pure helpers for reading, building, and validating wiki route\n// params. Kept free of Vue / vue-router imports so it can be shared\n// across hosts (MulmoClaude + MulmoTerminal) from\n// `@mulmoclaude/core/wiki`:\n//\n// - the host router guard (synchronous validation at navigation time)\n// - the wiki View (watcher + push helpers)\n// - the workspace-link click handler\n// - unit tests\n//\n// Mirrors the \"one file owns the URL ↔ domain mapping\" pattern so the\n// literals don't drift across the router definition, guards, views,\n// and tests.\n\nimport { isRecord } from \"@mulmoclaude/common\";\nimport { isSafeSlug } from \"./slug.js\";\n\n// URL segment used in `/wiki/:section/...`. Closed enum — the router\n// regex `(pages|log|lint-report)` rejects anything else.\nexport const WIKI_ROUTE_SECTION = {\n pages: \"pages\",\n log: \"log\",\n lintReport: \"lint-report\",\n graph: \"graph\",\n} as const;\n\nexport type WikiRouteSection = (typeof WIKI_ROUTE_SECTION)[keyof typeof WIKI_ROUTE_SECTION];\n\n// Internal action name sent to the server / shown in the View. Diverges\n// from the URL segment in one place only: `lint-report` (URL) vs\n// `lint_report` (action), because the server API still speaks the\n// underscore form.\nexport const WIKI_ACTION = {\n index: \"index\",\n page: \"page\",\n log: \"log\",\n lintReport: \"lint_report\",\n graph: \"graph\",\n save: \"save\",\n} as const;\n\nexport type WikiAction = (typeof WIKI_ACTION)[keyof typeof WIKI_ACTION];\n\n// Route-level representation. `pushWiki(target)` and\n// `readWikiRouteTarget(params)` both speak this so the watcher, the\n// button handlers, and the router guard agree on the same shape.\nexport type WikiTarget = { kind: \"index\" } | { kind: \"page\"; slug: string } | { kind: \"log\" } | { kind: \"lint_report\" } | { kind: \"graph\" };\n\n// Reject anything that could escape `data/wiki/pages/` or collide\n// with a different page. Vue Router decodes `%2F` back to `/` in\n// `route.params.slug`, so `/wiki/pages/..%2Fsecrets` lands here as\n// `slug === \"../secrets\"` — this check is the last line of defence\n// before the slug is passed to the server's page resolver. Non-\n// ASCII characters (e.g. Japanese page titles) are allowed; only\n// separators and the literal `.` / `..` are blocked.\n//\n// Delegates the actual rule to `isSafeSlug` in\n// `src/lib/wiki-page/slug.ts` (imported at the top of this file)\n// so server and frontend share one implementation (#1297). The\n// wrapper adds the type-guard / non-string-input handling the\n// router needs.\nexport function isSafeWikiSlug(value: unknown): value is string {\n return typeof value === \"string\" && isSafeSlug(value);\n}\n\n// Read `route.params` from the wiki route and normalise to a\n// `WikiTarget`. Returns `null` when the params describe an invalid\n// state (unknown section, missing slug for a page view, unsafe slug)\n// so the caller can decide what to do — the router guard redirects to\n// `/wiki`, the view watcher treats it as \"render the index\".\nexport function readWikiRouteTarget(params: unknown): WikiTarget | null {\n if (!isRecord(params)) return null;\n const { section, slug } = params;\n\n if (section === undefined || section === \"\") return { kind: \"index\" };\n\n if (section === WIKI_ROUTE_SECTION.pages) {\n if (!isSafeWikiSlug(slug)) return null;\n return { kind: \"page\", slug };\n }\n if (section === WIKI_ROUTE_SECTION.log) return { kind: \"log\" };\n if (section === WIKI_ROUTE_SECTION.lintReport) return { kind: \"lint_report\" };\n if (section === WIKI_ROUTE_SECTION.graph) return { kind: \"graph\" };\n\n return null;\n}\n\n// Inverse of `readWikiRouteTarget`: given a target, produce the\n// `{ section, slug }` params object that `router.push({ name: \"wiki\",\n// params })` needs.\n//\n// Optional route params are NOT cleared by named-route navigation\n// unless explicitly set — returning `{}` for `kind: \"index\"` would\n// leak the previous `section`/`slug` into the URL when navigating\n// from `/wiki/pages/foo` back to the index, leaving the user on\n// `/wiki/pages/foo` with the View believing it's the index. Pass\n// empty strings so the router writes out `/wiki` cleanly. The\n// readWikiRouteTarget() branch for `section === \"\"` already\n// normalises those back to `{ kind: \"index\" }`.\nexport function buildWikiRouteParams(target: WikiTarget): Record<string, string> {\n switch (target.kind) {\n case \"index\":\n return { section: \"\", slug: \"\" };\n case \"page\":\n return { section: WIKI_ROUTE_SECTION.pages, slug: target.slug };\n case \"log\":\n return { section: WIKI_ROUTE_SECTION.log, slug: \"\" };\n case \"lint_report\":\n return { section: WIKI_ROUTE_SECTION.lintReport, slug: \"\" };\n case \"graph\":\n return { section: WIKI_ROUTE_SECTION.graph, slug: \"\" };\n default: {\n const exhaustive: never = target;\n throw new Error(`unreachable WikiTarget kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n\n// Resolve a target to the action name the server expects. Centralises\n// the one place URL-shape and action-shape diverge (`lint-report` ↔\n// `lint_report`).\nexport function wikiActionFor(target: WikiTarget): WikiAction {\n switch (target.kind) {\n case \"index\":\n return WIKI_ACTION.index;\n case \"page\":\n return WIKI_ACTION.page;\n case \"log\":\n return WIKI_ACTION.log;\n case \"lint_report\":\n return WIKI_ACTION.lintReport;\n case \"graph\":\n return WIKI_ACTION.graph;\n default: {\n const exhaustive: never = target;\n throw new Error(`unreachable WikiTarget kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n","// Pure `[[wiki-link]]` → HTML renderer, shared across hosts via\n// `@mulmoclaude/core/wiki`. Extracted from the MulmoClaude host's\n// `src/plugins/wiki/helpers.ts` so MulmoTerminal can render the same\n// internal-link markup without forking the walker.\n//\n// String-only, no `marked` / DOM / Node deps — the host pipeline\n// (image-ref rewrite, marked.parse, task-interactive) wraps this.\n\nimport { escapeHtml } from \"@mulmoclaude/common\";\nimport { parseWikiLink, WIKI_LINK_MAX_LEN } from \"./link.js\";\n\n// `escapeHtml` moved down to the zero-dep leaf `@mulmoclaude/common` (#2483) so\n// `@mulmoclaude/markdown-utils` — a leaf THIS package depends on, hence unable\n// to import back up — can share it. Re-exported here because the wiki\n// consumers (collection-plugin's graph view, the host's wiki embeds and\n// spreadsheet view) already import it from `@mulmoclaude/core/wiki`.\nexport { escapeHtml };\n\n/**\n * Replace every `[[page name]]` occurrence in `content` with a\n * `<span class=\"wiki-link\" data-page=\"…\">…</span>` element. The\n * page name may not contain `[` or `]`; an opening `[[` that is\n * not followed later by `]]` (with no bare `[` or `]` in between)\n * is left untouched so malformed text renders as-is — matching the\n * previous regex's non-match behaviour.\n *\n * `[[target|display]]` is split via the shared `parseWikiLink`\n * helper so `data-page` carries only the target slug while the\n * visible text shows the display half (#1297). Both halves are\n * HTML-escaped before interpolation — `parseWikiLink` runs BEFORE\n * the host's `marked.parse`, so escaping has to happen here.\n */\nexport function renderWikiLinks(content: string): string {\n const out: string[] = [];\n let i = 0;\n while (i < content.length) {\n if (content[i] === \"[\" && content[i + 1] === \"[\") {\n const closeStart = findNextCloseBrackets(content, i + 2);\n if (closeStart !== -1) {\n const inner = content.slice(i + 2, closeStart);\n const { target, display } = parseWikiLink(inner);\n out.push(`<span class=\"wiki-link\" data-page=\"${escapeHtml(target)}\">${escapeHtml(display)}</span>`);\n i = closeStart + 2;\n continue;\n }\n }\n out.push(content.charAt(i));\n i++;\n }\n return out.join(\"\");\n}\n\n/**\n * Starting at `from`, scan forward for a `]]` sequence. Returns\n * the index of the first `]` of that pair, or -1 if the span isn't\n * a valid wiki-link body — mirroring `WIKI_LINK_PATTERN` exactly so\n * the renderer can't accept a link the graph / backlinks / lint\n * would reject. The pattern's body class is `[^\\][\\r\\n]`, so it\n * rejects `[` as well as `]`. Bails (-1) on:\n * - a bare `]` (regex `[^\\]]`),\n * - a `[` inside the body (regex `[^\\[]` — a nested `[[` opener\n * never belongs in a page name, and the pattern would not match),\n * - a `\\r` or `\\n` (regex `[^\\r\\n]` — a newline-bearing link must\n * not render clickable, and its slug must not reach the URL),\n * - a body longer than WIKI_LINK_MAX_LEN (regex `{1,200}`),\n * - a zero-length body (regex `{1,}` minimum),\n * - reaching end-of-input with no `]]`.\n */\nfunction findNextCloseBrackets(str: string, from: number): number {\n let j = from;\n while (j < str.length) {\n const char = str[j];\n if (char === \"]\") {\n if (str[j + 1] === \"]\" && j > from) return j;\n // Bare `]` inside the page-name span — regex would not match.\n return -1;\n }\n // A `[`, a newline, or a span past the cap: none match the pattern's\n // `[^\\][\\r\\n]{1,200}` body, so bail and let the caller emit `[[` literally.\n if (char === \"[\" || char === \"\\n\" || char === \"\\r\" || j - from >= WIKI_LINK_MAX_LEN) return -1;\n j++;\n }\n return -1;\n}\n"],"mappings":";;;;AAmBA,IAAa,qBAAqB;CAChC,OAAO;CACP,KAAK;CACL,YAAY;CACZ,OAAO;AACT;AAQA,IAAa,cAAc;CACzB,OAAO;CACP,MAAM;CACN,KAAK;CACL,YAAY;CACZ,OAAO;CACP,MAAM;AACR;AAsBA,SAAgB,eAAe,OAAiC;CAC9D,OAAO,OAAO,UAAU,YAAY,WAAW,KAAK;AACtD;AAOA,SAAgB,oBAAoB,QAAoC;CACtE,IAAI,CAAC,SAAS,MAAM,GAAG,OAAO;CAC9B,MAAM,EAAE,SAAS,SAAS;CAE1B,IAAI,YAAY,KAAA,KAAa,YAAY,IAAI,OAAO,EAAE,MAAM,QAAQ;CAEpE,IAAI,YAAY,mBAAmB,OAAO;EACxC,IAAI,CAAC,eAAe,IAAI,GAAG,OAAO;EAClC,OAAO;GAAE,MAAM;GAAQ;EAAK;CAC9B;CACA,IAAI,YAAY,mBAAmB,KAAK,OAAO,EAAE,MAAM,MAAM;CAC7D,IAAI,YAAY,mBAAmB,YAAY,OAAO,EAAE,MAAM,cAAc;CAC5E,IAAI,YAAY,mBAAmB,OAAO,OAAO,EAAE,MAAM,QAAQ;CAEjE,OAAO;AACT;AAcA,SAAgB,qBAAqB,QAA4C;CAC/E,QAAQ,OAAO,MAAf;EACE,KAAK,SACH,OAAO;GAAE,SAAS;GAAI,MAAM;EAAG;EACjC,KAAK,QACH,OAAO;GAAE,SAAS,mBAAmB;GAAO,MAAM,OAAO;EAAK;EAChE,KAAK,OACH,OAAO;GAAE,SAAS,mBAAmB;GAAK,MAAM;EAAG;EACrD,KAAK,eACH,OAAO;GAAE,SAAS,mBAAmB;GAAY,MAAM;EAAG;EAC5D,KAAK,SACH,OAAO;GAAE,SAAS,mBAAmB;GAAO,MAAM;EAAG;EACvD,SAEE,MAAM,IAAI,MAAM,gCAAgC,KAAK,UAAU,MAAU,GAAG;CAEhF;AACF;AAKA,SAAgB,cAAc,QAAgC;CAC5D,QAAQ,OAAO,MAAf;EACE,KAAK,SACH,OAAO,YAAY;EACrB,KAAK,QACH,OAAO,YAAY;EACrB,KAAK,OACH,OAAO,YAAY;EACrB,KAAK,eACH,OAAO,YAAY;EACrB,KAAK,SACH,OAAO,YAAY;EACrB,SAEE,MAAM,IAAI,MAAM,gCAAgC,KAAK,UAAU,MAAU,GAAG;CAEhF;AACF;;;;;;;;;;;;;;;;;AC1GA,SAAgB,gBAAgB,SAAyB;CACvD,MAAM,MAAgB,CAAC;CACvB,IAAI,IAAI;CACR,OAAO,IAAI,QAAQ,QAAQ;EACzB,IAAI,QAAQ,OAAO,OAAO,QAAQ,IAAI,OAAO,KAAK;GAChD,MAAM,aAAa,sBAAsB,SAAS,IAAI,CAAC;GACvD,IAAI,eAAe,IAAI;IACrB,MAAM,QAAQ,QAAQ,MAAM,IAAI,GAAG,UAAU;IAC7C,MAAM,EAAE,QAAQ,YAAY,cAAc,KAAK;IAC/C,IAAI,KAAK,sCAAsC,WAAW,MAAM,EAAE,IAAI,WAAW,OAAO,EAAE,QAAQ;IAClG,IAAI,aAAa;IACjB;GACF;EACF;EACA,IAAI,KAAK,QAAQ,OAAO,CAAC,CAAC;EAC1B;CACF;CACA,OAAO,IAAI,KAAK,EAAE;AACpB;;;;;;;;;;;;;;;;;AAkBA,SAAS,sBAAsB,KAAa,MAAsB;CAChE,IAAI,IAAI;CACR,OAAO,IAAI,IAAI,QAAQ;EACrB,MAAM,OAAO,IAAI;EACjB,IAAI,SAAS,KAAK;GAChB,IAAI,IAAI,IAAI,OAAO,OAAO,IAAI,MAAM,OAAO;GAE3C,OAAO;EACT;EAGA,IAAI,SAAS,OAAO,SAAS,QAAQ,SAAS,QAAQ,IAAI,QAAA,KAA2B,OAAO;EAC5F;CACF;CACA,OAAO;AACT"}
|
package/dist/wiki/paths.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_rolldown_runtime = require("../rolldown-runtime-
|
|
2
|
+
const require_rolldown_runtime = require("../rolldown-runtime-VH7oDXx4.cjs");
|
|
3
3
|
const require_slug = require("../slug-DbwORN9z.cjs");
|
|
4
4
|
let node_path = require("node:path");
|
|
5
5
|
node_path = require_rolldown_runtime.__toESM(node_path, 1);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_rolldown_runtime = require("../../rolldown-runtime-
|
|
2
|
+
const require_rolldown_runtime = require("../../rolldown-runtime-VH7oDXx4.cjs");
|
|
3
3
|
const require_graph = require("../../graph-BZJxiv1E.cjs");
|
|
4
4
|
const require_slug = require("../../slug-DbwORN9z.cjs");
|
|
5
5
|
const require_wiki_paths = require("../paths.cjs");
|
|
@@ -171,10 +171,12 @@ async function readPageBody(pagesDir, fileName) {
|
|
|
171
171
|
async function loadWikiGraph(workspace) {
|
|
172
172
|
const { pagesDir, indexFile } = require_wiki_paths.wikiDirs(workspace);
|
|
173
173
|
const { slugs } = await getPageIndex(pagesDir);
|
|
174
|
-
|
|
174
|
+
const pages = await Promise.all([...slugs.entries()].map(async ([slug, fileName]) => ({
|
|
175
175
|
slug,
|
|
176
176
|
content: await readPageBody(pagesDir, fileName)
|
|
177
|
-
})))
|
|
177
|
+
})));
|
|
178
|
+
const indexEntries = require_graph.parseIndexEntries(readFileOrEmpty(indexFile));
|
|
179
|
+
return require_graph.buildWikiGraph(pages, indexEntries);
|
|
178
180
|
}
|
|
179
181
|
/** Run every lint rule over the on-disk wiki, returning issue strings. */
|
|
180
182
|
async function collectLintIssues(workspace) {
|
|
@@ -1 +1 @@
|
|
|
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 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.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 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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"}
|
|
@@ -168,10 +168,12 @@ async function readPageBody(pagesDir, fileName) {
|
|
|
168
168
|
async function loadWikiGraph(workspace) {
|
|
169
169
|
const { pagesDir, indexFile } = wikiDirs(workspace);
|
|
170
170
|
const { slugs } = await getPageIndex(pagesDir);
|
|
171
|
-
|
|
171
|
+
const pages = await Promise.all([...slugs.entries()].map(async ([slug, fileName]) => ({
|
|
172
172
|
slug,
|
|
173
173
|
content: await readPageBody(pagesDir, fileName)
|
|
174
|
-
})))
|
|
174
|
+
})));
|
|
175
|
+
const indexEntries = parseIndexEntries(readFileOrEmpty(indexFile));
|
|
176
|
+
return buildWikiGraph(pages, indexEntries);
|
|
175
177
|
}
|
|
176
178
|
/** Run every lint rule over the on-disk wiki, returning issue strings. */
|
|
177
179
|
async function collectLintIssues(workspace) {
|
|
@@ -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 { 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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"}
|