narrarium-astro-reader 0.1.21 → 0.1.22
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/README.md +1 -0
- package/cli-dist/lib/canon-mentions.d.ts +8 -0
- package/cli-dist/lib/canon-mentions.d.ts.map +1 -0
- package/cli-dist/lib/canon-mentions.js +86 -0
- package/cli-dist/lib/canon-mentions.js.map +1 -0
- package/package.json +2 -2
- package/src/components/ReaderRuntime.astro +43 -15
- package/src/lib/canon-mentions.ts +105 -0
package/README.md
CHANGED
|
@@ -43,6 +43,7 @@ In public mode:
|
|
|
43
43
|
- secrets stay hidden from the public atlas and nav
|
|
44
44
|
- direct canon pages fall back to teaser or locked views when `known_from` or `reveal_in` say a dossier is not safe yet
|
|
45
45
|
- search, canon popups, and backlinks follow the same thresholds
|
|
46
|
+
- chapter and scene prose should keep canon names as plain text; the reader upgrades visible mentions into canon popups and also rewrites legacy internal canon links at runtime
|
|
46
47
|
|
|
47
48
|
If you want an author-only or spoiler-friendly deployment, enable full canon mode:
|
|
48
49
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type CanonMentionEntry = {
|
|
2
|
+
id: string;
|
|
3
|
+
href: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function buildCanonHrefIndex(entries: CanonMentionEntry[]): Map<string, string>;
|
|
6
|
+
export declare function resolveCanonEntryIdFromHref(href: string, entries: CanonMentionEntry[]): string | null;
|
|
7
|
+
export declare function normalizeCanonEntityHref(href: string): string | null;
|
|
8
|
+
//# sourceMappingURL=canon-mentions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canon-mentions.d.ts","sourceRoot":"","sources":["../../src/lib/canon-mentions.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,iBAAiB,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAarF;AAED,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,EAAE,GAAG,MAAM,GAAG,IAAI,CAOrG;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyCpE"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export function buildCanonHrefIndex(entries) {
|
|
2
|
+
const index = new Map();
|
|
3
|
+
for (const entry of entries) {
|
|
4
|
+
const normalizedHref = normalizeCanonEntityHref(entry.href);
|
|
5
|
+
if (!normalizedHref) {
|
|
6
|
+
continue;
|
|
7
|
+
}
|
|
8
|
+
index.set(normalizedHref, entry.id);
|
|
9
|
+
}
|
|
10
|
+
return index;
|
|
11
|
+
}
|
|
12
|
+
export function resolveCanonEntryIdFromHref(href, entries) {
|
|
13
|
+
const normalizedHref = normalizeCanonEntityHref(href);
|
|
14
|
+
if (!normalizedHref) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return buildCanonHrefIndex(entries).get(normalizedHref) ?? null;
|
|
18
|
+
}
|
|
19
|
+
export function normalizeCanonEntityHref(href) {
|
|
20
|
+
const strippedOrigin = href
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/^[a-z]+:\/\/[^/]+/i, "")
|
|
23
|
+
.split(/[?#]/, 1)[0]
|
|
24
|
+
.replace(/\\/g, "/");
|
|
25
|
+
if (!strippedOrigin) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const segments = strippedOrigin
|
|
29
|
+
.split("/")
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.filter((segment) => segment !== "." && segment !== "..");
|
|
32
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
33
|
+
const current = segments[index]?.toLowerCase();
|
|
34
|
+
if (!current) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (current === "timelines" && segments[index + 1]?.toLowerCase() === "events") {
|
|
38
|
+
const slug = normalizeSlugSegment(segments[index + 2]);
|
|
39
|
+
if (slug) {
|
|
40
|
+
return `timeline/${slug}/`;
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const section = normalizeCanonSection(current);
|
|
45
|
+
if (!section) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const slug = normalizeSlugSegment(segments[index + 1]);
|
|
49
|
+
if (slug) {
|
|
50
|
+
return `${section}/${slug}/`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function normalizeCanonSection(segment) {
|
|
56
|
+
switch (segment.toLowerCase()) {
|
|
57
|
+
case "character":
|
|
58
|
+
case "characters":
|
|
59
|
+
return "characters";
|
|
60
|
+
case "location":
|
|
61
|
+
case "locations":
|
|
62
|
+
return "locations";
|
|
63
|
+
case "faction":
|
|
64
|
+
case "factions":
|
|
65
|
+
return "factions";
|
|
66
|
+
case "item":
|
|
67
|
+
case "items":
|
|
68
|
+
return "items";
|
|
69
|
+
case "secret":
|
|
70
|
+
case "secrets":
|
|
71
|
+
return "secrets";
|
|
72
|
+
case "timeline":
|
|
73
|
+
case "timeline-event":
|
|
74
|
+
return "timeline";
|
|
75
|
+
default:
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function normalizeSlugSegment(segment) {
|
|
80
|
+
if (!segment) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const normalized = segment.replace(/\.md$/i, "").trim().toLowerCase();
|
|
84
|
+
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=canon-mentions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canon-mentions.js","sourceRoot":"","sources":["../../src/lib/canon-mentions.ts"],"names":[],"mappings":"AAKA,MAAM,UAAU,mBAAmB,CAAC,OAA4B;IAC9D,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;IAExC,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,MAAM,cAAc,GAAG,wBAAwB,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC5D,IAAI,CAAC,cAAc,EAAE,CAAC;YACpB,SAAS;QACX,CAAC;QAED,KAAK,CAAC,GAAG,CAAC,cAAc,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC;IACtC,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,2BAA2B,CAAC,IAAY,EAAE,OAA4B;IACpF,MAAM,cAAc,GAAG,wBAAwB,CAAC,IAAI,CAAC,CAAC;IACtD,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO,mBAAmB,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,IAAI,CAAC;AAClE,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,IAAY;IACnD,MAAM,cAAc,GAAG,IAAI;SACxB,IAAI,EAAE;SACN,OAAO,CAAC,oBAAoB,EAAE,EAAE,CAAC;SACjC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;SACnB,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;IACvB,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,QAAQ,GAAG,cAAc;SAC5B,KAAK,CAAC,GAAG,CAAC;SACV,MAAM,CAAC,OAAO,CAAC;SACf,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,KAAK,GAAG,IAAI,OAAO,KAAK,IAAI,CAAC,CAAC;IAE5D,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,QAAQ,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QACxD,MAAM,OAAO,GAAG,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,EAAE,CAAC;QAC/C,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,SAAS;QACX,CAAC;QAED,IAAI,OAAO,KAAK,WAAW,IAAI,QAAQ,CAAC,KAAK,GAAG,CAAC,CAAC,EAAE,WAAW,EAAE,KAAK,QAAQ,EAAE,CAAC;YAC/E,MAAM,IAAI,GAAG,oBAAoB,CAAC,QAAQ,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC;YACvD,IAAI,IAAI,EAAE,CAAC;gBACT,OAAO,YAAY,IAAI,GAAG,CAAC;YAC7B,CAAC;YACD,SAAS;QACX,CAAC;QAED,MAAM,OAAO,GAAG,qBAAqB,CAAC,OAAO,CAAC,CAAC;QAC/C,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,SAAS;QACX,CAAC;QAED,MAAM,IAAI,GAAG,oBAAoB,CAAC,QAAQ,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC;QACvD,IAAI,IAAI,EAAE,CAAC;YACT,OAAO,GAAG,OAAO,IAAI,IAAI,GAAG,CAAC;QAC/B,CAAC;IACH,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,qBAAqB,CAAC,OAAe;IAC5C,QAAQ,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC;QAC9B,KAAK,WAAW,CAAC;QACjB,KAAK,YAAY;YACf,OAAO,YAAY,CAAC;QACtB,KAAK,UAAU,CAAC;QAChB,KAAK,WAAW;YACd,OAAO,WAAW,CAAC;QACrB,KAAK,SAAS,CAAC;QACf,KAAK,UAAU;YACb,OAAO,UAAU,CAAC;QACpB,KAAK,MAAM,CAAC;QACZ,KAAK,OAAO;YACV,OAAO,OAAO,CAAC;QACjB,KAAK,QAAQ,CAAC;QACd,KAAK,SAAS;YACZ,OAAO,SAAS,CAAC;QACnB,KAAK,UAAU,CAAC;QAChB,KAAK,gBAAgB;YACnB,OAAO,UAAU,CAAC;QACpB;YACE,OAAO,IAAI,CAAC;IAChB,CAAC;AACH,CAAC;AAED,SAAS,oBAAoB,CAAC,OAA2B;IACvD,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACtE,OAAO,cAAc,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC;AAC7D,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "narrarium-astro-reader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Astro reader and scaffolding CLI for Narrarium book repositories.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"test": "npm run build:cli && node --test test/**/*.test.mjs"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"narrarium": "^0.1.
|
|
53
|
+
"narrarium": "^0.1.22",
|
|
54
54
|
"astro": "^5.14.1",
|
|
55
55
|
"chokidar": "^4.0.3",
|
|
56
56
|
"marked": "^16.3.0"
|
|
@@ -144,7 +144,9 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
144
144
|
<script type="application/json" id="canon-glossary-data" set:html={glossaryJson}></script>
|
|
145
145
|
<script type="application/json" id="reader-chapter-data" set:html={chaptersJson}></script>
|
|
146
146
|
|
|
147
|
-
<script
|
|
147
|
+
<script>
|
|
148
|
+
import { buildCanonHrefIndex, normalizeCanonEntityHref } from "../lib/canon-mentions.js";
|
|
149
|
+
|
|
148
150
|
const themeStorageKey = "narrarium-reader-theme";
|
|
149
151
|
const canonModeStorageKey = "narrarium-reader-canon";
|
|
150
152
|
const progressStorageKey = "narrarium-reader-progress";
|
|
@@ -354,6 +356,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
354
356
|
const limit = getSelectedSpoilerLimit();
|
|
355
357
|
const visibleEntries = glossaryEntries.filter((entry) => isEntryVisible(entry, limit));
|
|
356
358
|
const glossaryById = new Map(visibleEntries.map((entry) => [entry.id, entry]));
|
|
359
|
+
const hrefIndex = buildCanonHrefIndex(visibleEntries);
|
|
357
360
|
const termMap = new Map();
|
|
358
361
|
|
|
359
362
|
for (const entry of visibleEntries) {
|
|
@@ -368,15 +371,15 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
368
371
|
}
|
|
369
372
|
|
|
370
373
|
const terms = [...termMap.keys()].sort((left, right) => right.length - left.length);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
+
const pattern = terms.length > 0
|
|
375
|
+
? new RegExp(`(^|[^\\p{L}\\p{N}])(${terms.map(escapeRegex).join("|")})(?=$|[^\\p{L}\\p{N}])`, "giu")
|
|
376
|
+
: null;
|
|
374
377
|
|
|
375
|
-
enhanceCanonRoot(document, pattern, termMap);
|
|
376
|
-
initializeCanonOverlay(pattern, termMap, glossaryById, limit);
|
|
378
|
+
enhanceCanonRoot(document, pattern, termMap, hrefIndex);
|
|
379
|
+
initializeCanonOverlay(pattern, termMap, hrefIndex, glossaryById, limit);
|
|
377
380
|
}
|
|
378
381
|
|
|
379
|
-
function enhanceCanonRoot(root, pattern, termMap) {
|
|
382
|
+
function enhanceCanonRoot(root, pattern, termMap, hrefIndex) {
|
|
380
383
|
const containers = root instanceof Document
|
|
381
384
|
? root.querySelectorAll(".prose p, .prose li, .prose blockquote")
|
|
382
385
|
: root.matches?.(".prose")
|
|
@@ -385,7 +388,11 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
385
388
|
|
|
386
389
|
containers.forEach((container) => {
|
|
387
390
|
if (container.dataset.canonEnhanced === "true") return;
|
|
391
|
+
rewriteLegacyCanonLinks(container, hrefIndex);
|
|
388
392
|
container.dataset.canonEnhanced = "true";
|
|
393
|
+
if (!pattern) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
389
396
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
390
397
|
const textNodes = [];
|
|
391
398
|
while (walker.nextNode()) {
|
|
@@ -430,12 +437,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
430
437
|
}
|
|
431
438
|
|
|
432
439
|
if (entryId) {
|
|
433
|
-
|
|
434
|
-
button.type = "button";
|
|
435
|
-
button.className = "canon-mention";
|
|
436
|
-
button.dataset.canonId = entryId;
|
|
437
|
-
button.textContent = term;
|
|
438
|
-
fragment.append(button);
|
|
440
|
+
fragment.append(createCanonMentionButton(entryId, term));
|
|
439
441
|
} else {
|
|
440
442
|
fragment.append(term);
|
|
441
443
|
}
|
|
@@ -450,7 +452,33 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
450
452
|
textNode.replaceWith(fragment);
|
|
451
453
|
}
|
|
452
454
|
|
|
453
|
-
function
|
|
455
|
+
function rewriteLegacyCanonLinks(container, hrefIndex) {
|
|
456
|
+
container.querySelectorAll("a[href]").forEach((anchor) => {
|
|
457
|
+
if (!(anchor instanceof HTMLAnchorElement) || anchor.closest("[data-no-canon]")) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const normalizedHref = normalizeCanonEntityHref(anchor.getAttribute("href") || "");
|
|
462
|
+
if (!normalizedHref) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const label = anchor.textContent || "";
|
|
467
|
+
const entryId = hrefIndex.get(normalizedHref);
|
|
468
|
+
anchor.replaceWith(entryId ? createCanonMentionButton(entryId, label) : document.createTextNode(label));
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function createCanonMentionButton(entryId, label) {
|
|
473
|
+
const button = document.createElement("button");
|
|
474
|
+
button.type = "button";
|
|
475
|
+
button.className = "canon-mention";
|
|
476
|
+
button.dataset.canonId = entryId;
|
|
477
|
+
button.textContent = label;
|
|
478
|
+
return button;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function initializeCanonOverlay(pattern, termMap, hrefIndex, glossaryById, limit) {
|
|
454
482
|
const overlay = document.querySelector("[data-canon-overlay]");
|
|
455
483
|
if (!overlay || overlay.dataset.bound === "true") return;
|
|
456
484
|
overlay.dataset.bound = "true";
|
|
@@ -482,7 +510,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
|
|
|
482
510
|
link.href = entry.href;
|
|
483
511
|
link.hidden = !revealed;
|
|
484
512
|
body.innerHTML = revealed ? entry.bodyHtml || "" : "";
|
|
485
|
-
enhanceCanonRoot(body, pattern, termMap);
|
|
513
|
+
enhanceCanonRoot(body, pattern, termMap, hrefIndex);
|
|
486
514
|
metadata.replaceChildren(...(entry.metadataEntries || []).map((item) => {
|
|
487
515
|
const row = document.createElement("div");
|
|
488
516
|
row.className = "meta-row";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export type CanonMentionEntry = {
|
|
2
|
+
id: string;
|
|
3
|
+
href: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function buildCanonHrefIndex(entries: CanonMentionEntry[]): Map<string, string> {
|
|
7
|
+
const index = new Map<string, string>();
|
|
8
|
+
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
const normalizedHref = normalizeCanonEntityHref(entry.href);
|
|
11
|
+
if (!normalizedHref) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
index.set(normalizedHref, entry.id);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return index;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveCanonEntryIdFromHref(href: string, entries: CanonMentionEntry[]): string | null {
|
|
22
|
+
const normalizedHref = normalizeCanonEntityHref(href);
|
|
23
|
+
if (!normalizedHref) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return buildCanonHrefIndex(entries).get(normalizedHref) ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeCanonEntityHref(href: string): string | null {
|
|
31
|
+
const strippedOrigin = href
|
|
32
|
+
.trim()
|
|
33
|
+
.replace(/^[a-z]+:\/\/[^/]+/i, "")
|
|
34
|
+
.split(/[?#]/, 1)[0]
|
|
35
|
+
.replace(/\\/g, "/");
|
|
36
|
+
if (!strippedOrigin) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const segments = strippedOrigin
|
|
41
|
+
.split("/")
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.filter((segment) => segment !== "." && segment !== "..");
|
|
44
|
+
|
|
45
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
46
|
+
const current = segments[index]?.toLowerCase();
|
|
47
|
+
if (!current) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (current === "timelines" && segments[index + 1]?.toLowerCase() === "events") {
|
|
52
|
+
const slug = normalizeSlugSegment(segments[index + 2]);
|
|
53
|
+
if (slug) {
|
|
54
|
+
return `timeline/${slug}/`;
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const section = normalizeCanonSection(current);
|
|
60
|
+
if (!section) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const slug = normalizeSlugSegment(segments[index + 1]);
|
|
65
|
+
if (slug) {
|
|
66
|
+
return `${section}/${slug}/`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeCanonSection(segment: string): string | null {
|
|
74
|
+
switch (segment.toLowerCase()) {
|
|
75
|
+
case "character":
|
|
76
|
+
case "characters":
|
|
77
|
+
return "characters";
|
|
78
|
+
case "location":
|
|
79
|
+
case "locations":
|
|
80
|
+
return "locations";
|
|
81
|
+
case "faction":
|
|
82
|
+
case "factions":
|
|
83
|
+
return "factions";
|
|
84
|
+
case "item":
|
|
85
|
+
case "items":
|
|
86
|
+
return "items";
|
|
87
|
+
case "secret":
|
|
88
|
+
case "secrets":
|
|
89
|
+
return "secrets";
|
|
90
|
+
case "timeline":
|
|
91
|
+
case "timeline-event":
|
|
92
|
+
return "timeline";
|
|
93
|
+
default:
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeSlugSegment(segment: string | undefined): string | null {
|
|
99
|
+
if (!segment) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const normalized = segment.replace(/\.md$/i, "").trim().toLowerCase();
|
|
104
|
+
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
|
|
105
|
+
}
|