narrarium-astro-reader 0.1.20 → 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
 
@@ -26,6 +26,7 @@ export declare function loadHomePageData(): Promise<{
26
26
  path: string;
27
27
  metadata: import("narrarium").ChapterFrontmatter;
28
28
  }[];
29
+ draftChapterCount: number;
29
30
  characters: {
30
31
  slug: string;
31
32
  path: string;
@@ -1 +1 @@
1
- {"version":3,"file":"book.d.ts","sourceRoot":"","sources":["../../src/lib/book.ts"],"names":[],"mappings":"AAaA,KAAK,gBAAgB,GACjB,WAAW,GACX,UAAU,GACV,SAAS,GACT,MAAM,GACN,QAAQ,GACR,gBAAgB,CAAC;AAErB,wBAAgB,WAAW,IAAI,MAAM,CAKpC;AAED,wBAAsB,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CrC;AAED,wBAAsB,mBAAmB,CAAC,WAAW,EAAE,MAAM;;;;;;;;GAG5D;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,gBAAgB;;;;;;;;;GAa/D;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM;;;;;GAG5E;AAED,wBAAsB,oBAAoB;;;;;;;;;;;;;GAoBzC"}
1
+ {"version":3,"file":"book.d.ts","sourceRoot":"","sources":["../../src/lib/book.ts"],"names":[],"mappings":"AAcA,KAAK,gBAAgB,GACjB,WAAW,GACX,UAAU,GACV,SAAS,GACT,MAAM,GACN,QAAQ,GACR,gBAAgB,CAAC;AAErB,wBAAgB,WAAW,IAAI,MAAM,CAKpC;AAED,wBAAsB,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CrC;AAQD,wBAAsB,mBAAmB,CAAC,WAAW,EAAE,MAAM;;;;;;;;GAG5D;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,gBAAgB;;;;;;;;;GAa/D;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM;;;;;GAG5E;AAED,wBAAsB,oBAAoB;;;;;;;;;;;;;GAoBzC"}
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { readdir } from "node:fs/promises";
2
3
  import { listChapters, listEntities, pathExists, readBook, readChapter, readEntity, readTimelineMain, } from "narrarium";
3
4
  import { defaultBookRoot } from "./book-config.js";
4
5
  import { readReaderBookRootEnv, resolveReaderBookRootCandidate } from "./env.js";
@@ -18,6 +19,7 @@ export async function loadHomePageData() {
18
19
  root,
19
20
  book: null,
20
21
  chapters: [],
22
+ draftChapterCount: 0,
21
23
  characters: [],
22
24
  locations: [],
23
25
  factions: [],
@@ -26,9 +28,10 @@ export async function loadHomePageData() {
26
28
  timelineEvents: [],
27
29
  };
28
30
  }
29
- const [book, chapters, characters, locations, factions, items, secrets, timelineEvents] = await Promise.all([
31
+ const [book, chapters, draftChapterCount, characters, locations, factions, items, secrets, timelineEvents] = await Promise.all([
30
32
  readBook(root),
31
33
  listChapters(root),
34
+ countDraftChapters(root),
32
35
  listEntities(root, "character"),
33
36
  listEntities(root, "location"),
34
37
  listEntities(root, "faction"),
@@ -41,6 +44,7 @@ export async function loadHomePageData() {
41
44
  root,
42
45
  book,
43
46
  chapters,
47
+ draftChapterCount,
44
48
  characters,
45
49
  locations,
46
50
  factions,
@@ -49,6 +53,11 @@ export async function loadHomePageData() {
49
53
  timelineEvents,
50
54
  };
51
55
  }
56
+ async function countDraftChapters(root) {
57
+ const draftsRoot = path.join(root, "drafts");
58
+ const entries = await readdir(draftsRoot, { withFileTypes: true }).catch(() => []);
59
+ return entries.filter((entry) => entry.isDirectory()).length;
60
+ }
52
61
  export async function loadChapterPageData(chapterSlug) {
53
62
  const root = getBookRoot();
54
63
  return readChapter(root, chapterSlug);
@@ -1 +1 @@
1
- {"version":3,"file":"book.js","sourceRoot":"","sources":["../../src/lib/book.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"book.js","sourceRoot":"","sources":["../../src/lib/book.ts"],"names":[],"mappings":"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"}
@@ -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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"}
@@ -0,0 +1,49 @@
1
+ export type WorkshopEntry = {
2
+ id: string;
3
+ title: string;
4
+ body: string;
5
+ status: string;
6
+ tags: string[];
7
+ sourceKind?: string;
8
+ promotedTo?: string;
9
+ };
10
+ export type WorkshopDocument = {
11
+ path: string;
12
+ title: string;
13
+ bucket: string;
14
+ bodyHtml: string;
15
+ entries: WorkshopEntry[];
16
+ };
17
+ export type WorkshopDraftChapter = {
18
+ slug: string;
19
+ title: string;
20
+ summary: string;
21
+ bodyHtml: string;
22
+ paragraphs: Array<{
23
+ slug: string;
24
+ title: string;
25
+ summary: string;
26
+ }>;
27
+ ideas: WorkshopDocument | null;
28
+ notes: WorkshopDocument | null;
29
+ promoted: WorkshopDocument | null;
30
+ };
31
+ export declare function loadWorkshopPageData(): Promise<{
32
+ ready: boolean;
33
+ root: string;
34
+ global: null;
35
+ draftChapters: never[];
36
+ } | {
37
+ ready: boolean;
38
+ root: string;
39
+ global: {
40
+ context: WorkshopDocument | null;
41
+ ideas: WorkshopDocument | null;
42
+ notes: WorkshopDocument | null;
43
+ storyDesign: WorkshopDocument | null;
44
+ promoted: WorkshopDocument | null;
45
+ };
46
+ draftChapters: WorkshopDraftChapter[];
47
+ }>;
48
+ export declare function countDraftChapters(root: string): Promise<number>;
49
+ //# sourceMappingURL=workshop.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workshop.d.ts","sourceRoot":"","sources":["../../src/lib/workshop.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,aAAa,EAAE,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpE,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC/B,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC/B,QAAQ,EAAE,gBAAgB,GAAG,IAAI,CAAC;CACnC,CAAC;AAEF,wBAAsB,oBAAoB;;;;;;;;;;;;;;;;GAkCzC;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAEtE"}
@@ -0,0 +1,113 @@
1
+ import path from "node:path";
2
+ import { readFile, readdir } from "node:fs/promises";
3
+ import { marked } from "marked";
4
+ import { parseNarrariumMarkdownDocument, pathExists, readChapterDraft } from "narrarium";
5
+ import { getBookRoot } from "./book.js";
6
+ export async function loadWorkshopPageData() {
7
+ const root = getBookRoot();
8
+ const ready = await pathExists(path.join(root, "book.md"));
9
+ if (!ready) {
10
+ return {
11
+ ready: false,
12
+ root,
13
+ global: null,
14
+ draftChapters: [],
15
+ };
16
+ }
17
+ const [context, ideas, notes, storyDesign, promoted, draftChapters] = await Promise.all([
18
+ readWorkshopDocument(root, "context.md"),
19
+ readWorkshopDocument(root, "ideas.md"),
20
+ readWorkshopDocument(root, "notes.md"),
21
+ readWorkshopDocument(root, "story-design.md"),
22
+ readWorkshopDocument(root, "promoted.md"),
23
+ listDraftChapters(root),
24
+ ]);
25
+ return {
26
+ ready: true,
27
+ root,
28
+ global: {
29
+ context,
30
+ ideas,
31
+ notes,
32
+ storyDesign,
33
+ promoted,
34
+ },
35
+ draftChapters,
36
+ };
37
+ }
38
+ export async function countDraftChapters(root) {
39
+ return (await listDraftChapters(root)).length;
40
+ }
41
+ async function listDraftChapters(root) {
42
+ const draftsRoot = path.join(root, "drafts");
43
+ const entries = await readdir(draftsRoot, { withFileTypes: true }).catch(() => []);
44
+ const slugs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
45
+ const chapters = await Promise.all(slugs.map(async (slug) => {
46
+ const chapterFile = path.join(draftsRoot, slug, "chapter.md");
47
+ if (!(await pathExists(chapterFile))) {
48
+ return null;
49
+ }
50
+ const chapter = await readChapterDraft(root, slug);
51
+ const [ideas, notes, promoted] = await Promise.all([
52
+ readWorkshopDocument(root, path.posix.join("drafts", slug, "ideas.md")),
53
+ readWorkshopDocument(root, path.posix.join("drafts", slug, "notes.md")),
54
+ readWorkshopDocument(root, path.posix.join("drafts", slug, "promoted.md")),
55
+ ]);
56
+ return {
57
+ slug,
58
+ title: chapter.metadata.title,
59
+ summary: chapter.metadata.summary ?? "Draft chapter in progress.",
60
+ bodyHtml: await toHtml(chapter.body || "No chapter draft body yet."),
61
+ paragraphs: chapter.paragraphs.map((paragraph) => ({
62
+ slug: path.basename(paragraph.path, ".md"),
63
+ title: paragraph.metadata.title,
64
+ summary: paragraph.metadata.summary ?? "Draft scene.",
65
+ })),
66
+ ideas,
67
+ notes,
68
+ promoted,
69
+ };
70
+ }));
71
+ return chapters.filter((chapter) => Boolean(chapter));
72
+ }
73
+ async function readWorkshopDocument(root, relativePath) {
74
+ const absolutePath = path.join(root, relativePath);
75
+ const raw = await readFile(absolutePath, "utf8").catch(() => null);
76
+ if (!raw) {
77
+ return null;
78
+ }
79
+ const document = parseNarrariumMarkdownDocument(relativePath, raw);
80
+ const frontmatter = document.frontmatter;
81
+ const title = typeof frontmatter.title === "string" && frontmatter.title.trim() ? frontmatter.title : relativePath;
82
+ const bucket = typeof frontmatter.bucket === "string" ? frontmatter.bucket : document.kind;
83
+ return {
84
+ path: relativePath,
85
+ title,
86
+ bucket,
87
+ bodyHtml: await toHtml(document.body || "No content yet."),
88
+ entries: readWorkshopEntries(frontmatter),
89
+ };
90
+ }
91
+ function readWorkshopEntries(frontmatter) {
92
+ if (!Array.isArray(frontmatter.entries)) {
93
+ return [];
94
+ }
95
+ return frontmatter.entries
96
+ .filter((entry) => Boolean(entry && typeof entry === "object"))
97
+ .map((entry) => ({
98
+ id: typeof entry.id === "string" ? entry.id : "",
99
+ title: typeof entry.title === "string" ? entry.title : "Untitled",
100
+ body: typeof entry.body === "string" ? entry.body : "",
101
+ status: typeof entry.status === "string" ? entry.status : "active",
102
+ tags: Array.isArray(entry.tags) ? entry.tags.filter((tag) => typeof tag === "string") : [],
103
+ sourceKind: typeof entry.source_kind === "string" ? entry.source_kind : undefined,
104
+ promotedTo: typeof entry.promoted_to === "string" ? entry.promoted_to : undefined,
105
+ }))
106
+ .filter((entry) => entry.id.length > 0)
107
+ .sort((left, right) => left.title.localeCompare(right.title));
108
+ }
109
+ async function toHtml(markdown) {
110
+ const rendered = await marked.parse(markdown);
111
+ return typeof rendered === "string" ? rendered : String(rendered);
112
+ }
113
+ //# sourceMappingURL=workshop.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workshop.js","sourceRoot":"","sources":["../../src/lib/workshop.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.20",
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.20",
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";
@@ -14,6 +14,7 @@ interface Props {
14
14
  activeSection?:
15
15
  | "home"
16
16
  | "chapters"
17
+ | "workshop"
17
18
  | "characters"
18
19
  | "locations"
19
20
  | "factions"
@@ -75,6 +76,7 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
75
76
  <nav class="site-nav">
76
77
  <a class:list={["nav-link", activeSection === "home" && "is-active"]} href="./">Book</a>
77
78
  <a class:list={["nav-link", activeSection === "chapters" && "is-active"]} href="./#chapters">Chapters</a>
79
+ {fullCanonMode && <a class:list={["nav-link", activeSection === "workshop" && "is-active"]} href="workshop/">Workshop</a>}
78
80
  <a class:list={["nav-link", activeSection === "characters" && "is-active"]} href="characters/">Characters</a>
79
81
  <a class:list={["nav-link", activeSection === "locations" && "is-active"]} href="locations/">Locations</a>
80
82
  <a class:list={["nav-link", activeSection === "factions" && "is-active"]} href="factions/">Factions</a>
package/src/lib/book.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { readdir } from "node:fs/promises";
2
3
  import {
3
4
  listChapters,
4
5
  listEntities,
@@ -36,6 +37,7 @@ export async function loadHomePageData() {
36
37
  root,
37
38
  book: null,
38
39
  chapters: [],
40
+ draftChapterCount: 0,
39
41
  characters: [],
40
42
  locations: [],
41
43
  factions: [],
@@ -45,9 +47,10 @@ export async function loadHomePageData() {
45
47
  };
46
48
  }
47
49
 
48
- const [book, chapters, characters, locations, factions, items, secrets, timelineEvents] = await Promise.all([
50
+ const [book, chapters, draftChapterCount, characters, locations, factions, items, secrets, timelineEvents] = await Promise.all([
49
51
  readBook(root),
50
52
  listChapters(root),
53
+ countDraftChapters(root),
51
54
  listEntities(root, "character"),
52
55
  listEntities(root, "location"),
53
56
  listEntities(root, "faction"),
@@ -61,6 +64,7 @@ export async function loadHomePageData() {
61
64
  root,
62
65
  book,
63
66
  chapters,
67
+ draftChapterCount,
64
68
  characters,
65
69
  locations,
66
70
  factions,
@@ -70,6 +74,12 @@ export async function loadHomePageData() {
70
74
  };
71
75
  }
72
76
 
77
+ async function countDraftChapters(root: string): Promise<number> {
78
+ const draftsRoot = path.join(root, "drafts");
79
+ const entries = await readdir(draftsRoot, { withFileTypes: true }).catch(() => []);
80
+ return entries.filter((entry) => entry.isDirectory()).length;
81
+ }
82
+
73
83
  export async function loadChapterPageData(chapterSlug: string) {
74
84
  const root = getBookRoot();
75
85
  return readChapter(root, chapterSlug);
@@ -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
+ }
@@ -0,0 +1,159 @@
1
+ import path from "node:path";
2
+ import { readFile, readdir } from "node:fs/promises";
3
+ import { marked } from "marked";
4
+ import { parseNarrariumMarkdownDocument, pathExists, readChapterDraft } from "narrarium";
5
+ import { getBookRoot } from "./book.js";
6
+
7
+ export type WorkshopEntry = {
8
+ id: string;
9
+ title: string;
10
+ body: string;
11
+ status: string;
12
+ tags: string[];
13
+ sourceKind?: string;
14
+ promotedTo?: string;
15
+ };
16
+
17
+ export type WorkshopDocument = {
18
+ path: string;
19
+ title: string;
20
+ bucket: string;
21
+ bodyHtml: string;
22
+ entries: WorkshopEntry[];
23
+ };
24
+
25
+ export type WorkshopDraftChapter = {
26
+ slug: string;
27
+ title: string;
28
+ summary: string;
29
+ bodyHtml: string;
30
+ paragraphs: Array<{ slug: string; title: string; summary: string }>;
31
+ ideas: WorkshopDocument | null;
32
+ notes: WorkshopDocument | null;
33
+ promoted: WorkshopDocument | null;
34
+ };
35
+
36
+ export async function loadWorkshopPageData() {
37
+ const root = getBookRoot();
38
+ const ready = await pathExists(path.join(root, "book.md"));
39
+
40
+ if (!ready) {
41
+ return {
42
+ ready: false,
43
+ root,
44
+ global: null,
45
+ draftChapters: [],
46
+ };
47
+ }
48
+
49
+ const [context, ideas, notes, storyDesign, promoted, draftChapters] = await Promise.all([
50
+ readWorkshopDocument(root, "context.md"),
51
+ readWorkshopDocument(root, "ideas.md"),
52
+ readWorkshopDocument(root, "notes.md"),
53
+ readWorkshopDocument(root, "story-design.md"),
54
+ readWorkshopDocument(root, "promoted.md"),
55
+ listDraftChapters(root),
56
+ ]);
57
+
58
+ return {
59
+ ready: true,
60
+ root,
61
+ global: {
62
+ context,
63
+ ideas,
64
+ notes,
65
+ storyDesign,
66
+ promoted,
67
+ },
68
+ draftChapters,
69
+ };
70
+ }
71
+
72
+ export async function countDraftChapters(root: string): Promise<number> {
73
+ return (await listDraftChapters(root)).length;
74
+ }
75
+
76
+ async function listDraftChapters(root: string): Promise<WorkshopDraftChapter[]> {
77
+ const draftsRoot = path.join(root, "drafts");
78
+ const entries = await readdir(draftsRoot, { withFileTypes: true }).catch(() => []);
79
+ const slugs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
80
+
81
+ const chapters = await Promise.all(
82
+ slugs.map(async (slug) => {
83
+ const chapterFile = path.join(draftsRoot, slug, "chapter.md");
84
+ if (!(await pathExists(chapterFile))) {
85
+ return null;
86
+ }
87
+
88
+ const chapter = await readChapterDraft(root, slug);
89
+ const [ideas, notes, promoted] = await Promise.all([
90
+ readWorkshopDocument(root, path.posix.join("drafts", slug, "ideas.md")),
91
+ readWorkshopDocument(root, path.posix.join("drafts", slug, "notes.md")),
92
+ readWorkshopDocument(root, path.posix.join("drafts", slug, "promoted.md")),
93
+ ]);
94
+
95
+ return {
96
+ slug,
97
+ title: chapter.metadata.title,
98
+ summary: chapter.metadata.summary ?? "Draft chapter in progress.",
99
+ bodyHtml: await toHtml(chapter.body || "No chapter draft body yet."),
100
+ paragraphs: chapter.paragraphs.map((paragraph) => ({
101
+ slug: path.basename(paragraph.path, ".md"),
102
+ title: paragraph.metadata.title,
103
+ summary: paragraph.metadata.summary ?? "Draft scene.",
104
+ })),
105
+ ideas,
106
+ notes,
107
+ promoted,
108
+ } satisfies WorkshopDraftChapter;
109
+ }),
110
+ );
111
+
112
+ return chapters.filter((chapter): chapter is WorkshopDraftChapter => Boolean(chapter));
113
+ }
114
+
115
+ async function readWorkshopDocument(root: string, relativePath: string): Promise<WorkshopDocument | null> {
116
+ const absolutePath = path.join(root, relativePath);
117
+ const raw = await readFile(absolutePath, "utf8").catch(() => null);
118
+ if (!raw) {
119
+ return null;
120
+ }
121
+
122
+ const document = parseNarrariumMarkdownDocument(relativePath, raw);
123
+ const frontmatter = document.frontmatter as Record<string, unknown>;
124
+ const title = typeof frontmatter.title === "string" && frontmatter.title.trim() ? frontmatter.title : relativePath;
125
+ const bucket = typeof frontmatter.bucket === "string" ? frontmatter.bucket : document.kind;
126
+
127
+ return {
128
+ path: relativePath,
129
+ title,
130
+ bucket,
131
+ bodyHtml: await toHtml(document.body || "No content yet."),
132
+ entries: readWorkshopEntries(frontmatter),
133
+ };
134
+ }
135
+
136
+ function readWorkshopEntries(frontmatter: Record<string, unknown>): WorkshopEntry[] {
137
+ if (!Array.isArray(frontmatter.entries)) {
138
+ return [];
139
+ }
140
+
141
+ return frontmatter.entries
142
+ .filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object"))
143
+ .map((entry) => ({
144
+ id: typeof entry.id === "string" ? entry.id : "",
145
+ title: typeof entry.title === "string" ? entry.title : "Untitled",
146
+ body: typeof entry.body === "string" ? entry.body : "",
147
+ status: typeof entry.status === "string" ? entry.status : "active",
148
+ tags: Array.isArray(entry.tags) ? entry.tags.filter((tag): tag is string => typeof tag === "string") : [],
149
+ sourceKind: typeof entry.source_kind === "string" ? entry.source_kind : undefined,
150
+ promotedTo: typeof entry.promoted_to === "string" ? entry.promoted_to : undefined,
151
+ }))
152
+ .filter((entry) => entry.id.length > 0)
153
+ .sort((left, right) => left.title.localeCompare(right.title));
154
+ }
155
+
156
+ async function toHtml(markdown: string): Promise<string> {
157
+ const rendered = await marked.parse(markdown);
158
+ return typeof rendered === "string" ? rendered : String(rendered);
159
+ }
@@ -6,7 +6,7 @@ import { loadAssetFigure } from "../lib/assets";
6
6
  import { countPublicCanonEntries } from "../lib/public-canon";
7
7
  import { isFullCanonMode } from "../lib/reader-mode";
8
8
 
9
- const { ready, root, book, chapters, characters, locations, factions, items, secrets, timelineEvents } = await loadHomePageData();
9
+ const { ready, root, book, chapters, draftChapterCount, characters, locations, factions, items, secrets, timelineEvents } = await loadHomePageData();
10
10
  const title = book?.frontmatter.title ?? "Narrarium Reader";
11
11
  const coverFigure = await loadAssetFigure("book", `${title} cover`);
12
12
  const fullCanonMode = isFullCanonMode();
@@ -129,6 +129,16 @@ const chapterCards = await Promise.all(
129
129
  <div class="chip-row"><span class="chip">{visibleTimelineCount} visible</span></div>
130
130
  </a>
131
131
  </article>
132
+ {fullCanonMode && (
133
+ <article class="catalog-card">
134
+ <a href="workshop/">
135
+ <span class="browse-card-icon">🧭</span>
136
+ <h3 class="chapter-title">Workshop</h3>
137
+ <div class="chapter-meta">Inspect drafts, ideas, notes, story design, and promoted items.</div>
138
+ <div class="chip-row"><span class="chip">{draftChapterCount} draft chapters</span></div>
139
+ </a>
140
+ </article>
141
+ )}
132
142
  </div>
133
143
  </section>
134
144
 
@@ -0,0 +1,138 @@
1
+ ---
2
+ import BaseLayout from "../../layouts/BaseLayout.astro";
3
+ import { isFullCanonMode } from "../../lib/reader-mode";
4
+ import { loadWorkshopPageData } from "../../lib/workshop";
5
+
6
+ const fullCanonMode = isFullCanonMode();
7
+ const { ready, global, draftChapters } = await loadWorkshopPageData();
8
+
9
+ const globalDocuments = global
10
+ ? [
11
+ { label: "Context", document: global.context },
12
+ { label: "Story Design", document: global.storyDesign },
13
+ { label: "Ideas", document: global.ideas },
14
+ { label: "Notes", document: global.notes },
15
+ { label: "Promoted", document: global.promoted },
16
+ ]
17
+ : [];
18
+ ---
19
+
20
+ <BaseLayout title="Workshop" description="Inspect drafts, ideas, notes, and story design." activeSection="workshop">
21
+ <section class="hero">
22
+ <p class="eyebrow">Workshop</p>
23
+ <h1>Drafts, Ideas, and Notes</h1>
24
+ <p class="lede">
25
+ {fullCanonMode
26
+ ? "Review global planning material, draft chapters, and promoted work items from one author-facing workspace."
27
+ : "Workshop content is available only in full canon mode."}
28
+ </p>
29
+ </section>
30
+
31
+ {!ready ? (
32
+ <div class="empty">No Narrarium book repository is connected yet.</div>
33
+ ) : !fullCanonMode ? (
34
+ <div class="empty">Switch the reader to full canon mode to inspect workshop material such as drafts, ideas, notes, and story design.</div>
35
+ ) : (
36
+ <>
37
+ <section class="section">
38
+ <h2>Global Workspace</h2>
39
+ <div class="chapter-list">
40
+ {globalDocuments.map(({ label, document }) => (
41
+ <article class="chapter-card workspace-card">
42
+ <div class="chapter-number">{label}</div>
43
+ <h3 class="chapter-title">{document?.title ?? `${label} unavailable`}</h3>
44
+ <div class="chapter-meta">{document ? document.path : "File not present in this book yet."}</div>
45
+ {document ? (
46
+ <>
47
+ <div class="chip-row"><span class="chip">{document.entries.length} entries</span></div>
48
+ <div class="prose" set:html={document.bodyHtml}></div>
49
+ {document.entries.length > 0 && (
50
+ <div class="workspace-entry-list">
51
+ {document.entries.map((entry) => (
52
+ <article class="workspace-entry">
53
+ <div class="chip-row">
54
+ <span class="chip">{entry.status}</span>
55
+ {entry.tags.map((tag) => (
56
+ <span class="chip">{tag}</span>
57
+ ))}
58
+ {entry.promotedTo && <span class="chip">to {entry.promotedTo}</span>}
59
+ </div>
60
+ <h4>{entry.title}</h4>
61
+ <p>{entry.body || "No details yet."}</p>
62
+ </article>
63
+ ))}
64
+ </div>
65
+ )}
66
+ </>
67
+ ) : null}
68
+ </article>
69
+ ))}
70
+ </div>
71
+ </section>
72
+
73
+ <section class="section">
74
+ <h2>Draft Chapters</h2>
75
+ {draftChapters.length > 0 ? (
76
+ <div class="chapter-list">
77
+ {draftChapters.map((chapter) => (
78
+ <article class="chapter-card workspace-card">
79
+ <div class="chapter-number">Draft</div>
80
+ <h3 class="chapter-title">{chapter.title}</h3>
81
+ <div class="chapter-meta">{chapter.summary}</div>
82
+ <div class="chip-row">
83
+ <span class="chip">{chapter.paragraphs.length} scenes</span>
84
+ <span class="chip">{chapter.ideas?.entries.length ?? 0} ideas</span>
85
+ <span class="chip">{chapter.notes?.entries.length ?? 0} notes</span>
86
+ <span class="chip">{chapter.promoted?.entries.length ?? 0} promoted</span>
87
+ </div>
88
+ <div class="prose" set:html={chapter.bodyHtml}></div>
89
+ {chapter.paragraphs.length > 0 && (
90
+ <div class="workspace-subsection">
91
+ <h4>Draft Scenes</h4>
92
+ <div class="workspace-entry-list">
93
+ {chapter.paragraphs.map((paragraph) => (
94
+ <article class="workspace-entry">
95
+ <h5>{paragraph.title}</h5>
96
+ <p>{paragraph.summary}</p>
97
+ </article>
98
+ ))}
99
+ </div>
100
+ </div>
101
+ )}
102
+
103
+ {[chapter.ideas, chapter.notes, chapter.promoted].map(
104
+ (document, index) =>
105
+ document && (
106
+ <div class="workspace-subsection">
107
+ <h4>{index === 0 ? "Ideas" : index === 1 ? "Notes" : "Promoted"}</h4>
108
+ <div class="prose" set:html={document.bodyHtml}></div>
109
+ {document.entries.length > 0 && (
110
+ <div class="workspace-entry-list">
111
+ {document.entries.map((entry) => (
112
+ <article class="workspace-entry">
113
+ <div class="chip-row">
114
+ <span class="chip">{entry.status}</span>
115
+ {entry.tags.map((tag) => (
116
+ <span class="chip">{tag}</span>
117
+ ))}
118
+ {entry.promotedTo && <span class="chip">to {entry.promotedTo}</span>}
119
+ </div>
120
+ <h5>{entry.title}</h5>
121
+ <p>{entry.body || "No details yet."}</p>
122
+ </article>
123
+ ))}
124
+ </div>
125
+ )}
126
+ </div>
127
+ ),
128
+ )}
129
+ </article>
130
+ ))}
131
+ </div>
132
+ ) : (
133
+ <div class="empty">No chapter drafts exist yet.</div>
134
+ )}
135
+ </section>
136
+ </>
137
+ )}
138
+ </BaseLayout>
@@ -248,6 +248,42 @@ body.tts-enabled .shell {
248
248
  flex-wrap: wrap;
249
249
  }
250
250
 
251
+ .workspace-card {
252
+ width: 100%;
253
+ }
254
+
255
+ .workspace-entry-list {
256
+ display: grid;
257
+ gap: 0.85rem;
258
+ margin-top: 1rem;
259
+ }
260
+
261
+ .workspace-entry {
262
+ padding: 1rem 1.1rem;
263
+ border: 1px solid var(--line);
264
+ border-radius: 14px;
265
+ background: var(--surface-muted);
266
+ }
267
+
268
+ .workspace-entry h4,
269
+ .workspace-entry h5 {
270
+ margin: 0.35rem 0 0.45rem;
271
+ font-size: 1rem;
272
+ }
273
+
274
+ .workspace-entry p {
275
+ margin: 0;
276
+ color: var(--text-muted);
277
+ }
278
+
279
+ .workspace-subsection {
280
+ margin-top: 1.4rem;
281
+ }
282
+
283
+ .workspace-subsection h4 {
284
+ margin: 0 0 0.75rem;
285
+ }
286
+
251
287
  /* ─── Icon buttons (masthead controls) ──────────────────────── */
252
288
  .masthead-btn {
253
289
  display: inline-flex;