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.
- package/README.md +52 -0
- package/astro.config.mjs +12 -0
- package/cli-dist/cli.d.ts +3 -0
- package/cli-dist/cli.d.ts.map +1 -0
- package/cli-dist/cli.js +78 -0
- package/cli-dist/cli.js.map +1 -0
- package/cli-dist/lib/assets.d.ts +8 -0
- package/cli-dist/lib/assets.d.ts.map +1 -0
- package/cli-dist/lib/assets.js +35 -0
- package/cli-dist/lib/assets.js.map +1 -0
- package/cli-dist/lib/book-config.d.ts +2 -0
- package/cli-dist/lib/book-config.d.ts.map +1 -0
- package/cli-dist/lib/book-config.js +2 -0
- package/cli-dist/lib/book-config.js.map +1 -0
- package/cli-dist/lib/book.d.ts +103 -0
- package/cli-dist/lib/book.d.ts.map +1 -0
- package/cli-dist/lib/book.js +89 -0
- package/cli-dist/lib/book.js.map +1 -0
- package/cli-dist/lib/canon.d.ts +16 -0
- package/cli-dist/lib/canon.d.ts.map +1 -0
- package/cli-dist/lib/canon.js +164 -0
- package/cli-dist/lib/canon.js.map +1 -0
- package/cli-dist/lib/glossary.d.ts +25 -0
- package/cli-dist/lib/glossary.d.ts.map +1 -0
- package/cli-dist/lib/glossary.js +195 -0
- package/cli-dist/lib/glossary.js.map +1 -0
- package/cli-dist/lib/search.d.ts +9 -0
- package/cli-dist/lib/search.d.ts.map +1 -0
- package/cli-dist/lib/search.js +55 -0
- package/cli-dist/lib/search.js.map +1 -0
- package/cli-dist/scaffold.d.ts +14 -0
- package/cli-dist/scaffold.d.ts.map +1 -0
- package/cli-dist/scaffold.js +190 -0
- package/cli-dist/scaffold.js.map +1 -0
- package/package.json +58 -0
- package/scripts/export-epub.mjs +13 -0
- package/src/cli.ts +96 -0
- package/src/components/AssetFigure.astro +17 -0
- package/src/components/ChapterPager.astro +40 -0
- package/src/components/LinkedValue.astro +18 -0
- package/src/components/MetadataSection.astro +22 -0
- package/src/components/ReaderRuntime.astro +310 -0
- package/src/components/RelatedLinks.astro +19 -0
- package/src/components/SiteSearch.astro +91 -0
- package/src/layouts/BaseLayout.astro +676 -0
- package/src/lib/assets.ts +44 -0
- package/src/lib/book-config.ts +1 -0
- package/src/lib/book.ts +116 -0
- package/src/lib/canon.ts +212 -0
- package/src/lib/glossary.ts +247 -0
- package/src/lib/search.ts +74 -0
- package/src/pages/chapters/[chapter].astro +102 -0
- package/src/pages/characters/[slug].astro +65 -0
- package/src/pages/characters/index.astro +45 -0
- package/src/pages/factions/[slug].astro +64 -0
- package/src/pages/factions/index.astro +45 -0
- package/src/pages/index.astro +129 -0
- package/src/pages/items/[slug].astro +63 -0
- package/src/pages/items/index.astro +45 -0
- package/src/pages/locations/[slug].astro +61 -0
- package/src/pages/locations/index.astro +45 -0
- package/src/pages/secrets/[slug].astro +67 -0
- package/src/pages/secrets/index.astro +46 -0
- package/src/pages/timeline/[slug].astro +58 -0
- package/src/pages/timeline/index.astro +52 -0
- package/src/scaffold.ts +232 -0
- package/tsconfig.json +6 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { scaffoldReaderSite } from "./scaffold.js";
|
|
6
|
+
|
|
7
|
+
type ParsedArgs = {
|
|
8
|
+
targetDir?: string;
|
|
9
|
+
bookRoot?: string;
|
|
10
|
+
packageName?: string;
|
|
11
|
+
coreDependency?: string;
|
|
12
|
+
pagesDomain?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const args = parseArgs(process.argv.slice(2));
|
|
16
|
+
const resolved = await resolveInputs(args);
|
|
17
|
+
const result = await scaffoldReaderSite(resolved.targetDir, {
|
|
18
|
+
bookRoot: resolved.bookRoot,
|
|
19
|
+
packageName: resolved.packageName,
|
|
20
|
+
coreDependency: resolved.coreDependency,
|
|
21
|
+
pagesDomain: resolved.pagesDomain,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
output.write(
|
|
25
|
+
[
|
|
26
|
+
`Narrarium reader scaffolded at ${result.targetRoot}`,
|
|
27
|
+
`Book root default: ${result.bookRoot}`,
|
|
28
|
+
`Core dependency: ${result.coreDependency}`,
|
|
29
|
+
"",
|
|
30
|
+
"Next steps:",
|
|
31
|
+
`- cd ${result.targetRoot}`,
|
|
32
|
+
"- npm install",
|
|
33
|
+
"- copy .env.example to .env if you want a local override",
|
|
34
|
+
"- npm run dev",
|
|
35
|
+
].join("\n"),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
async function resolveInputs(args: ParsedArgs) {
|
|
39
|
+
if (args.targetDir) {
|
|
40
|
+
return {
|
|
41
|
+
targetDir: args.targetDir,
|
|
42
|
+
bookRoot: args.bookRoot ?? "..",
|
|
43
|
+
packageName: args.packageName,
|
|
44
|
+
coreDependency: args.coreDependency,
|
|
45
|
+
pagesDomain: args.pagesDomain,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!input.isTTY || !output.isTTY) {
|
|
50
|
+
throw new Error("Missing target directory. Use narrarium-reader-init <target-dir> [--book-root <path>] [--package-name <name>] [--pages-domain <domain>].");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const rl = createInterface({ input, output });
|
|
54
|
+
try {
|
|
55
|
+
const targetDir = (await rl.question("Reader folder [reader]: ")) || "reader";
|
|
56
|
+
const bookRoot = (await rl.question("Book root relative to reader [. . becomes ..] [..]: ")) || "..";
|
|
57
|
+
const packageName = (await rl.question("Package name (optional): ")) || undefined;
|
|
58
|
+
const coreDependency = (await rl.question("Core dependency [published latest compatible]: ")) || undefined;
|
|
59
|
+
const pagesDomain = (await rl.question("GitHub Pages custom domain (optional): ")) || undefined;
|
|
60
|
+
return { targetDir, bookRoot, packageName, coreDependency, pagesDomain };
|
|
61
|
+
} finally {
|
|
62
|
+
rl.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
67
|
+
const parsed: ParsedArgs = {};
|
|
68
|
+
|
|
69
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
70
|
+
const token = argv[index];
|
|
71
|
+
|
|
72
|
+
if (!token.startsWith("-")) {
|
|
73
|
+
if (!parsed.targetDir) parsed.targetDir = token;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
switch (token) {
|
|
78
|
+
case "--book-root":
|
|
79
|
+
parsed.bookRoot = argv[++index];
|
|
80
|
+
break;
|
|
81
|
+
case "--package-name":
|
|
82
|
+
parsed.packageName = argv[++index];
|
|
83
|
+
break;
|
|
84
|
+
case "--core-dependency":
|
|
85
|
+
parsed.coreDependency = argv[++index];
|
|
86
|
+
break;
|
|
87
|
+
case "--pages-domain":
|
|
88
|
+
parsed.pagesDomain = argv[++index];
|
|
89
|
+
break;
|
|
90
|
+
default:
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { ReaderFigure } from "../lib/assets";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
figure: ReaderFigure;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { figure, className } = Astro.props;
|
|
10
|
+
const ratioStyle = figure.aspectRatio.includes(":")
|
|
11
|
+
? `aspect-ratio: ${figure.aspectRatio.replace(":", " / ")};`
|
|
12
|
+
: undefined;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<figure class:list={["media-frame", className]}>
|
|
16
|
+
<img src={figure.src} alt={figure.alt} loading="lazy" style={ratioStyle} />
|
|
17
|
+
</figure>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface ChapterItem {
|
|
3
|
+
slug: string;
|
|
4
|
+
metadata: {
|
|
5
|
+
number: number;
|
|
6
|
+
title: string;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
currentSlug: string;
|
|
12
|
+
chapters: ChapterItem[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { currentSlug, chapters } = Astro.props;
|
|
16
|
+
const currentIndex = chapters.findIndex((chapter) => chapter.slug === currentSlug);
|
|
17
|
+
const previousChapter = currentIndex > 0 ? chapters[currentIndex - 1] : null;
|
|
18
|
+
const nextChapter = currentIndex >= 0 && currentIndex < chapters.length - 1 ? chapters[currentIndex + 1] : null;
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<nav class="chapter-pager" aria-label="Chapter navigation">
|
|
22
|
+
<a class:list={["pager-link", !previousChapter && "is-disabled"]} href={previousChapter ? `chapters/${previousChapter.slug}/` : undefined} aria-disabled={!previousChapter}>
|
|
23
|
+
{previousChapter ? `Previous: ${previousChapter.metadata.title}` : "Start of book"}
|
|
24
|
+
</a>
|
|
25
|
+
|
|
26
|
+
<label class="pager-jump">
|
|
27
|
+
<span class="label">Jump To</span>
|
|
28
|
+
<select onchange="if (this.value) window.location.href = this.value">
|
|
29
|
+
{chapters.map((chapter) => (
|
|
30
|
+
<option value={`chapters/${chapter.slug}/`} selected={chapter.slug === currentSlug}>
|
|
31
|
+
{`Chapter ${String(chapter.metadata.number).padStart(3, "0")} - ${chapter.metadata.title}`}
|
|
32
|
+
</option>
|
|
33
|
+
))}
|
|
34
|
+
</select>
|
|
35
|
+
</label>
|
|
36
|
+
|
|
37
|
+
<a class:list={["pager-link", !nextChapter && "is-disabled"]} href={nextChapter ? `chapters/${nextChapter.slug}/` : undefined} aria-disabled={!nextChapter}>
|
|
38
|
+
{nextChapter ? `Next: ${nextChapter.metadata.title}` : "End of book"}
|
|
39
|
+
</a>
|
|
40
|
+
</nav>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { resolveValueParts } from "../lib/canon";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
value: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { value } = Astro.props;
|
|
9
|
+
const parts = await resolveValueParts(value);
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
{parts.map((part) =>
|
|
13
|
+
part.href ? (
|
|
14
|
+
<a href={part.href}>{part.text}</a>
|
|
15
|
+
) : (
|
|
16
|
+
part.text
|
|
17
|
+
),
|
|
18
|
+
)}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
import LinkedValue from "./LinkedValue.astro";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
entries: Array<[string, unknown]>;
|
|
6
|
+
heading?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { entries, heading = "Metadata" } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<div class="scene">
|
|
13
|
+
<h2>{heading}</h2>
|
|
14
|
+
<div class="meta-list">
|
|
15
|
+
{entries.map(([key, value]) => (
|
|
16
|
+
<div class="meta-row">
|
|
17
|
+
<div class="meta-key">{key}</div>
|
|
18
|
+
<div class="meta-value"><LinkedValue value={value} /></div>
|
|
19
|
+
</div>
|
|
20
|
+
))}
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { GlossaryEntry } from "../lib/glossary";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
glossary: GlossaryEntry[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { glossary } = Astro.props;
|
|
9
|
+
const glossaryJson = JSON.stringify(glossary).replace(/</g, "\\u003c");
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<div class="canon-overlay" data-canon-overlay hidden>
|
|
13
|
+
<button type="button" class="canon-overlay__backdrop" data-canon-close aria-label="Close canon popup"></button>
|
|
14
|
+
<aside class="canon-overlay__panel" aria-live="polite" aria-modal="true" role="dialog">
|
|
15
|
+
<button type="button" class="canon-overlay__close" data-canon-close aria-label="Close canon popup">x</button>
|
|
16
|
+
<p class="eyebrow" data-canon-kind></p>
|
|
17
|
+
<h2 data-canon-title></h2>
|
|
18
|
+
<div class="canon-tabs" data-canon-tabs>
|
|
19
|
+
<button type="button" class="canon-tab is-active" data-canon-tab="overview">Overview</button>
|
|
20
|
+
<button type="button" class="canon-tab" data-canon-tab="notes">Notes</button>
|
|
21
|
+
<button type="button" class="canon-tab" data-canon-tab="metadata">Metadata</button>
|
|
22
|
+
<button type="button" class="canon-tab" data-canon-tab="image">Image</button>
|
|
23
|
+
</div>
|
|
24
|
+
<section class="canon-panel is-active" data-canon-panel="overview">
|
|
25
|
+
<div class="chip-row" data-canon-meta></div>
|
|
26
|
+
<p class="chapter-meta" data-canon-summary></p>
|
|
27
|
+
<div class="canon-related" data-canon-mentions-wrap hidden>
|
|
28
|
+
<p class="label">Mentioned In</p>
|
|
29
|
+
<div class="chip-row" data-canon-mentions></div>
|
|
30
|
+
</div>
|
|
31
|
+
</section>
|
|
32
|
+
<section class="canon-panel" data-canon-panel="notes">
|
|
33
|
+
<div class="prose canon-overlay__body" data-canon-body></div>
|
|
34
|
+
</section>
|
|
35
|
+
<section class="canon-panel" data-canon-panel="metadata">
|
|
36
|
+
<div class="meta-list" data-canon-metadata></div>
|
|
37
|
+
</section>
|
|
38
|
+
<section class="canon-panel" data-canon-panel="image">
|
|
39
|
+
<div class="canon-overlay__image-panel" data-canon-image-panel></div>
|
|
40
|
+
</section>
|
|
41
|
+
<a class="chip" data-canon-link href="./">Open full entry</a>
|
|
42
|
+
</aside>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<script type="application/json" id="canon-glossary-data" set:html={glossaryJson}></script>
|
|
46
|
+
|
|
47
|
+
<script is:inline>
|
|
48
|
+
const themeStorageKey = "narrarium-reader-theme";
|
|
49
|
+
const glossaryDataElement = document.getElementById("canon-glossary-data");
|
|
50
|
+
const glossaryEntries = glossaryDataElement ? JSON.parse(glossaryDataElement.textContent || "[]") : [];
|
|
51
|
+
const glossaryById = new Map(glossaryEntries.map((entry) => [entry.id, entry]));
|
|
52
|
+
|
|
53
|
+
initializeThemeToggle();
|
|
54
|
+
initializeCanonMentions();
|
|
55
|
+
|
|
56
|
+
function initializeThemeToggle() {
|
|
57
|
+
const root = document.documentElement;
|
|
58
|
+
const toggle = document.querySelector("[data-theme-toggle]");
|
|
59
|
+
if (!toggle) return;
|
|
60
|
+
|
|
61
|
+
const saved = localStorage.getItem(themeStorageKey);
|
|
62
|
+
const initialTheme = saved || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
|
63
|
+
applyTheme(initialTheme);
|
|
64
|
+
|
|
65
|
+
toggle.addEventListener("click", () => {
|
|
66
|
+
applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function applyTheme(theme, persist) {
|
|
71
|
+
document.documentElement.dataset.theme = theme;
|
|
72
|
+
const toggle = document.querySelector("[data-theme-toggle]");
|
|
73
|
+
if (toggle) {
|
|
74
|
+
toggle.textContent = theme === "dark" ? "Light mode" : "Dark mode";
|
|
75
|
+
toggle.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode");
|
|
76
|
+
}
|
|
77
|
+
if (persist) {
|
|
78
|
+
localStorage.setItem(themeStorageKey, theme);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function initializeCanonMentions() {
|
|
83
|
+
if (glossaryEntries.length === 0) return;
|
|
84
|
+
|
|
85
|
+
const termMap = new Map();
|
|
86
|
+
for (const entry of glossaryEntries) {
|
|
87
|
+
for (const term of entry.terms || []) {
|
|
88
|
+
const normalized = String(term || "").trim();
|
|
89
|
+
if (!normalized || normalized.length < 3) continue;
|
|
90
|
+
const key = normalized.toLowerCase();
|
|
91
|
+
if (!termMap.has(key)) {
|
|
92
|
+
termMap.set(key, entry.id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const terms = [...termMap.keys()].sort((left, right) => right.length - left.length);
|
|
98
|
+
if (terms.length === 0) return;
|
|
99
|
+
|
|
100
|
+
const pattern = new RegExp(`(^|[^\\p{L}\\p{N}])(${terms.map(escapeRegex).join("|")})(?=$|[^\\p{L}\\p{N}])`, "giu");
|
|
101
|
+
|
|
102
|
+
enhanceCanonRoot(document, pattern, termMap);
|
|
103
|
+
|
|
104
|
+
initializeCanonOverlay(pattern, termMap);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function enhanceCanonRoot(root, pattern, termMap) {
|
|
108
|
+
const containers = root instanceof Document
|
|
109
|
+
? root.querySelectorAll(".prose p, .prose li, .prose blockquote")
|
|
110
|
+
: root.matches?.(".prose")
|
|
111
|
+
? root.querySelectorAll("p, li, blockquote")
|
|
112
|
+
: root.querySelectorAll(".prose p, .prose li, .prose blockquote");
|
|
113
|
+
|
|
114
|
+
containers.forEach((container) => {
|
|
115
|
+
if (container.dataset.canonEnhanced === "true") return;
|
|
116
|
+
container.dataset.canonEnhanced = "true";
|
|
117
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
118
|
+
const textNodes = [];
|
|
119
|
+
while (walker.nextNode()) {
|
|
120
|
+
textNodes.push(walker.currentNode);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const node of textNodes) {
|
|
124
|
+
enhanceTextNode(node, pattern, termMap);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function enhanceTextNode(textNode, pattern, termMap) {
|
|
130
|
+
const parent = textNode.parentElement;
|
|
131
|
+
if (!parent || parent.closest("a, button, code, pre, h1, h2, h3, h4, h5, h6, [data-no-canon]")) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const raw = textNode.textContent || "";
|
|
136
|
+
pattern.lastIndex = 0;
|
|
137
|
+
if (!pattern.test(raw)) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
pattern.lastIndex = 0;
|
|
141
|
+
|
|
142
|
+
const fragment = document.createDocumentFragment();
|
|
143
|
+
let cursor = 0;
|
|
144
|
+
let match;
|
|
145
|
+
|
|
146
|
+
while ((match = pattern.exec(raw))) {
|
|
147
|
+
const boundary = match[1] || "";
|
|
148
|
+
const term = match[2] || "";
|
|
149
|
+
const entryId = termMap.get(term.toLowerCase());
|
|
150
|
+
const start = match.index;
|
|
151
|
+
|
|
152
|
+
if (start > cursor) {
|
|
153
|
+
fragment.append(raw.slice(cursor, start));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (boundary) {
|
|
157
|
+
fragment.append(boundary);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (entryId) {
|
|
161
|
+
const button = document.createElement("button");
|
|
162
|
+
button.type = "button";
|
|
163
|
+
button.className = "canon-mention";
|
|
164
|
+
button.dataset.canonId = entryId;
|
|
165
|
+
button.textContent = term;
|
|
166
|
+
fragment.append(button);
|
|
167
|
+
} else {
|
|
168
|
+
fragment.append(term);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
cursor = start + boundary.length + term.length;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (cursor < raw.length) {
|
|
175
|
+
fragment.append(raw.slice(cursor));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
textNode.replaceWith(fragment);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function initializeCanonOverlay(pattern, termMap) {
|
|
182
|
+
const overlay = document.querySelector("[data-canon-overlay]");
|
|
183
|
+
if (!overlay || overlay.dataset.bound === "true") return;
|
|
184
|
+
overlay.dataset.bound = "true";
|
|
185
|
+
|
|
186
|
+
const title = overlay.querySelector("[data-canon-title]");
|
|
187
|
+
const kind = overlay.querySelector("[data-canon-kind]");
|
|
188
|
+
const summary = overlay.querySelector("[data-canon-summary]");
|
|
189
|
+
const meta = overlay.querySelector("[data-canon-meta]");
|
|
190
|
+
const link = overlay.querySelector("[data-canon-link]");
|
|
191
|
+
const body = overlay.querySelector("[data-canon-body]");
|
|
192
|
+
const metadata = overlay.querySelector("[data-canon-metadata]");
|
|
193
|
+
const imagePanel = overlay.querySelector("[data-canon-image-panel]");
|
|
194
|
+
const mentions = overlay.querySelector("[data-canon-mentions]");
|
|
195
|
+
const mentionsWrap = overlay.querySelector("[data-canon-mentions-wrap]");
|
|
196
|
+
|
|
197
|
+
document.addEventListener("click", (event) => {
|
|
198
|
+
const target = event.target;
|
|
199
|
+
if (!(target instanceof Element)) return;
|
|
200
|
+
|
|
201
|
+
const trigger = target.closest("[data-canon-id]");
|
|
202
|
+
if (trigger instanceof HTMLElement) {
|
|
203
|
+
const entry = glossaryById.get(trigger.dataset.canonId || "");
|
|
204
|
+
if (!entry) return;
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
title.textContent = entry.label;
|
|
207
|
+
kind.textContent = entry.kindLabel;
|
|
208
|
+
summary.textContent = entry.summary;
|
|
209
|
+
link.href = entry.href;
|
|
210
|
+
body.innerHTML = entry.bodyHtml || "";
|
|
211
|
+
enhanceCanonRoot(body, pattern, termMap);
|
|
212
|
+
metadata.replaceChildren(...(entry.metadataEntries || []).map((item) => {
|
|
213
|
+
const row = document.createElement("div");
|
|
214
|
+
row.className = "meta-row";
|
|
215
|
+
const key = document.createElement("div");
|
|
216
|
+
key.className = "meta-key";
|
|
217
|
+
key.textContent = item.label;
|
|
218
|
+
const value = document.createElement("div");
|
|
219
|
+
value.className = "meta-value";
|
|
220
|
+
value.textContent = item.value;
|
|
221
|
+
row.append(key, value);
|
|
222
|
+
return row;
|
|
223
|
+
}));
|
|
224
|
+
meta.replaceChildren(...(entry.meta || []).map((value) => {
|
|
225
|
+
const span = document.createElement("span");
|
|
226
|
+
span.className = "chip";
|
|
227
|
+
span.textContent = value;
|
|
228
|
+
return span;
|
|
229
|
+
}));
|
|
230
|
+
mentions.replaceChildren(...(entry.mentions || []).map((item) => {
|
|
231
|
+
const anchor = document.createElement("a");
|
|
232
|
+
anchor.className = "chip";
|
|
233
|
+
anchor.href = item.href;
|
|
234
|
+
anchor.textContent = item.label;
|
|
235
|
+
return anchor;
|
|
236
|
+
}));
|
|
237
|
+
mentionsWrap.hidden = !(entry.mentions && entry.mentions.length > 0);
|
|
238
|
+
|
|
239
|
+
if (entry.imageSrc) {
|
|
240
|
+
const wrap = document.createElement("div");
|
|
241
|
+
wrap.className = "canon-overlay__media";
|
|
242
|
+
const img = document.createElement("img");
|
|
243
|
+
img.src = entry.imageSrc;
|
|
244
|
+
img.alt = entry.imageAlt || entry.label;
|
|
245
|
+
wrap.append(img);
|
|
246
|
+
imagePanel.replaceChildren(wrap);
|
|
247
|
+
} else {
|
|
248
|
+
imagePanel.replaceChildren();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
syncCanonTabs(overlay, {
|
|
252
|
+
notes: Boolean(entry.bodyHtml),
|
|
253
|
+
metadata: Boolean(entry.metadataEntries?.length),
|
|
254
|
+
image: Boolean(entry.imageSrc),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
overlay.hidden = false;
|
|
258
|
+
document.body.classList.add("has-overlay");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const tabTrigger = target.closest("[data-canon-tab]");
|
|
263
|
+
if (tabTrigger instanceof HTMLElement) {
|
|
264
|
+
activateCanonTab(overlay, tabTrigger.dataset.canonTab || "overview");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (target.closest("[data-canon-close]")) {
|
|
269
|
+
closeCanonOverlay();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
document.addEventListener("keydown", (event) => {
|
|
274
|
+
if (event.key === "Escape") {
|
|
275
|
+
closeCanonOverlay();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function closeCanonOverlay() {
|
|
281
|
+
const overlay = document.querySelector("[data-canon-overlay]");
|
|
282
|
+
if (!overlay) return;
|
|
283
|
+
overlay.hidden = true;
|
|
284
|
+
document.body.classList.remove("has-overlay");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function syncCanonTabs(overlay, availability) {
|
|
288
|
+
overlay.querySelectorAll("[data-canon-tab]").forEach((button) => {
|
|
289
|
+
const key = button.dataset.canonTab;
|
|
290
|
+
const enabled = key === "overview" || availability[key];
|
|
291
|
+
button.hidden = !enabled;
|
|
292
|
+
button.disabled = !enabled;
|
|
293
|
+
});
|
|
294
|
+
activateCanonTab(overlay, "overview");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function activateCanonTab(overlay, key) {
|
|
298
|
+
const nextKey = overlay.querySelector(`[data-canon-tab="${key}"]`)?.hidden ? "overview" : key;
|
|
299
|
+
overlay.querySelectorAll("[data-canon-tab]").forEach((button) => {
|
|
300
|
+
button.classList.toggle("is-active", button.dataset.canonTab === nextKey);
|
|
301
|
+
});
|
|
302
|
+
overlay.querySelectorAll("[data-canon-panel]").forEach((panel) => {
|
|
303
|
+
panel.classList.toggle("is-active", panel.dataset.canonPanel === nextKey);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function escapeRegex(value) {
|
|
308
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
309
|
+
}
|
|
310
|
+
</script>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CanonLink } from "../lib/canon";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
links: CanonLink[];
|
|
6
|
+
heading?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { links, heading = "Related canon" } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<div class="scene">
|
|
13
|
+
<h2>{heading}</h2>
|
|
14
|
+
<div class="chip-row">
|
|
15
|
+
{links.map((link) => (
|
|
16
|
+
<a class="chip" href={link.href}>{link.label}</a>
|
|
17
|
+
))}
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { SearchEntry } from "../lib/search";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
entries: SearchEntry[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { entries } = Astro.props;
|
|
9
|
+
const entriesJson = JSON.stringify(entries).replace(/</g, "\\u003c");
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<div class="site-search" data-site-search>
|
|
13
|
+
<label class="site-search__field">
|
|
14
|
+
<span class="label">Search</span>
|
|
15
|
+
<input type="search" placeholder="Search chapters, scenes, and canon..." data-search-input />
|
|
16
|
+
</label>
|
|
17
|
+
<div class="site-search__results" data-search-results hidden></div>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<script type="application/json" id="reader-search-data" set:html={entriesJson}></script>
|
|
21
|
+
|
|
22
|
+
<script is:inline>
|
|
23
|
+
const searchDataElement = document.getElementById("reader-search-data");
|
|
24
|
+
const searchEntries = searchDataElement ? JSON.parse(searchDataElement.textContent || "[]") : [];
|
|
25
|
+
const searchRoot = document.querySelector("[data-site-search]");
|
|
26
|
+
|
|
27
|
+
if (searchRoot) {
|
|
28
|
+
const input = searchRoot.querySelector("[data-search-input]");
|
|
29
|
+
const results = searchRoot.querySelector("[data-search-results]");
|
|
30
|
+
|
|
31
|
+
const renderResults = (value) => {
|
|
32
|
+
const query = String(value || "").trim().toLowerCase();
|
|
33
|
+
if (!query) {
|
|
34
|
+
results.hidden = true;
|
|
35
|
+
results.replaceChildren();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const matches = searchEntries
|
|
40
|
+
.map((entry) => ({ entry, score: scoreSearchEntry(entry, query) }))
|
|
41
|
+
.filter((entry) => entry.score > 0)
|
|
42
|
+
.sort((left, right) => right.score - left.score)
|
|
43
|
+
.slice(0, 8);
|
|
44
|
+
|
|
45
|
+
results.replaceChildren(...matches.map(({ entry }) => {
|
|
46
|
+
const anchor = document.createElement("a");
|
|
47
|
+
anchor.className = "site-search__result";
|
|
48
|
+
anchor.href = entry.href;
|
|
49
|
+
const kind = document.createElement("span");
|
|
50
|
+
kind.className = "chip";
|
|
51
|
+
kind.textContent = entry.kind;
|
|
52
|
+
const title = document.createElement("strong");
|
|
53
|
+
title.textContent = entry.title;
|
|
54
|
+
const summary = document.createElement("span");
|
|
55
|
+
summary.className = "chapter-meta";
|
|
56
|
+
summary.textContent = entry.summary;
|
|
57
|
+
anchor.append(kind, title, summary);
|
|
58
|
+
return anchor;
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
results.hidden = matches.length === 0;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
input.addEventListener("input", () => renderResults(input.value));
|
|
65
|
+
input.addEventListener("focus", () => renderResults(input.value));
|
|
66
|
+
document.addEventListener("click", (event) => {
|
|
67
|
+
if (!(event.target instanceof Node) || searchRoot.contains(event.target)) return;
|
|
68
|
+
results.hidden = true;
|
|
69
|
+
});
|
|
70
|
+
document.addEventListener("keydown", (event) => {
|
|
71
|
+
if ((event.key === "/" && document.activeElement !== input) || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k")) {
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
input.focus();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function scoreSearchEntry(entry, query) {
|
|
79
|
+
const title = String(entry.title || "").toLowerCase();
|
|
80
|
+
const summary = String(entry.summary || "").toLowerCase();
|
|
81
|
+
const keywords = Array.isArray(entry.keywords) ? entry.keywords.join(" ").toLowerCase() : "";
|
|
82
|
+
|
|
83
|
+
let score = 0;
|
|
84
|
+
if (title === query) score += 120;
|
|
85
|
+
if (title.startsWith(query)) score += 80;
|
|
86
|
+
if (title.includes(query)) score += 45;
|
|
87
|
+
if (keywords.includes(query)) score += 20;
|
|
88
|
+
if (summary.includes(query)) score += 10;
|
|
89
|
+
return score;
|
|
90
|
+
}
|
|
91
|
+
</script>
|