@mulmoclaude/core 0.2.16 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ //#region src/wiki/slug.ts
2
+ /** Reject slugs that would escape `data/wiki/pages/` once
3
+ * joined back into a path, or that are otherwise invalid as
4
+ * page filenames. The chokepoint must defend itself even when
5
+ * callers derive the slug from a trusted source — a typo or
6
+ * future caller mistake should fail loud, not silently write
7
+ * outside the wiki tree.
8
+ *
9
+ * The rule is intentionally narrow — separators / `..` / NUL /
10
+ * empty — so it only rejects unambiguous violations. Aesthetic
11
+ * concerns (e.g. dot-prefixed filenames) are out of scope: a
12
+ * pre-existing `data/wiki/pages/.foo.md` should remain writable
13
+ * through the chokepoint (codex review iter-2 #883). */
14
+ function isSafeSlug(slug) {
15
+ if (slug.length === 0) return false;
16
+ if (slug === "." || slug === "..") return false;
17
+ if (slug.includes("/") || slug.includes("\\")) return false;
18
+ if (slug.includes("\0")) return false;
19
+ return true;
20
+ }
21
+ /** Slug rules for `[[wiki link]]` text → slug derivation:
22
+ * lowercase, spaces collapsed to hyphens, every non-ASCII /
23
+ * non-alphanumeric / non-hyphen character stripped.
24
+ *
25
+ * Pure: no normalisation, no transliteration — this is the
26
+ * same shape the index parser, page resolver, and frontend
27
+ * renderer all need to agree on. Non-ASCII titles (e.g.
28
+ * Japanese) collapse to an empty string here; callers fall back
29
+ * to other strategies (title-match in the index, or the agent
30
+ * pre-resolving to a slug-form target via `[[slug|display]]`). */
31
+ function wikiSlugify(text) {
32
+ return text.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
33
+ }
34
+ //#endregion
35
+ export { wikiSlugify as n, isSafeSlug as t };
36
+
37
+ //# sourceMappingURL=slug-CdN-pQX1.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slug-CdN-pQX1.js","names":[],"sources":["../src/wiki/slug.ts"],"sourcesContent":["// Pure wiki-page slug helpers — string-only, no Node / browser\n// dependencies, importable from any bundle.\n//\n// Used by:\n// - server/workspace/wiki-pages/io.ts (the write chokepoint)\n// - server/workspace/hooks/handlers/wikiSnapshot.ts (the\n// PostToolUse handler that fires on Write/Edit of a wiki page)\n// - server/api/routes/wiki.ts (page resolver + lint)\n// - server/api/routes/wiki/history.ts (history slug guard)\n// - src/plugins/wiki/route.ts (router slug guard — replaces the\n// local `isSafeWikiSlug` duplicate that existed before the\n// pure-lib refactor)\n// - src/plugins/wiki/helpers.ts (renderer slugify for [[…]] links)\n//\n// Any helper that needs `node:path` lives in `./paths.ts` so that\n// importing this file from frontend code never pulls in Node\n// builtins.\n\n/** Reject slugs that would escape `data/wiki/pages/` once\n * joined back into a path, or that are otherwise invalid as\n * page filenames. The chokepoint must defend itself even when\n * callers derive the slug from a trusted source — a typo or\n * future caller mistake should fail loud, not silently write\n * outside the wiki tree.\n *\n * The rule is intentionally narrow — separators / `..` / NUL /\n * empty — so it only rejects unambiguous violations. Aesthetic\n * concerns (e.g. dot-prefixed filenames) are out of scope: a\n * pre-existing `data/wiki/pages/.foo.md` should remain writable\n * through the chokepoint (codex review iter-2 #883). */\nexport function isSafeSlug(slug: string): boolean {\n if (slug.length === 0) return false;\n if (slug === \".\" || slug === \"..\") return false;\n if (slug.includes(\"/\") || slug.includes(\"\\\\\")) return false;\n if (slug.includes(\"\\0\")) return false;\n return true;\n}\n\n/** Slug rules for `[[wiki link]]` text → slug derivation:\n * lowercase, spaces collapsed to hyphens, every non-ASCII /\n * non-alphanumeric / non-hyphen character stripped.\n *\n * Pure: no normalisation, no transliteration — this is the\n * same shape the index parser, page resolver, and frontend\n * renderer all need to agree on. Non-ASCII titles (e.g.\n * Japanese) collapse to an empty string here; callers fall back\n * to other strategies (title-match in the index, or the agent\n * pre-resolving to a slug-form target via `[[slug|display]]`). */\nexport function wikiSlugify(text: string): string {\n return text\n .toLowerCase()\n .replace(/\\s+/g, \"-\")\n .replace(/[^a-z0-9-]/g, \"\");\n}\n"],"mappings":";;;;;;;;;;;;;AA8BA,SAAgB,WAAW,MAAuB;CAChD,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,SAAS,OAAO,SAAS,MAAM,OAAO;CAC1C,IAAI,KAAK,SAAS,GAAG,KAAK,KAAK,SAAS,IAAI,GAAG,OAAO;CACtD,IAAI,KAAK,SAAS,IAAI,GAAG,OAAO;CAChC,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,YAAY,MAAsB;CAChD,OAAO,KACJ,YAAY,CAAC,CACb,QAAQ,QAAQ,GAAG,CAAC,CACpB,QAAQ,eAAe,EAAE;AAC9B"}
@@ -0,0 +1,48 @@
1
+ //#region src/wiki/slug.ts
2
+ /** Reject slugs that would escape `data/wiki/pages/` once
3
+ * joined back into a path, or that are otherwise invalid as
4
+ * page filenames. The chokepoint must defend itself even when
5
+ * callers derive the slug from a trusted source — a typo or
6
+ * future caller mistake should fail loud, not silently write
7
+ * outside the wiki tree.
8
+ *
9
+ * The rule is intentionally narrow — separators / `..` / NUL /
10
+ * empty — so it only rejects unambiguous violations. Aesthetic
11
+ * concerns (e.g. dot-prefixed filenames) are out of scope: a
12
+ * pre-existing `data/wiki/pages/.foo.md` should remain writable
13
+ * through the chokepoint (codex review iter-2 #883). */
14
+ function isSafeSlug(slug) {
15
+ if (slug.length === 0) return false;
16
+ if (slug === "." || slug === "..") return false;
17
+ if (slug.includes("/") || slug.includes("\\")) return false;
18
+ if (slug.includes("\0")) return false;
19
+ return true;
20
+ }
21
+ /** Slug rules for `[[wiki link]]` text → slug derivation:
22
+ * lowercase, spaces collapsed to hyphens, every non-ASCII /
23
+ * non-alphanumeric / non-hyphen character stripped.
24
+ *
25
+ * Pure: no normalisation, no transliteration — this is the
26
+ * same shape the index parser, page resolver, and frontend
27
+ * renderer all need to agree on. Non-ASCII titles (e.g.
28
+ * Japanese) collapse to an empty string here; callers fall back
29
+ * to other strategies (title-match in the index, or the agent
30
+ * pre-resolving to a slug-form target via `[[slug|display]]`). */
31
+ function wikiSlugify(text) {
32
+ return text.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
33
+ }
34
+ //#endregion
35
+ Object.defineProperty(exports, "isSafeSlug", {
36
+ enumerable: true,
37
+ get: function() {
38
+ return isSafeSlug;
39
+ }
40
+ });
41
+ Object.defineProperty(exports, "wikiSlugify", {
42
+ enumerable: true,
43
+ get: function() {
44
+ return wikiSlugify;
45
+ }
46
+ });
47
+
48
+ //# sourceMappingURL=slug-DbwORN9z.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slug-DbwORN9z.cjs","names":[],"sources":["../src/wiki/slug.ts"],"sourcesContent":["// Pure wiki-page slug helpers — string-only, no Node / browser\n// dependencies, importable from any bundle.\n//\n// Used by:\n// - server/workspace/wiki-pages/io.ts (the write chokepoint)\n// - server/workspace/hooks/handlers/wikiSnapshot.ts (the\n// PostToolUse handler that fires on Write/Edit of a wiki page)\n// - server/api/routes/wiki.ts (page resolver + lint)\n// - server/api/routes/wiki/history.ts (history slug guard)\n// - src/plugins/wiki/route.ts (router slug guard — replaces the\n// local `isSafeWikiSlug` duplicate that existed before the\n// pure-lib refactor)\n// - src/plugins/wiki/helpers.ts (renderer slugify for [[…]] links)\n//\n// Any helper that needs `node:path` lives in `./paths.ts` so that\n// importing this file from frontend code never pulls in Node\n// builtins.\n\n/** Reject slugs that would escape `data/wiki/pages/` once\n * joined back into a path, or that are otherwise invalid as\n * page filenames. The chokepoint must defend itself even when\n * callers derive the slug from a trusted source — a typo or\n * future caller mistake should fail loud, not silently write\n * outside the wiki tree.\n *\n * The rule is intentionally narrow — separators / `..` / NUL /\n * empty — so it only rejects unambiguous violations. Aesthetic\n * concerns (e.g. dot-prefixed filenames) are out of scope: a\n * pre-existing `data/wiki/pages/.foo.md` should remain writable\n * through the chokepoint (codex review iter-2 #883). */\nexport function isSafeSlug(slug: string): boolean {\n if (slug.length === 0) return false;\n if (slug === \".\" || slug === \"..\") return false;\n if (slug.includes(\"/\") || slug.includes(\"\\\\\")) return false;\n if (slug.includes(\"\\0\")) return false;\n return true;\n}\n\n/** Slug rules for `[[wiki link]]` text → slug derivation:\n * lowercase, spaces collapsed to hyphens, every non-ASCII /\n * non-alphanumeric / non-hyphen character stripped.\n *\n * Pure: no normalisation, no transliteration — this is the\n * same shape the index parser, page resolver, and frontend\n * renderer all need to agree on. Non-ASCII titles (e.g.\n * Japanese) collapse to an empty string here; callers fall back\n * to other strategies (title-match in the index, or the agent\n * pre-resolving to a slug-form target via `[[slug|display]]`). */\nexport function wikiSlugify(text: string): string {\n return text\n .toLowerCase()\n .replace(/\\s+/g, \"-\")\n .replace(/[^a-z0-9-]/g, \"\");\n}\n"],"mappings":";;;;;;;;;;;;;AA8BA,SAAgB,WAAW,MAAuB;CAChD,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,SAAS,OAAO,SAAS,MAAM,OAAO;CAC1C,IAAI,KAAK,SAAS,GAAG,KAAK,KAAK,SAAS,IAAI,GAAG,OAAO;CACtD,IAAI,KAAK,SAAS,IAAI,GAAG,OAAO;CAChC,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,YAAY,MAAsB;CAChD,OAAO,KACJ,YAAY,CAAC,CACb,QAAQ,QAAQ,GAAG,CAAC,CACpB,QAAQ,eAAe,EAAE;AAC9B"}
@@ -0,0 +1,33 @@
1
+ import { WikiPageEntry } from './index-parse.js';
2
+ export interface WikiGraphNode {
3
+ slug: string;
4
+ title: string;
5
+ }
6
+ export interface WikiGraphEdge {
7
+ from: string;
8
+ to: string;
9
+ }
10
+ export interface WikiGraph {
11
+ nodes: WikiGraphNode[];
12
+ edges: WikiGraphEdge[];
13
+ }
14
+ /** One page's raw body plus its canonical (file-derived) slug. */
15
+ export interface WikiPageContent {
16
+ slug: string;
17
+ content: string;
18
+ }
19
+ /** Resolve a raw `[[link]]` target to an existing page slug, or null.
20
+ * Mirrors the route resolver's strategy: slugify first, then fall
21
+ * back to matching an index entry title so non-ASCII targets like
22
+ * `[[さくらインターネット]]` resolve to their ASCII file slug. */
23
+ export declare function resolveLinkTarget(target: string, fileSlugs: ReadonlySet<string>, slugByTitle: ReadonlyMap<string, string>): string | null;
24
+ /** Resolved, deduped outgoing slugs for one page body. Self-links and
25
+ * links to non-existent pages are dropped (the latter are already a
26
+ * lint "broken link", not a graph edge). */
27
+ export declare function pageOutgoingSlugs(fromSlug: string, content: string, fileSlugs: ReadonlySet<string>, slugByTitle: ReadonlyMap<string, string>): string[];
28
+ /** Build the full page→page graph. Nodes are the existing page files
29
+ * (titled from index.md, falling back to the slug for un-indexed
30
+ * pages); edges are the resolved `[[links]]`, deduped per (from,to). */
31
+ export declare function buildWikiGraph(pages: readonly WikiPageContent[], entries: readonly WikiPageEntry[]): WikiGraph;
32
+ /** Pages that link TO `slug` (incoming edges), deduped, as nodes. */
33
+ export declare function incomingLinks(graph: WikiGraph, slug: string): WikiGraphNode[];
@@ -0,0 +1,39 @@
1
+ export interface WikiPageEntry {
2
+ title: string;
3
+ slug: string;
4
+ description: string;
5
+ tags: string[];
6
+ }
7
+ export declare const BULLET_LINK_PATTERN: RegExp;
8
+ export declare const BULLET_WIKI_LINK_PATTERN: RegExp;
9
+ /** Extract `#tag` tokens from a bullet description, returning the
10
+ * stripped description and a sorted, deduped, lowercased tag list.
11
+ * Only matches at word boundaries so mid-word `#` (e.g. anchor
12
+ * URLs) is left alone. */
13
+ export declare function extractHashTags(text: string): {
14
+ description: string;
15
+ tags: string[];
16
+ };
17
+ /** Split a table Tags cell — tolerates comma, whitespace, or `#`
18
+ * prefixes. Empty cell yields an empty list. */
19
+ export declare function parseTagsCell(cell: string): string[];
20
+ /** Map header cell names → column indices, case- and whitespace-
21
+ * tolerant. Used by the row parser to locate the Tags column (and
22
+ * any other named column) without assuming a fixed position, so
23
+ * older 3- and 4-column tables keep working. */
24
+ export declare function buildTableColumnMap(headerRow: string): Map<string, number>;
25
+ /** Extract the slug segment from a bullet link's href. Accepts the
26
+ * canonical `pages/<slug>.md`, a bare `<slug>.md`, or just `<slug>`
27
+ * — the three forms produced by different historical writers of
28
+ * index.md. Returns "" for hrefs that don't look like a wiki page
29
+ * reference (e.g. `https://example.com`) so the caller can fall
30
+ * back to title-based slugification. */
31
+ export declare function extractSlugFromBulletHref(rawHref: string): string;
32
+ /** Parse entries from index.md. Supports three formats:
33
+ * 1. Table: `| slug | Title | Summary | (Tags) |`
34
+ * 2. Bullet link: `- [Title](pages/slug.md) — description`
35
+ * 3. Wiki link: `- [[Title]] — description`
36
+ *
37
+ * Returns entries in source order. Any unrecognised line is
38
+ * silently skipped — index.md is freeform markdown otherwise. */
39
+ export declare function parseIndexEntries(content: string): WikiPageEntry[];
@@ -0,0 +1,493 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_slug = require("../slug-DbwORN9z.cjs");
3
+ //#region src/wiki/link.ts
4
+ /** Inline `[[...]]` link pattern. The capture group is the raw
5
+ * text between the brackets — the caller is expected to feed it
6
+ * through `parseWikiLink` to split off any `|display` suffix.
7
+ *
8
+ * Body length is capped at 200 chars to keep the regex linear:
9
+ * catastrophic backtracking isn't possible (no nested
10
+ * alternation), but a malicious page with thousands of
11
+ * unmatched `[[` could still pin the CPU. 200 is well above the
12
+ * longest legitimate title we've seen and matches the cap the
13
+ * server-side `WIKI_LINK_PATTERN` constant has carried since the
14
+ * original implementation in #951. */
15
+ var WIKI_LINK_PATTERN = /\[\[([^\][\r\n]{1,200})\]\]/g;
16
+ /** Split the raw text inside `[[...]]` into target + display.
17
+ *
18
+ * - `[[foo|Bar Baz]]` → `{ target: "foo", display: "Bar Baz" }`
19
+ * - `[[foo]]` → `{ target: "foo", display: "foo" }`
20
+ * - `[[|empty target]]` → `{ target: "", display: "empty target" }`
21
+ * (caller decides whether an empty target is meaningful;
22
+ * typically lint flags it, renderer shows the display text raw)
23
+ * - `[[foo|]]` → `{ target: "foo", display: "" }`
24
+ * (same — caller decides)
25
+ *
26
+ * Whitespace around the pipe is preserved on the display side
27
+ * (so the user can write `[[foo| ある記事 ]]` and get the
28
+ * spacing intentionally) but TRIMMED on the target side, since
29
+ * a slug-comparable target with leading/trailing whitespace is
30
+ * always a typo.
31
+ *
32
+ * Only the FIRST pipe splits. `[[a|b|c]]` becomes
33
+ * `{ target: "a", display: "b|c" }` so display strings can
34
+ * legitimately contain a literal `|`. */
35
+ function parseWikiLink(inner) {
36
+ const pipeIdx = inner.indexOf("|");
37
+ if (pipeIdx === -1) return {
38
+ target: inner,
39
+ display: inner
40
+ };
41
+ return {
42
+ target: inner.slice(0, pipeIdx).trim(),
43
+ display: inner.slice(pipeIdx + 1)
44
+ };
45
+ }
46
+ //#endregion
47
+ //#region src/wiki/index-parse.ts
48
+ var BULLET_LINK_PATTERN = /^[-*]\s+\[([^\]]+)\]\(([^)]*)\)(?:\s*[—–-]\s*(.*))?/;
49
+ var BULLET_WIKI_LINK_PATTERN = /^[-*]\s+\[\[([^\]]+)\]\](?:\s*[—–-]\s*(.*))?/;
50
+ var TABLE_SEPARATOR_PATTERN = /^\|[\s|:-]+\|$/;
51
+ var HASHTAG_PATTERN = /(?:^|\s)#([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu;
52
+ /** Extract `#tag` tokens from a bullet description, returning the
53
+ * stripped description and a sorted, deduped, lowercased tag list.
54
+ * Only matches at word boundaries so mid-word `#` (e.g. anchor
55
+ * URLs) is left alone. */
56
+ function extractHashTags(text) {
57
+ const tags = [];
58
+ HASHTAG_PATTERN.lastIndex = 0;
59
+ let match;
60
+ while ((match = HASHTAG_PATTERN.exec(text)) !== null) tags.push(match[1].toLowerCase());
61
+ return {
62
+ description: text.replace(HASHTAG_PATTERN, "").replace(/\s+/g, " ").trim(),
63
+ tags: [...new Set(tags)].sort()
64
+ };
65
+ }
66
+ /** Split a table Tags cell — tolerates comma, whitespace, or `#`
67
+ * prefixes. Empty cell yields an empty list. */
68
+ function parseTagsCell(cell) {
69
+ const tokens = cell.split(/[,\s]+/).map((token) => token.trim().replace(/^#/, "").toLowerCase()).filter((token) => token.length > 0);
70
+ return [...new Set(tokens)].sort();
71
+ }
72
+ /** Map header cell names → column indices, case- and whitespace-
73
+ * tolerant. Used by the row parser to locate the Tags column (and
74
+ * any other named column) without assuming a fixed position, so
75
+ * older 3- and 4-column tables keep working. */
76
+ function buildTableColumnMap(headerRow) {
77
+ const cells = headerRow.split("|").slice(1, -1).map((cell) => cell.trim().replace(/^`|`$/g, "").toLowerCase());
78
+ const map = /* @__PURE__ */ new Map();
79
+ cells.forEach((cell, i) => {
80
+ if (cell) map.set(cell, i);
81
+ });
82
+ return map;
83
+ }
84
+ /** Resolve the per-column indices the row parser needs. Falls back
85
+ * to positional defaults (0/1/2) when the table has no header map.
86
+ * "summary" is the canonical column name; "description" is accepted
87
+ * as a legacy alias used by older fixtures. */
88
+ function resolveTableColumnIndices(columnMap) {
89
+ return {
90
+ slug: columnMap?.get("slug") ?? 0,
91
+ title: columnMap?.get("title") ?? 1,
92
+ summary: columnMap?.get("summary") ?? columnMap?.get("description") ?? 2,
93
+ tags: columnMap?.get("tags")
94
+ };
95
+ }
96
+ function parseTableRow(trimmed, columnMap) {
97
+ const cols = trimmed.split("|").slice(1, -1).map((column) => column.trim().replace(/^`|`$/g, ""));
98
+ if (cols.length < 2) return null;
99
+ const idx = resolveTableColumnIndices(columnMap);
100
+ const slug = cols[idx.slug] ?? "";
101
+ const title = cols[idx.title] || slug;
102
+ if (!slug || !title) return null;
103
+ return {
104
+ title,
105
+ slug,
106
+ description: cols[idx.summary] ?? "",
107
+ tags: idx.tags !== void 0 ? parseTagsCell(cols[idx.tags] ?? "") : []
108
+ };
109
+ }
110
+ /** Extract the slug segment from a bullet link's href. Accepts the
111
+ * canonical `pages/<slug>.md`, a bare `<slug>.md`, or just `<slug>`
112
+ * — the three forms produced by different historical writers of
113
+ * index.md. Returns "" for hrefs that don't look like a wiki page
114
+ * reference (e.g. `https://example.com`) so the caller can fall
115
+ * back to title-based slugification. */
116
+ function extractSlugFromBulletHref(rawHref) {
117
+ const href = rawHref.trim();
118
+ if (!href) return "";
119
+ if (/^[a-z]+:\/\//i.test(href)) return "";
120
+ return (href.split("/").pop() ?? href).replace(/\.md$/i, "");
121
+ }
122
+ function parseBulletLinkRow(trimmed) {
123
+ const match = BULLET_LINK_PATTERN.exec(trimmed);
124
+ if (!match) return null;
125
+ const title = match[1].trim();
126
+ const href = match[2] ?? "";
127
+ const { description, tags } = extractHashTags(match[3]?.trim() ?? "");
128
+ return {
129
+ title,
130
+ slug: extractSlugFromBulletHref(href) || require_slug.wikiSlugify(title),
131
+ description,
132
+ tags
133
+ };
134
+ }
135
+ function parseBulletWikiLinkRow(trimmed) {
136
+ const match = BULLET_WIKI_LINK_PATTERN.exec(trimmed);
137
+ if (!match) return null;
138
+ const { target, display } = parseWikiLink(match[1]);
139
+ const title = (display || target).trim();
140
+ const slug = target ? require_slug.wikiSlugify(target) : require_slug.wikiSlugify(title);
141
+ const { description, tags } = extractHashTags(match[2]?.trim() ?? "");
142
+ return {
143
+ title,
144
+ slug,
145
+ description,
146
+ tags
147
+ };
148
+ }
149
+ /** Parse entries from index.md. Supports three formats:
150
+ * 1. Table: `| slug | Title | Summary | (Tags) |`
151
+ * 2. Bullet link: `- [Title](pages/slug.md) — description`
152
+ * 3. Wiki link: `- [[Title]] — description`
153
+ *
154
+ * Returns entries in source order. Any unrecognised line is
155
+ * silently skipped — index.md is freeform markdown otherwise. */
156
+ function parseIndexEntries(content) {
157
+ const entries = [];
158
+ let inTable = false;
159
+ let columnMap = null;
160
+ for (const line of content.split("\n")) {
161
+ const trimmed = line.trim();
162
+ if (trimmed.startsWith("|")) {
163
+ if (TABLE_SEPARATOR_PATTERN.test(trimmed)) {
164
+ inTable = true;
165
+ continue;
166
+ }
167
+ if (!inTable) {
168
+ columnMap = buildTableColumnMap(trimmed);
169
+ inTable = true;
170
+ continue;
171
+ }
172
+ const entry = parseTableRow(trimmed, columnMap);
173
+ if (entry) entries.push(entry);
174
+ continue;
175
+ }
176
+ inTable = false;
177
+ columnMap = null;
178
+ const bullet = parseBulletLinkRow(trimmed) ?? parseBulletWikiLinkRow(trimmed);
179
+ if (bullet) entries.push(bullet);
180
+ }
181
+ return entries;
182
+ }
183
+ //#endregion
184
+ //#region src/wiki/lint.ts
185
+ /** Files on disk that aren't referenced by index.md. */
186
+ function findOrphanPages(fileSlugs, indexedSlugs) {
187
+ const issues = [];
188
+ for (const slug of fileSlugs) if (!indexedSlugs.has(slug)) issues.push(`- **Orphan page**: \`${slug}.md\` exists but is missing from index.md`);
189
+ return issues;
190
+ }
191
+ /** Slugs referenced by index.md that have no corresponding file. */
192
+ function findMissingFiles(pageEntries, fileSlugs) {
193
+ const issues = [];
194
+ for (const entry of pageEntries) if (!fileSlugs.has(entry.slug)) issues.push(`- **Missing file**: index.md references \`${entry.slug}\` but the file does not exist`);
195
+ return issues;
196
+ }
197
+ /** Walk a page's body for `[[…]]` links and flag any whose
198
+ * resolved slug doesn't exist in the file set.
199
+ *
200
+ * Critically: this routes through `parseWikiLink` so
201
+ * `[[slug|display]]` is split correctly — the lint slugifies the
202
+ * TARGET, not the full bracket body. Pre-#1297 the lint
203
+ * slugified the entire content (`slug|display`), which collapsed
204
+ * to a slug that always missed and produced ~168 false-positive
205
+ * "broken link" warnings. */
206
+ function findBrokenLinksInPage(fileName, content, fileSlugs) {
207
+ const issues = [];
208
+ const matches = [...content.matchAll(WIKI_LINK_PATTERN)];
209
+ for (const match of matches) {
210
+ const { target } = parseWikiLink(match[1]);
211
+ const linkSlug = require_slug.wikiSlugify(target);
212
+ if (linkSlug.length === 0) {
213
+ issues.push(`- **Broken link** in \`${fileName}\`: [[${match[1]}]] → empty target`);
214
+ continue;
215
+ }
216
+ if (!fileSlugs.has(linkSlug)) issues.push(`- **Broken link** in \`${fileName}\`: [[${match[1]}]] → \`${linkSlug}.md\` not found`);
217
+ }
218
+ return issues;
219
+ }
220
+ function formatTagList(tags) {
221
+ return `[${[...tags].sort().join(", ")}]`;
222
+ }
223
+ /** Flag any slug whose index.md tags differ from the page's own
224
+ * frontmatter `tags:` field. Comparison is set-based and order-
225
+ * insensitive; both sides are lowercased at parse time. Slugs
226
+ * missing from `frontmatterTagsBySlug` are ignored here — the
227
+ * missing file itself is already reported by `findMissingFiles`. */
228
+ function findTagDrift(pageEntries, frontmatterTagsBySlug) {
229
+ const issues = [];
230
+ for (const entry of pageEntries) {
231
+ const pageTags = frontmatterTagsBySlug.get(entry.slug.toLowerCase());
232
+ if (pageTags === void 0) continue;
233
+ const pageSet = new Set(pageTags);
234
+ const indexSet = new Set(entry.tags);
235
+ if (pageSet.size !== indexSet.size || [...pageSet].some((tag) => !indexSet.has(tag))) issues.push(`- **Tag drift**: \`${entry.slug}.md\` frontmatter has ${formatTagList(pageTags)} but index.md has ${formatTagList(entry.tags)}`);
236
+ }
237
+ return issues;
238
+ }
239
+ /** Render the final markdown report from the accumulated issues
240
+ * list. Empty input yields the "wiki is healthy" sentinel so the
241
+ * caller can write the same file every time without branching on
242
+ * presence. */
243
+ function formatLintReport(issues) {
244
+ if (issues.length === 0) return "# Wiki Lint Report\n\n✓ No issues found. Wiki is healthy.";
245
+ const noun = `issue${issues.length !== 1 ? "s" : ""}`;
246
+ return `# Wiki Lint Report\n\n${issues.length} ${noun} found:\n\n${issues.join("\n")}`;
247
+ }
248
+ //#endregion
249
+ //#region src/wiki/graph.ts
250
+ /** Resolve a raw `[[link]]` target to an existing page slug, or null.
251
+ * Mirrors the route resolver's strategy: slugify first, then fall
252
+ * back to matching an index entry title so non-ASCII targets like
253
+ * `[[さくらインターネット]]` resolve to their ASCII file slug. */
254
+ function resolveLinkTarget(target, fileSlugs, slugByTitle) {
255
+ const slug = require_slug.wikiSlugify(target);
256
+ if (slug.length > 0 && fileSlugs.has(slug)) return slug;
257
+ const byTitle = slugByTitle.get(target.trim());
258
+ if (byTitle !== void 0 && fileSlugs.has(byTitle)) return byTitle;
259
+ return null;
260
+ }
261
+ /** Resolved, deduped outgoing slugs for one page body. Self-links and
262
+ * links to non-existent pages are dropped (the latter are already a
263
+ * lint "broken link", not a graph edge). */
264
+ function pageOutgoingSlugs(fromSlug, content, fileSlugs, slugByTitle) {
265
+ const out = /* @__PURE__ */ new Set();
266
+ for (const match of content.matchAll(WIKI_LINK_PATTERN)) {
267
+ const { target } = parseWikiLink(match[1]);
268
+ const resolved = resolveLinkTarget(target, fileSlugs, slugByTitle);
269
+ if (resolved !== null && resolved !== fromSlug) out.add(resolved);
270
+ }
271
+ return [...out];
272
+ }
273
+ function buildTitleMaps(entries) {
274
+ const titleBySlug = /* @__PURE__ */ new Map();
275
+ const slugByTitle = /* @__PURE__ */ new Map();
276
+ for (const entry of entries) {
277
+ if (!titleBySlug.has(entry.slug)) titleBySlug.set(entry.slug, entry.title);
278
+ if (entry.title.length > 0 && !slugByTitle.has(entry.title)) slugByTitle.set(entry.title, entry.slug);
279
+ }
280
+ return {
281
+ titleBySlug,
282
+ slugByTitle
283
+ };
284
+ }
285
+ /** Build the full page→page graph. Nodes are the existing page files
286
+ * (titled from index.md, falling back to the slug for un-indexed
287
+ * pages); edges are the resolved `[[links]]`, deduped per (from,to). */
288
+ function buildWikiGraph(pages, entries) {
289
+ const fileSlugs = new Set(pages.map((page) => page.slug));
290
+ const { titleBySlug, slugByTitle } = buildTitleMaps(entries);
291
+ const nodes = pages.map((page) => ({
292
+ slug: page.slug,
293
+ title: titleBySlug.get(page.slug) ?? page.slug
294
+ }));
295
+ const edges = [];
296
+ const seen = /* @__PURE__ */ new Set();
297
+ for (const page of pages) for (const toSlug of pageOutgoingSlugs(page.slug, page.content, fileSlugs, slugByTitle)) {
298
+ const key = [page.slug, toSlug].join("\n");
299
+ if (seen.has(key)) continue;
300
+ seen.add(key);
301
+ edges.push({
302
+ from: page.slug,
303
+ to: toSlug
304
+ });
305
+ }
306
+ return {
307
+ nodes,
308
+ edges
309
+ };
310
+ }
311
+ /** Pages that link TO `slug` (incoming edges), deduped, as nodes. */
312
+ function incomingLinks(graph, slug) {
313
+ const nodeBySlug = new Map(graph.nodes.map((node) => [node.slug, node]));
314
+ const result = [];
315
+ const seen = /* @__PURE__ */ new Set();
316
+ for (const edge of graph.edges) {
317
+ if (edge.to !== slug || seen.has(edge.from)) continue;
318
+ seen.add(edge.from);
319
+ const node = nodeBySlug.get(edge.from);
320
+ if (node) result.push(node);
321
+ }
322
+ return result;
323
+ }
324
+ //#endregion
325
+ //#region src/wiki/route.ts
326
+ var WIKI_ROUTE_SECTION = {
327
+ pages: "pages",
328
+ log: "log",
329
+ lintReport: "lint-report",
330
+ graph: "graph"
331
+ };
332
+ var WIKI_ACTION = {
333
+ index: "index",
334
+ page: "page",
335
+ log: "log",
336
+ lintReport: "lint_report",
337
+ graph: "graph",
338
+ save: "save"
339
+ };
340
+ function isSafeWikiSlug(value) {
341
+ return typeof value === "string" && require_slug.isSafeSlug(value);
342
+ }
343
+ function readWikiRouteTarget(params) {
344
+ if (!params || typeof params !== "object") return null;
345
+ const { section, slug } = params;
346
+ if (section === void 0 || section === "") return { kind: "index" };
347
+ if (section === WIKI_ROUTE_SECTION.pages) {
348
+ if (!isSafeWikiSlug(slug)) return null;
349
+ return {
350
+ kind: "page",
351
+ slug
352
+ };
353
+ }
354
+ if (section === WIKI_ROUTE_SECTION.log) return { kind: "log" };
355
+ if (section === WIKI_ROUTE_SECTION.lintReport) return { kind: "lint_report" };
356
+ if (section === WIKI_ROUTE_SECTION.graph) return { kind: "graph" };
357
+ return null;
358
+ }
359
+ function buildWikiRouteParams(target) {
360
+ switch (target.kind) {
361
+ case "index": return {
362
+ section: "",
363
+ slug: ""
364
+ };
365
+ case "page": return {
366
+ section: WIKI_ROUTE_SECTION.pages,
367
+ slug: target.slug
368
+ };
369
+ case "log": return {
370
+ section: WIKI_ROUTE_SECTION.log,
371
+ slug: ""
372
+ };
373
+ case "lint_report": return {
374
+ section: WIKI_ROUTE_SECTION.lintReport,
375
+ slug: ""
376
+ };
377
+ case "graph": return {
378
+ section: WIKI_ROUTE_SECTION.graph,
379
+ slug: ""
380
+ };
381
+ default: throw new Error(`unreachable WikiTarget kind: ${JSON.stringify(target)}`);
382
+ }
383
+ }
384
+ function wikiActionFor(target) {
385
+ switch (target.kind) {
386
+ case "index": return WIKI_ACTION.index;
387
+ case "page": return WIKI_ACTION.page;
388
+ case "log": return WIKI_ACTION.log;
389
+ case "lint_report": return WIKI_ACTION.lintReport;
390
+ case "graph": return WIKI_ACTION.graph;
391
+ default: throw new Error(`unreachable WikiTarget kind: ${JSON.stringify(target)}`);
392
+ }
393
+ }
394
+ //#endregion
395
+ //#region src/wiki/render.ts
396
+ /** HTML-escape attribute / text content. Self-contained on purpose:
397
+ * this package can't reach the host's escaper, and the rule is a
398
+ * fixed five-char map. */
399
+ function escapeHtml(value) {
400
+ return value.replace(/[&<>"']/g, (char) => {
401
+ switch (char) {
402
+ case "&": return "&amp;";
403
+ case "<": return "&lt;";
404
+ case ">": return "&gt;";
405
+ case "\"": return "&quot;";
406
+ default: return "&#39;";
407
+ }
408
+ });
409
+ }
410
+ /**
411
+ * Replace every `[[page name]]` occurrence in `content` with a
412
+ * `<span class="wiki-link" data-page="…">…</span>` element. The
413
+ * page name may not contain `]`; an opening `[[` that is not
414
+ * followed later by `]]` (with no bare `]` in between) is left
415
+ * untouched so malformed text renders as-is — matching the
416
+ * previous regex's non-match behaviour.
417
+ *
418
+ * `[[target|display]]` is split via the shared `parseWikiLink`
419
+ * helper so `data-page` carries only the target slug while the
420
+ * visible text shows the display half (#1297). Both halves are
421
+ * HTML-escaped before interpolation — `parseWikiLink` runs BEFORE
422
+ * the host's `marked.parse`, so escaping has to happen here.
423
+ */
424
+ function renderWikiLinks(content) {
425
+ const out = [];
426
+ let i = 0;
427
+ while (i < content.length) {
428
+ if (content[i] === "[" && content[i + 1] === "[") {
429
+ const closeStart = findNextCloseBrackets(content, i + 2);
430
+ if (closeStart !== -1) {
431
+ const { target, display } = parseWikiLink(content.slice(i + 2, closeStart));
432
+ out.push(`<span class="wiki-link" data-page="${escapeHtml(target)}">${escapeHtml(display)}</span>`);
433
+ i = closeStart + 2;
434
+ continue;
435
+ }
436
+ }
437
+ out.push(content[i]);
438
+ i++;
439
+ }
440
+ return out.join("");
441
+ }
442
+ /**
443
+ * Starting at `from`, scan forward for a `]]` sequence. Returns
444
+ * the index of the first `]` of that pair, or -1 if a bare `]`
445
+ * (one not immediately followed by a second `]`) is encountered
446
+ * first — mirroring the old regex's `[^\]]+` constraint that the
447
+ * page name must contain no `]` characters. Also returns -1 if
448
+ * nothing matched before the end of input, or if the pair sits
449
+ * immediately after `from` (zero-length page name, which the old
450
+ * regex rejected via the `+` quantifier).
451
+ */
452
+ function findNextCloseBrackets(str, from) {
453
+ let j = from;
454
+ while (j < str.length) {
455
+ if (str[j] === "]") {
456
+ if (str[j + 1] === "]" && j > from) return j;
457
+ return -1;
458
+ }
459
+ j++;
460
+ }
461
+ return -1;
462
+ }
463
+ //#endregion
464
+ exports.BULLET_LINK_PATTERN = BULLET_LINK_PATTERN;
465
+ exports.BULLET_WIKI_LINK_PATTERN = BULLET_WIKI_LINK_PATTERN;
466
+ exports.WIKI_ACTION = WIKI_ACTION;
467
+ exports.WIKI_LINK_PATTERN = WIKI_LINK_PATTERN;
468
+ exports.WIKI_ROUTE_SECTION = WIKI_ROUTE_SECTION;
469
+ exports.buildTableColumnMap = buildTableColumnMap;
470
+ exports.buildWikiGraph = buildWikiGraph;
471
+ exports.buildWikiRouteParams = buildWikiRouteParams;
472
+ exports.escapeHtml = escapeHtml;
473
+ exports.extractHashTags = extractHashTags;
474
+ exports.extractSlugFromBulletHref = extractSlugFromBulletHref;
475
+ exports.findBrokenLinksInPage = findBrokenLinksInPage;
476
+ exports.findMissingFiles = findMissingFiles;
477
+ exports.findOrphanPages = findOrphanPages;
478
+ exports.findTagDrift = findTagDrift;
479
+ exports.formatLintReport = formatLintReport;
480
+ exports.incomingLinks = incomingLinks;
481
+ exports.isSafeSlug = require_slug.isSafeSlug;
482
+ exports.isSafeWikiSlug = isSafeWikiSlug;
483
+ exports.pageOutgoingSlugs = pageOutgoingSlugs;
484
+ exports.parseIndexEntries = parseIndexEntries;
485
+ exports.parseTagsCell = parseTagsCell;
486
+ exports.parseWikiLink = parseWikiLink;
487
+ exports.readWikiRouteTarget = readWikiRouteTarget;
488
+ exports.renderWikiLinks = renderWikiLinks;
489
+ exports.resolveLinkTarget = resolveLinkTarget;
490
+ exports.wikiActionFor = wikiActionFor;
491
+ exports.wikiSlugify = require_slug.wikiSlugify;
492
+
493
+ //# sourceMappingURL=index.cjs.map