narrarium-astro-reader 0.1.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.
Files changed (67) hide show
  1. package/README.md +52 -0
  2. package/astro.config.mjs +12 -0
  3. package/cli-dist/cli.d.ts +3 -0
  4. package/cli-dist/cli.d.ts.map +1 -0
  5. package/cli-dist/cli.js +78 -0
  6. package/cli-dist/cli.js.map +1 -0
  7. package/cli-dist/lib/assets.d.ts +8 -0
  8. package/cli-dist/lib/assets.d.ts.map +1 -0
  9. package/cli-dist/lib/assets.js +35 -0
  10. package/cli-dist/lib/assets.js.map +1 -0
  11. package/cli-dist/lib/book-config.d.ts +2 -0
  12. package/cli-dist/lib/book-config.d.ts.map +1 -0
  13. package/cli-dist/lib/book-config.js +2 -0
  14. package/cli-dist/lib/book-config.js.map +1 -0
  15. package/cli-dist/lib/book.d.ts +103 -0
  16. package/cli-dist/lib/book.d.ts.map +1 -0
  17. package/cli-dist/lib/book.js +89 -0
  18. package/cli-dist/lib/book.js.map +1 -0
  19. package/cli-dist/lib/canon.d.ts +16 -0
  20. package/cli-dist/lib/canon.d.ts.map +1 -0
  21. package/cli-dist/lib/canon.js +164 -0
  22. package/cli-dist/lib/canon.js.map +1 -0
  23. package/cli-dist/lib/glossary.d.ts +25 -0
  24. package/cli-dist/lib/glossary.d.ts.map +1 -0
  25. package/cli-dist/lib/glossary.js +195 -0
  26. package/cli-dist/lib/glossary.js.map +1 -0
  27. package/cli-dist/lib/search.d.ts +9 -0
  28. package/cli-dist/lib/search.d.ts.map +1 -0
  29. package/cli-dist/lib/search.js +55 -0
  30. package/cli-dist/lib/search.js.map +1 -0
  31. package/cli-dist/scaffold.d.ts +14 -0
  32. package/cli-dist/scaffold.d.ts.map +1 -0
  33. package/cli-dist/scaffold.js +190 -0
  34. package/cli-dist/scaffold.js.map +1 -0
  35. package/package.json +58 -0
  36. package/scripts/export-epub.mjs +13 -0
  37. package/src/cli.ts +96 -0
  38. package/src/components/AssetFigure.astro +17 -0
  39. package/src/components/ChapterPager.astro +40 -0
  40. package/src/components/LinkedValue.astro +18 -0
  41. package/src/components/MetadataSection.astro +22 -0
  42. package/src/components/ReaderRuntime.astro +310 -0
  43. package/src/components/RelatedLinks.astro +19 -0
  44. package/src/components/SiteSearch.astro +91 -0
  45. package/src/layouts/BaseLayout.astro +676 -0
  46. package/src/lib/assets.ts +44 -0
  47. package/src/lib/book-config.ts +1 -0
  48. package/src/lib/book.ts +116 -0
  49. package/src/lib/canon.ts +212 -0
  50. package/src/lib/glossary.ts +247 -0
  51. package/src/lib/search.ts +74 -0
  52. package/src/pages/chapters/[chapter].astro +102 -0
  53. package/src/pages/characters/[slug].astro +65 -0
  54. package/src/pages/characters/index.astro +45 -0
  55. package/src/pages/factions/[slug].astro +64 -0
  56. package/src/pages/factions/index.astro +45 -0
  57. package/src/pages/index.astro +129 -0
  58. package/src/pages/items/[slug].astro +63 -0
  59. package/src/pages/items/index.astro +45 -0
  60. package/src/pages/locations/[slug].astro +61 -0
  61. package/src/pages/locations/index.astro +45 -0
  62. package/src/pages/secrets/[slug].astro +67 -0
  63. package/src/pages/secrets/index.astro +46 -0
  64. package/src/pages/timeline/[slug].astro +58 -0
  65. package/src/pages/timeline/index.astro +52 -0
  66. package/src/scaffold.ts +232 -0
  67. package/tsconfig.json +6 -0
@@ -0,0 +1,116 @@
1
+ import path from "node:path";
2
+ import {
3
+ listChapters,
4
+ listEntities,
5
+ pathExists,
6
+ readBook,
7
+ readChapter,
8
+ readEntity,
9
+ readTimelineMain,
10
+ } from "narrarium";
11
+ import { defaultBookRoot } from "./book-config.js";
12
+
13
+ type ReaderEntityKind =
14
+ | "character"
15
+ | "location"
16
+ | "faction"
17
+ | "item"
18
+ | "secret"
19
+ | "timeline-event";
20
+
21
+ export function getBookRoot(): string {
22
+ const configured = process.env.NARRARIUM_BOOK_ROOT ?? process.env.GHOSTWRITER_BOOK_ROOT;
23
+ if (configured) return path.resolve(configured);
24
+ return path.resolve(process.cwd(), defaultBookRoot);
25
+ }
26
+
27
+ export async function loadHomePageData() {
28
+ const root = getBookRoot();
29
+ const hasBook = await pathExists(path.join(root, "book.md"));
30
+
31
+ if (!hasBook) {
32
+ return {
33
+ ready: false,
34
+ root,
35
+ book: null,
36
+ chapters: [],
37
+ characters: [],
38
+ locations: [],
39
+ factions: [],
40
+ items: [],
41
+ secrets: [],
42
+ timelineEvents: [],
43
+ };
44
+ }
45
+
46
+ const [book, chapters, characters, locations, factions, items, secrets, timelineEvents] = await Promise.all([
47
+ readBook(root),
48
+ listChapters(root),
49
+ listEntities(root, "character"),
50
+ listEntities(root, "location"),
51
+ listEntities(root, "faction"),
52
+ listEntities(root, "item"),
53
+ listEntities(root, "secret"),
54
+ listEntities(root, "timeline-event"),
55
+ ]);
56
+
57
+ return {
58
+ ready: true,
59
+ root,
60
+ book,
61
+ chapters,
62
+ characters,
63
+ locations,
64
+ factions,
65
+ items,
66
+ secrets,
67
+ timelineEvents,
68
+ };
69
+ }
70
+
71
+ export async function loadChapterPageData(chapterSlug: string) {
72
+ const root = getBookRoot();
73
+ return readChapter(root, chapterSlug);
74
+ }
75
+
76
+ export async function loadEntityIndexData(kind: ReaderEntityKind) {
77
+ const root = getBookRoot();
78
+ const ready = await pathExists(path.join(root, "book.md"));
79
+
80
+ if (!ready) {
81
+ return { ready: false, root, entities: [] };
82
+ }
83
+
84
+ return {
85
+ ready: true,
86
+ root,
87
+ entities: await listEntities(root, kind),
88
+ };
89
+ }
90
+
91
+ export async function loadEntityPageData(kind: ReaderEntityKind, slug: string) {
92
+ const root = getBookRoot();
93
+ return readEntity(root, kind, slug);
94
+ }
95
+
96
+ export async function loadTimelinePageData() {
97
+ const root = getBookRoot();
98
+ const ready = await pathExists(path.join(root, "book.md"));
99
+
100
+ if (!ready) {
101
+ return {
102
+ ready: false,
103
+ root,
104
+ main: null,
105
+ events: [],
106
+ };
107
+ }
108
+
109
+ const [main, events] = await Promise.all([readTimelineMain(root), listEntities(root, "timeline-event")]);
110
+ return {
111
+ ready: true,
112
+ root,
113
+ main,
114
+ events,
115
+ };
116
+ }
@@ -0,0 +1,212 @@
1
+ import path from "node:path";
2
+ import { listChapters, listEntities, listRelatedCanon, readChapter, toPosixPath } from "narrarium";
3
+ import { getBookRoot } from "./book.js";
4
+
5
+ type ReaderEntityKind =
6
+ | "character"
7
+ | "location"
8
+ | "faction"
9
+ | "item"
10
+ | "secret"
11
+ | "timeline-event";
12
+
13
+ export type CanonLink = {
14
+ href: string;
15
+ label: string;
16
+ kind: string;
17
+ };
18
+
19
+ export type ValuePart =
20
+ | { text: string; href?: undefined }
21
+ | { text: string; href: string };
22
+
23
+ type ReferenceIndex = Map<string, CanonLink>;
24
+
25
+ const entityKinds: ReaderEntityKind[] = ["character", "location", "faction", "item", "secret", "timeline-event"];
26
+
27
+ let referenceIndexPromise: Promise<ReferenceIndex> | null = null;
28
+
29
+ export async function resolveValueParts(value: unknown): Promise<ValuePart[]> {
30
+ const strings = Array.isArray(value) ? value : [value];
31
+ const parts: ValuePart[] = [];
32
+
33
+ for (const entry of strings) {
34
+ if (entry === undefined || entry === null || entry === "") continue;
35
+ if (parts.length > 0) parts.push({ text: ", " });
36
+
37
+ if (typeof entry === "string") {
38
+ const link = await resolveReference(entry);
39
+ if (link) {
40
+ parts.push({ text: link.label, href: link.href });
41
+ continue;
42
+ }
43
+ }
44
+
45
+ parts.push({ text: String(entry) });
46
+ }
47
+
48
+ return parts;
49
+ }
50
+
51
+ export async function loadRelatedCanonLinks(id: string, values: unknown): Promise<CanonLink[]> {
52
+ const explicitRefs = collectReferenceStrings(values);
53
+ const [resolvedRefs, relatedHits] = await Promise.all([
54
+ Promise.all(explicitRefs.map((value) => resolveReference(value))),
55
+ id ? listRelatedCanon(getBookRoot(), id, { limit: 8 }) : Promise.resolve([]),
56
+ ]);
57
+
58
+ const links: CanonLink[] = [];
59
+ const seen = new Set<string>();
60
+
61
+ for (const link of resolvedRefs) {
62
+ if (!link || seen.has(link.href)) continue;
63
+ seen.add(link.href);
64
+ links.push(link);
65
+ }
66
+
67
+ for (const hit of relatedHits) {
68
+ const href = resolveContentPathToHref(hit.path);
69
+ if (!href || seen.has(href)) continue;
70
+ seen.add(href);
71
+ links.push({ href, label: hit.title, kind: hit.type });
72
+ }
73
+
74
+ return links;
75
+ }
76
+
77
+ export async function resolveReference(value: string): Promise<CanonLink | null> {
78
+ const normalized = value.trim();
79
+ if (!normalized) return null;
80
+
81
+ const index = await loadReferenceIndex();
82
+ const exact = index.get(normalized.toLowerCase());
83
+ if (exact) return exact;
84
+
85
+ return buildFallbackReference(normalized);
86
+ }
87
+
88
+ function collectReferenceStrings(value: unknown): string[] {
89
+ if (Array.isArray(value)) {
90
+ return value.flatMap((entry) => collectReferenceStrings(entry));
91
+ }
92
+
93
+ if (typeof value !== "string") return [];
94
+
95
+ return isSupportedReference(value) ? [value] : [];
96
+ }
97
+
98
+ async function loadReferenceIndex(): Promise<ReferenceIndex> {
99
+ referenceIndexPromise ??= buildReferenceIndex();
100
+ return referenceIndexPromise;
101
+ }
102
+
103
+ async function buildReferenceIndex(): Promise<ReferenceIndex> {
104
+ const index: ReferenceIndex = new Map();
105
+ const root = getBookRoot();
106
+ const entitiesByKind = await Promise.all(entityKinds.map((kind) => listEntities(root, kind)));
107
+
108
+ entitiesByKind.forEach((entities, offset) => {
109
+ const kind = entityKinds[offset];
110
+
111
+ for (const entity of entities) {
112
+ const label = String(entity.metadata.name ?? entity.metadata.title ?? entity.slug);
113
+ const href = entityHref(kind, entity.slug);
114
+ const id = String(entity.metadata.id ?? `${kind}:${entity.slug}`);
115
+ setReference(index, id, { href, label, kind });
116
+ setReference(index, `${kind}:${entity.slug}`, { href, label, kind });
117
+ }
118
+ });
119
+
120
+ const chapters = await listChapters(root);
121
+ for (const chapter of chapters) {
122
+ const chapterId = String(chapter.metadata.id ?? `chapter:${chapter.slug}`);
123
+ const chapterLink = { href: `chapters/${chapter.slug}/`, label: chapter.metadata.title, kind: "chapter" };
124
+ setReference(index, chapterId, chapterLink);
125
+ setReference(index, `chapter:${chapter.slug}`, chapterLink);
126
+
127
+ const chapterData = await readChapter(root, chapter.slug);
128
+ for (const paragraph of chapterData.paragraphs) {
129
+ const paragraphSlug = path.basename(paragraph.path, ".md");
130
+ const href = `chapters/${chapter.slug}/#scene-${paragraphSlug}`;
131
+ const label = paragraph.metadata.title;
132
+ setReference(index, String(paragraph.metadata.id), { href, label, kind: "paragraph" });
133
+ setReference(index, `paragraph:${chapter.slug}:${paragraphSlug}`, { href, label, kind: "paragraph" });
134
+ }
135
+ }
136
+
137
+ return index;
138
+ }
139
+
140
+ function setReference(index: ReferenceIndex, key: string, link: CanonLink) {
141
+ index.set(key.toLowerCase(), link);
142
+ }
143
+
144
+ function entityHref(kind: ReaderEntityKind, slug: string): string {
145
+ switch (kind) {
146
+ case "timeline-event":
147
+ return `timeline/${slug}/`;
148
+ default:
149
+ return `${kind}s/${slug}/`;
150
+ }
151
+ }
152
+
153
+ function resolveContentPathToHref(filePath: string): string | null {
154
+ const normalized = toPosixPath(filePath);
155
+
156
+ const entityMatch = normalized.match(/^(characters|locations|factions|items|secrets|timelines\/events)\/([^/]+)\.md$/);
157
+ if (entityMatch) {
158
+ const section = entityMatch[1] === "timelines/events" ? "timeline" : entityMatch[1];
159
+ return `${section}/${entityMatch[2]}/`;
160
+ }
161
+
162
+ const chapterMatch = normalized.match(/^chapters\/([^/]+)\/chapter\.md$/);
163
+ if (chapterMatch) {
164
+ return `chapters/${chapterMatch[1]}/`;
165
+ }
166
+
167
+ const paragraphMatch = normalized.match(/^chapters\/([^/]+)\/([^/]+)\.md$/);
168
+ if (paragraphMatch) {
169
+ return `chapters/${paragraphMatch[1]}/#scene-${paragraphMatch[2]}`;
170
+ }
171
+
172
+ return null;
173
+ }
174
+
175
+ function buildFallbackReference(value: string): CanonLink | null {
176
+ if (value.startsWith("chapter:")) {
177
+ const slug = value.slice("chapter:".length);
178
+ return { href: `chapters/${slug}/`, label: humanizeSlug(slug), kind: "chapter" };
179
+ }
180
+
181
+ if (value.startsWith("paragraph:")) {
182
+ const [, chapterSlug, paragraphSlug] = value.split(":");
183
+ if (!chapterSlug || !paragraphSlug) return null;
184
+ return {
185
+ href: `chapters/${chapterSlug}/#scene-${paragraphSlug}`,
186
+ label: humanizeSlug(paragraphSlug),
187
+ kind: "paragraph",
188
+ };
189
+ }
190
+
191
+ for (const kind of entityKinds) {
192
+ const prefix = `${kind}:`;
193
+ if (!value.startsWith(prefix)) continue;
194
+ const slug = value.slice(prefix.length);
195
+ return { href: entityHref(kind, slug), label: humanizeSlug(slug), kind };
196
+ }
197
+
198
+ return null;
199
+ }
200
+
201
+ function isSupportedReference(value: string): boolean {
202
+ return value.startsWith("chapter:") || value.startsWith("paragraph:") || entityKinds.some((kind) => value.startsWith(`${kind}:`));
203
+ }
204
+
205
+ function humanizeSlug(value: string): string {
206
+ return value
207
+ .replace(/^[0-9]+-/, "")
208
+ .split("-")
209
+ .filter(Boolean)
210
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
211
+ .join(" ");
212
+ }
@@ -0,0 +1,247 @@
1
+ import { listEntities, listRelatedCanon, readEntity, toPosixPath } from "narrarium";
2
+ import { marked } from "marked";
3
+ import { loadAssetFigure } from "./assets.js";
4
+ import { getBookRoot } from "./book.js";
5
+
6
+ type ReaderEntityKind =
7
+ | "character"
8
+ | "location"
9
+ | "faction"
10
+ | "item"
11
+ | "secret"
12
+ | "timeline-event";
13
+
14
+ export type GlossaryEntry = {
15
+ id: string;
16
+ kind: ReaderEntityKind;
17
+ kindLabel: string;
18
+ label: string;
19
+ href: string;
20
+ terms: string[];
21
+ summary: string;
22
+ meta: string[];
23
+ metadataEntries: Array<{ label: string; value: string }>;
24
+ mentions: Array<{ label: string; href: string }>;
25
+ bodyHtml?: string;
26
+ imageSrc?: string;
27
+ imageAlt?: string;
28
+ };
29
+
30
+ const entityKinds: ReaderEntityKind[] = ["character", "location", "faction", "item", "secret", "timeline-event"];
31
+
32
+ let glossaryPromise: Promise<GlossaryEntry[]> | null = null;
33
+
34
+ export async function loadCanonGlossary(): Promise<GlossaryEntry[]> {
35
+ glossaryPromise ??= buildCanonGlossary();
36
+ return glossaryPromise;
37
+ }
38
+
39
+ async function buildCanonGlossary(): Promise<GlossaryEntry[]> {
40
+ const root = getBookRoot();
41
+ const groups = await Promise.all(entityKinds.map((kind) => listEntities(root, kind)));
42
+ const entries = await Promise.all(
43
+ groups.flatMap((entities, offset) =>
44
+ entities.map(async (entity) => {
45
+ const kind = entityKinds[offset];
46
+ const id = String(entity.metadata.id ?? `${kind}:${entity.slug}`);
47
+ const label = String(entity.metadata.name ?? entity.metadata.title ?? entity.slug);
48
+ const fullEntry = await readEntity(root, kind, entity.slug);
49
+ const related = await listRelatedCanon(root, id, { limit: 8 });
50
+ const figure = await loadAssetFigure(id, label);
51
+
52
+ return {
53
+ id,
54
+ kind,
55
+ kindLabel: kindLabel(kind),
56
+ label,
57
+ href: entityHref(kind, entity.slug),
58
+ terms: uniqueStrings([label, ...readAliases(entity.metadata.aliases)]),
59
+ summary: summaryFor(kind, entity.metadata),
60
+ meta: metaFor(kind, entity.metadata),
61
+ metadataEntries: metadataEntriesFor(kind, entity.metadata),
62
+ mentions: mentionLinksFor(related),
63
+ bodyHtml: fullEntry.body ? await marked.parse(fullEntry.body) : undefined,
64
+ imageSrc: figure?.src,
65
+ imageAlt: figure?.alt,
66
+ } satisfies GlossaryEntry;
67
+ }),
68
+ ),
69
+ );
70
+
71
+ return entries.sort((left, right) => left.label.localeCompare(right.label));
72
+ }
73
+
74
+ function mentionLinksFor(hits: Array<{ path: string; title: string }>): Array<{ label: string; href: string }> {
75
+ const seen = new Set<string>();
76
+ const links: Array<{ label: string; href: string }> = [];
77
+
78
+ for (const hit of hits) {
79
+ const href = readerHrefFromPath(hit.path);
80
+ if (!href || seen.has(href)) continue;
81
+ seen.add(href);
82
+ links.push({ label: hit.title, href });
83
+ }
84
+
85
+ return links;
86
+ }
87
+
88
+ function readerHrefFromPath(filePath: string): string | null {
89
+ const normalized = toPosixPath(filePath);
90
+ const chapterMatch = normalized.match(/^chapters\/([^/]+)\/chapter\.md$/);
91
+ if (chapterMatch) {
92
+ return `chapters/${chapterMatch[1]}/`;
93
+ }
94
+
95
+ const paragraphMatch = normalized.match(/^chapters\/([^/]+)\/([^/]+)\.md$/);
96
+ if (paragraphMatch && paragraphMatch[2] !== "chapter") {
97
+ return `chapters/${paragraphMatch[1]}/#scene-${paragraphMatch[2]}`;
98
+ }
99
+
100
+ return null;
101
+ }
102
+
103
+ function entityHref(kind: ReaderEntityKind, slug: string): string {
104
+ switch (kind) {
105
+ case "timeline-event":
106
+ return `timeline/${slug}/`;
107
+ default:
108
+ return `${kind}s/${slug}/`;
109
+ }
110
+ }
111
+
112
+ function kindLabel(kind: ReaderEntityKind): string {
113
+ switch (kind) {
114
+ case "timeline-event":
115
+ return "Timeline Event";
116
+ default:
117
+ return kind.charAt(0).toUpperCase() + kind.slice(1);
118
+ }
119
+ }
120
+
121
+ function summaryFor(kind: ReaderEntityKind, metadata: Record<string, unknown>): string {
122
+ switch (kind) {
123
+ case "character":
124
+ return String(metadata.function_in_book ?? metadata.story_role ?? metadata.background_summary ?? "Canonical character entry.");
125
+ case "location":
126
+ return String(metadata.atmosphere ?? metadata.function_in_book ?? "Canonical location entry.");
127
+ case "faction":
128
+ return String(metadata.function_in_book ?? metadata.mission ?? metadata.ideology ?? "Canonical faction entry.");
129
+ case "item":
130
+ return String(metadata.function_in_book ?? metadata.purpose ?? metadata.significance ?? "Canonical item entry.");
131
+ case "secret":
132
+ return String(metadata.function_in_book ?? metadata.stakes ?? metadata.reveal_strategy ?? "Canonical secret entry.");
133
+ case "timeline-event":
134
+ return String(metadata.significance ?? metadata.function_in_book ?? "Canonical timeline event.");
135
+ }
136
+ }
137
+
138
+ function metaFor(kind: ReaderEntityKind, metadata: Record<string, unknown>): string[] {
139
+ switch (kind) {
140
+ case "character":
141
+ return compactStrings([metadata.role_tier, metadata.story_role, metadata.home_location]);
142
+ case "location":
143
+ return compactStrings([metadata.location_kind, metadata.region, metadata.timeline_ref]);
144
+ case "faction":
145
+ return compactStrings([metadata.faction_kind, metadata.base_location, metadata.public_image]);
146
+ case "item":
147
+ return compactStrings([metadata.item_kind, metadata.owner, metadata.introduced_in]);
148
+ case "secret":
149
+ return compactStrings([metadata.secret_kind, metadata.reveal_in, metadata.known_from]);
150
+ case "timeline-event":
151
+ return compactStrings([metadata.date, metadata.function_in_book]);
152
+ }
153
+ }
154
+
155
+ function metadataEntriesFor(kind: ReaderEntityKind, metadata: Record<string, unknown>): Array<{ label: string; value: string }> {
156
+ switch (kind) {
157
+ case "character":
158
+ return compactEntries([
159
+ ["Role tier", metadata.role_tier],
160
+ ["Story role", metadata.story_role],
161
+ ["Occupation", metadata.occupation],
162
+ ["Origin", metadata.origin],
163
+ ["Home", metadata.home_location],
164
+ ["Introduced in", metadata.introduced_in],
165
+ ]);
166
+ case "location":
167
+ return compactEntries([
168
+ ["Kind", metadata.location_kind],
169
+ ["Region", metadata.region],
170
+ ["Timeline", metadata.timeline_ref],
171
+ ["Real world basis", metadata.based_on_real_place],
172
+ ]);
173
+ case "faction":
174
+ return compactEntries([
175
+ ["Kind", metadata.faction_kind],
176
+ ["Base", metadata.base_location],
177
+ ["Public image", metadata.public_image],
178
+ ["Historical", metadata.historical],
179
+ ]);
180
+ case "item":
181
+ return compactEntries([
182
+ ["Kind", metadata.item_kind],
183
+ ["Owner", metadata.owner],
184
+ ["Introduced in", metadata.introduced_in],
185
+ ["Significance", metadata.significance],
186
+ ]);
187
+ case "secret":
188
+ return compactEntries([
189
+ ["Kind", metadata.secret_kind],
190
+ ["Reveal in", metadata.reveal_in],
191
+ ["Known from", metadata.known_from],
192
+ ["Holders", metadata.holders],
193
+ ]);
194
+ case "timeline-event":
195
+ return compactEntries([
196
+ ["Date", metadata.date],
197
+ ["Participants", metadata.participants],
198
+ ["Function", metadata.function_in_book],
199
+ ["Consequences", metadata.consequences],
200
+ ]);
201
+ }
202
+ }
203
+
204
+ function compactStrings(values: unknown[]): string[] {
205
+ return values
206
+ .map((value) => (typeof value === "string" ? value.trim() : ""))
207
+ .filter(Boolean);
208
+ }
209
+
210
+ function compactEntries(entries: Array<[string, unknown]>): Array<{ label: string; value: string }> {
211
+ return entries.flatMap(([label, value]) => {
212
+ const normalized = normalizeEntryValue(value);
213
+ return normalized ? [{ label, value: normalized }] : [];
214
+ });
215
+ }
216
+
217
+ function normalizeEntryValue(value: unknown): string {
218
+ if (Array.isArray(value)) {
219
+ const normalized = value
220
+ .map((entry) => normalizeEntryValue(entry))
221
+ .filter(Boolean)
222
+ .join(", ");
223
+ return normalized;
224
+ }
225
+
226
+ if (typeof value === "boolean") {
227
+ return value ? "Yes" : "No";
228
+ }
229
+
230
+ if (value === undefined || value === null) {
231
+ return "";
232
+ }
233
+
234
+ return String(value).trim();
235
+ }
236
+
237
+ function readAliases(value: unknown): string[] {
238
+ if (!Array.isArray(value)) {
239
+ return [];
240
+ }
241
+
242
+ return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
243
+ }
244
+
245
+ function uniqueStrings(values: string[]): string[] {
246
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
247
+ }
@@ -0,0 +1,74 @@
1
+ import path from "node:path";
2
+ import { listChapters, readChapter } from "narrarium";
3
+ import { getBookRoot } from "./book.js";
4
+ import { loadCanonGlossary } from "./glossary.js";
5
+
6
+ export type SearchEntry = {
7
+ title: string;
8
+ href: string;
9
+ kind: string;
10
+ summary: string;
11
+ keywords: string[];
12
+ };
13
+
14
+ let searchIndexPromise: Promise<SearchEntry[]> | null = null;
15
+
16
+ export async function loadSearchIndex(): Promise<SearchEntry[]> {
17
+ searchIndexPromise ??= buildSearchIndex();
18
+ return searchIndexPromise;
19
+ }
20
+
21
+ async function buildSearchIndex(): Promise<SearchEntry[]> {
22
+ const root = getBookRoot();
23
+ const glossary = await loadCanonGlossary();
24
+ const chapters = await listChapters(root);
25
+
26
+ const chapterEntries = await Promise.all(
27
+ chapters.flatMap(async (chapter) => {
28
+ const chapterData = await readChapter(root, chapter.slug);
29
+ const chapterEntry: SearchEntry = {
30
+ title: chapter.metadata.title,
31
+ href: `chapters/${chapter.slug}/`,
32
+ kind: "Chapter",
33
+ summary: String(chapter.metadata.summary ?? "Read this chapter."),
34
+ keywords: compactStrings([chapter.metadata.summary, ...(chapter.metadata.tags ?? []), ...(chapter.metadata.pov ?? [])]),
35
+ };
36
+
37
+ const sceneEntries = chapterData.paragraphs.map((paragraph) => ({
38
+ title: paragraph.metadata.title,
39
+ href: `chapters/${chapter.slug}/#scene-${path.basename(paragraph.path, ".md")}`,
40
+ kind: "Scene",
41
+ summary: String(paragraph.metadata.summary ?? `Scene in ${chapter.metadata.title}.`),
42
+ keywords: compactStrings([chapter.metadata.title, paragraph.metadata.viewpoint, paragraph.metadata.summary]),
43
+ } satisfies SearchEntry));
44
+
45
+ return [chapterEntry, ...sceneEntries];
46
+ }),
47
+ );
48
+
49
+ return [
50
+ ...glossary.map((entry) => ({
51
+ title: entry.label,
52
+ href: entry.href,
53
+ kind: entry.kindLabel,
54
+ summary: entry.summary,
55
+ keywords: [...entry.terms, ...entry.meta, ...entry.metadataEntries.map((item) => item.value)],
56
+ })),
57
+ ...chapterEntries.flat(),
58
+ ];
59
+ }
60
+
61
+ function compactStrings(values: unknown[]): string[] {
62
+ return values.flatMap((value) => {
63
+ if (Array.isArray(value)) {
64
+ return compactStrings(value);
65
+ }
66
+
67
+ if (value === undefined || value === null) {
68
+ return [];
69
+ }
70
+
71
+ const normalized = String(value).trim();
72
+ return normalized ? [normalized] : [];
73
+ });
74
+ }