-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrender.ts
More file actions
406 lines (365 loc) · 17.7 KB
/
Copy pathrender.ts
File metadata and controls
406 lines (365 loc) · 17.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
/**
* render.ts — builds a concept page's shell at runtime so authors don't write it.
*
* A slim page's <body> contains only the content (one or more <primer-card>s) plus the
* inline `concept-meta` JSON block, an optional `scene-strings` JSON block, and an optional
* inline scene <script>. This module imports the Primer custom elements, then wraps that
* content in the shell the pages used to spell out by hand:
*
* <main class="primer-shell">
* <primer-page> footer back to the tree
* <primer-pathway> navigation map (top)
* <primer-concept> <h1> title (+ level badge) + slotted body + confidence control
* ...the cards...
* </primer-concept>
* <primer-pathway> navigation map (bottom)
* </primer-page>
* </main>
*
* Internationalization: every lesson lives at ONE canonical URL (the English page). When the
* active locale (a user setting; see src/i18n.ts) is not English, this module fetches a
* translation overlay at `/i18n/<locale>/<id>.html` and swaps in its translated content +
* `scene-strings`, reusing the canonical page's (language-independent) inline scene JS. If no
* overlay exists, it falls back to English so the lesson is never blocked.
* @module
*/
import "./primer.ts";
import type { ConceptMeta } from "./types/domain.ts";
import { getConceptMeta, conceptIdFromPath } from "./concept-meta.ts";
import { initTheme } from "./theme.ts";
import { initLocale, getLocale, DEFAULT_LOCALE, t, type LocaleId } from "./i18n.ts";
import { loadGraph } from "./graph-data.ts";
import { mountConceptSearch as mountConceptSearchBox, makeBackButton, SEARCH_BOX_CSS } from "./concept-search-box.ts";
import { runProgressMigration } from "./progress-migration.ts";
import { initWikiLookup } from "./wiki-selection.ts";
/** Build the page shell once the DOM is ready. */
async function render(): Promise<void> {
// App-shell pages (/get-started, /course-quiz, …) own their layout. Harvesting a lesson
// script can load this module as a side-effect of the concept bundle — never wrap those
// pages as a nameless concept with a star row.
if (!conceptIdFromPath()) return;
const body = document.body;
// Reconcile the synchronously-set theme (boot.js) with storage — this also loads the
// fun display font when that theme is the saved choice.
initTheme();
// Reconcile the synchronously-set locale (boot.js) with storage + browser languages.
initLocale();
// Recover confidence scores stranded by a moved concept BEFORE the shell (and its star control +
// pathways) reads them, so a relocated lesson shows its stars on this very load. Uses the same
// memoized graph the render below awaits, so it adds no extra fetch; never throws.
await runProgressMigration();
// Global page chrome: the top-right hamburger menu (theme + language), mounted once.
if (!body.querySelector("primer-menu")) {
body.appendChild(document.createElement("primer-menu"));
}
// Selecting a short phrase in the prose pops a Wikipedia-summary card (mounted once).
initWikiLookup();
const meta = safeMeta();
const id = conceptIdFromPath();
// The content is every direct element child of <body> that is authored lesson content —
// i.e. NOT a <script> (leaving the concept-meta/scene-strings JSON blocks and any inline
// scene script in place), NOT the <primer-title> (its text is the page title, read below),
// and NOT the chrome we just mounted (the fixed <primer-menu>) or a previously-built shell
// <main>. Excluding the menu matters: otherwise the overlay swap would move or even remove
// it along with the canonical content.
const SKIP = new Set(["SCRIPT", "PRIMER-TITLE", "PRIMER-MENU", "MAIN"]);
let content = [...body.children].filter((el) => !SKIP.has(el.tagName)) as Element[];
// The title lives in the <primer-title> element (translatable, part of the body). We keep its
// plain text (for <title>/SEO) AND the element itself: its child nodes — which may include a
// <primer-math> for a math title — are slotted into the header below so the math typesets.
const canonicalTitleEl = body.querySelector("primer-title");
let pageTitle = canonicalTitleEl?.textContent?.trim() || null;
let titleEl: Element | null = canonicalTitleEl;
// Non-English: apply the translation overlay IF one exists, else fall back to English.
// We consult the emitted graph (which records a translated `titles[locale]` for every
// concept that has an overlay) so we only fetch when a translation is actually there —
// avoiding a noisy 404 in the console for the (common) untranslated case.
const locale = getLocale();
// The language of the rendered CONTENT (not the chrome): the chosen locale only when a real
// overlay is applied, else English. Drives the SEO canonical/hreflang + the content's own `lang`,
// so an untranslated page canonicalises to its clean English URL even though the chrome is localized.
let contentLocale: LocaleId = DEFAULT_LOCALE;
if (id && locale !== DEFAULT_LOCALE) {
const applied = (await hasOverlay(id, locale))
? await applyOverlay(id, locale, content)
: null;
if (applied) {
content = applied.content;
pageTitle = applied.title ?? pageTitle;
if (applied.titleEl) titleEl = applied.titleEl; // slot the translated title (may carry math)
contentLocale = locale; // this rendering shows the translated content
} else {
// The lesson CONTENT isn't translated into this locale, but the CHROME still is: KEEP
// <html lang> as the chosen locale so getLocale()/t() render the whole UI (confidence prompt,
// pathway, "Up next", menus, quiz) in it. The untranslated prose is itself English, so mark
// just the content + title with lang="en" — assistive tech then pronounces the English text
// correctly under the Dutch/Spanish chrome (nearest-ancestor lang wins). <html lang> is not
// reset.
for (const el of content) el.setAttribute("lang", DEFAULT_LOCALE);
titleEl?.setAttribute("lang", DEFAULT_LOCALE);
}
}
// Title from the (possibly translated) concept title (the page writes no <head>/<title>).
if (pageTitle) document.title = `${pageTitle} — ${t("app.name")}`;
// Which locales this concept is translated into (per the emitted graph) — the hreflang set.
let altLocales: string[] = [];
try {
const { byId } = await loadGraph();
altLocales = Object.keys(byId.get(id)?.titles ?? {});
} catch {
/* graph unavailable → no hreflang alternates (English-only indexing) */
}
// SEO metadata. Concept pages carry no static <head>, so inject it here; crawlers that
// render JS (e.g. Googlebot) index the result. See README → SEO.
injectSeo(pageTitle ?? "", firstText(content), contentLocale, meta?.declaredLevel, altLocales);
if (content.length === 0) return;
const main = document.createElement("main");
main.className = "primer-shell";
// Landmark + skip-link target. Concept pages have no static HTML, so the "skip to content" link
// (the first focusable element, jumping past the fixed chrome) is injected here too.
main.id = "main";
main.tabIndex = -1;
if (!body.querySelector(".skip-link")) {
const skip = document.createElement("a");
skip.className = "skip-link";
skip.href = "#main";
skip.textContent = t("a11y.skipLink");
body.insertBefore(skip, body.firstChild);
}
const page = document.createElement("primer-page");
const concept = document.createElement("primer-concept");
// Feed <primer-concept> the resolved title + id (it no longer reads them from concept-meta).
if (pageTitle) concept.setAttribute("title", pageTitle);
if (id) concept.setAttribute("concept-id", id);
// Move the (authored or translated) content into the concept body.
concept.append(...content);
// Slot the title element's child nodes (which may include a <primer-math>) into the header's
// named slot. They remain in the LIGHT DOM — so the document-level KaTeX CSS styles any math —
// while being projected into the shadow <h1>. The plain `title` attribute set above is the
// fallback shown when there is no slotted title (e.g. direct use without render.js).
if (titleEl && titleEl.childNodes.length) {
const titleSlot = document.createElement("span");
titleSlot.setAttribute("slot", "title");
titleSlot.append(...titleEl.childNodes);
concept.appendChild(titleSlot);
}
// A navigation pathway (the mini-explorer) at the TOP of the lesson, and an "Up next…"
// recommendation control at the BOTTOM; both slot into <primer-page>'s single <slot> in order.
// Each fetches the graph and renders itself (<primer-up-next> falls back to the mini-explorer
// when it has nothing to recommend).
const topPathway = document.createElement("primer-pathway");
const bottomUpNext = document.createElement("primer-up-next");
page.append(topPathway, concept, bottomUpNext);
main.appendChild(page);
body.appendChild(main);
// A fixed top-left concept search, mirroring the top-right hamburger — jump to any concept by
// name from any lesson. Mounted after the content scan above so it isn't treated as lesson body.
void mountConceptSearch(body, locale);
}
/**
* Whether the page is running as an installed PWA (standalone display mode) rather than in an
* ordinary browser tab. Installed apps have no browser chrome — no visible back button — so this
* gates the in-page back button below (a normal tab already has one; adding a second is clutter).
*/
function isStandalone(): boolean {
return (
(typeof matchMedia === "function" && matchMedia("(display-mode: standalone)").matches) ||
(navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
/**
* Mount the fixed top-left search box (and, when running as an installed PWA with somewhere to go
* back to, a back button beside it) once on a lesson page. Loads the graph for the concept-name
* list; selecting a result navigates to that concept.
*/
async function mountConceptSearch(body: HTMLElement, locale: string): Promise<void> {
if (body.querySelector(".cg-search-bar--fixed")) return; // already mounted
if (!document.getElementById("concept-search-style")) {
const s = document.createElement("style");
s.id = "concept-search-style";
s.textContent = SEARCH_BOX_CSS;
document.head.appendChild(s);
}
const graph = await loadGraph().catch(() => null);
if (!graph) return; // no graph → no search (the page is otherwise fine)
const bar = document.createElement("div");
bar.className = "cg-search-bar--fixed";
if (isStandalone() && history.length > 1) bar.appendChild(makeBackButton(t("nav.back")));
body.appendChild(bar);
mountConceptSearchBox(bar, {
byId: graph.byId,
locale,
placement: "inline",
onSelect: (id: string) => {
window.location.href = `/concepts/${id}`;
},
});
}
/**
* Whether a translation overlay exists for `id` in `locale`, per the emitted graph
* (build-graph records a `titles[locale]` for every concept that has an overlay). Used to
* skip the overlay fetch — and its console 404 — when nothing is translated. Returns false
* if the graph can't be loaded (so we simply render English).
*/
async function hasOverlay(id: string, locale: string): Promise<boolean> {
try {
const { byId } = await loadGraph();
return Boolean(byId.get(id)?.titles?.[locale]);
} catch {
return false;
}
}
/**
* Fetch and apply the translation overlay for `id` in `locale`. Returns the translated
* content elements (and title) to render, or null when there is no usable overlay (so the
* caller falls back to English). Swaps the canonical content out of the DOM and appends the
* overlay's `scene-strings` block tagged `data-locale`, KEEPING the English block as the
* fallback source so the reused scene JS narrates in the target language and falls back to
* English per-key (see src/scene-strings.ts `makeStrings`).
*/
async function applyOverlay(
id: string,
locale: string,
canonicalContent: Element[],
): Promise<{ content: Element[]; title: string | null; titleEl: Element | null } | null> {
let html;
try {
const res = await fetch(`/i18n/${locale}/${id}.html`);
if (!res.ok) return null; // 404 etc. → no translation
html = await res.text();
} catch {
return null; // network/parse failure → fall back to English
}
const doc = new DOMParser().parseFromString(html, "text/html");
const translated = [...doc.body.children].filter(
(el) => el.tagName !== "SCRIPT" && el.tagName !== "PRIMER-TITLE",
);
if (translated.length === 0) return null;
// Translated title from the overlay's <primer-title>. The caller sets the plain text on
// <primer-concept> and slots the (imported) element so a translated math title typesets; the
// canonical concept-meta block (prerequisites/level) is untouched.
const titleSrc = doc.querySelector("primer-title");
const title = titleSrc?.textContent?.trim() || null;
const titleEl = titleSrc ? (document.importNode(titleSrc, true) as Element) : null;
// Remove the canonical (English) content from the DOM…
for (const el of canonicalContent) el.remove();
// …but KEEP the canonical (English) scene-strings block(s) in place and append EACH of the
// overlay's blocks tagged with the active locale. A page may carry several blocks (e.g. quiz
// strings kept separate from scene/chart strings); makeStrings merges them all by namespace and
// resolves each key from the locale blocks, falling back to the retained English blocks per-key.
for (const overlayStrings of doc.querySelectorAll("script.scene-strings")) {
const node = document.importNode(overlayStrings, true) as HTMLElement;
node.setAttribute("data-locale", locale);
document.body.appendChild(node);
}
const content = translated.map((el) => document.importNode(el, true) as Element);
return { content, title, titleEl };
}
function safeMeta(): ConceptMeta | null {
try {
return getConceptMeta();
} catch {
return null;
}
}
/**
* A meta-description from the first non-empty card's text (collapsed, ~155 chars at a
* word boundary).
*/
function firstText(content: Element[]): string {
for (const el of content) {
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
if (!text) continue;
if (text.length <= 155) return text;
const cut = text.slice(0, 155);
const sp = cut.lastIndexOf(" ");
return `${(sp > 60 ? cut.slice(0, sp) : cut).replace(/[\s,.;:]+$/, "")}…`;
}
return "";
}
/** Set (or replace) a single `<head>` element matched by `selector`, creating it with `make`. */
function headTag(selector: string, make: () => Element): Element {
let el = document.head.querySelector(selector);
if (!el) {
el = make();
document.head.appendChild(el);
}
return el;
}
/**
* Inject SEO tags into `<head>`: a description, a per-language self-referential canonical, the
* `hreflang` alternates linking every language version, and a LearningResource JSON-LD. Idempotent
* (re-running updates the same elements).
*
* A translation is the same path with `?lang=<locale>`, so the canonical for a non-default locale is
* the `?lang=` URL (self-referential) — without this Google would fold every language into the bare
* English URL and never index the translations. `altLocales` (from the graph) are the locales this
* concept is translated into; we emit `en` + each of them + `x-default` so the set cross-links.
*/
function injectSeo(
title: string,
description: string,
locale: string,
level?: number,
altLocales: string[] = [],
): void {
const cleanUrl = location.origin + location.pathname; // ?lang has been stripped by initLocale
const langUrl = (loc: string) => (loc === DEFAULT_LOCALE ? cleanUrl : `${cleanUrl}?lang=${loc}`);
const canonical = langUrl(locale); // self-referential: this rendering's own language URL
if (description) {
headTag('meta[name="description"]', () => {
const m = document.createElement("meta");
m.setAttribute("name", "description");
return m;
}).setAttribute("content", description);
}
headTag('link[rel="canonical"]', () => {
const l = document.createElement("link");
l.setAttribute("rel", "canonical");
return l;
}).setAttribute("href", canonical);
// hreflang alternates — only meaningful when the concept actually has translations.
const translated = altLocales.filter((l) => l !== DEFAULT_LOCALE);
if (translated.length > 0) {
for (const [hreflang, href] of [
["en", cleanUrl],
...translated.map((l) => [l, langUrl(l)]),
["x-default", cleanUrl],
]) {
headTag(`link[rel="alternate"][hreflang="${hreflang}"]`, () => {
const l = document.createElement("link");
l.setAttribute("rel", "alternate");
l.setAttribute("hreflang", hreflang);
return l;
}).setAttribute("href", href);
}
}
const ld: Record<string, any> = {
"@context": "https://schema.org",
"@type": "LearningResource",
name: title,
url: canonical,
inLanguage: locale,
isPartOf: { "@type": "WebSite", name: t("app.name"), url: `${location.origin}/` },
};
if (description) ld.description = description;
if (typeof level === "number") ld.educationalLevel = `Level ${level}`;
headTag('script.primer-seo[type="application/ld+json"]', () => {
const s = document.createElement("script");
s.type = "application/ld+json";
s.className = "primer-seo";
return s;
}).textContent = JSON.stringify(ld);
}
/**
* Run render and, however it settles (success, empty-content early return, or error),
* announce it so boot.js can lift the anti-FOUC veil and fade the page in.
*/
function start(): void {
render().finally(() => document.dispatchEvent(new Event("primer:rendered")));
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}