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 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":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "narrarium-astro-reader",
3
- "version": "0.1.21",
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.21",
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 is:inline>
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
- if (terms.length === 0) return;
372
-
373
- const pattern = new RegExp(`(^|[^\\p{L}\\p{N}])(${terms.map(escapeRegex).join("|")})(?=$|[^\\p{L}\\p{N}])`, "giu");
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
- const button = document.createElement("button");
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 initializeCanonOverlay(pattern, termMap, glossaryById, limit) {
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
+ }