@hajimism/dek 0.5.0 → 0.6.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 (58) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/package.json +12 -5
  3. package/src/cli/address.ts +48 -11
  4. package/src/cli/build.ts +3 -1
  5. package/src/cli/commands.ts +24 -10
  6. package/src/cli/contract.ts +28 -2
  7. package/src/cli/flags.ts +5 -0
  8. package/src/cli/keep-alive.ts +12 -6
  9. package/src/cli/lint.ts +53 -1
  10. package/src/cli/main.ts +2 -2
  11. package/src/cli/sync.ts +21 -6
  12. package/src/cli/text.ts +20 -2
  13. package/src/cli/theme.ts +1 -1
  14. package/src/core/build.ts +4 -0
  15. package/src/core/config-keys.ts +7 -2
  16. package/src/core/config.ts +63 -0
  17. package/src/core/diagnostic.ts +9 -1
  18. package/src/core/document.ts +28 -3
  19. package/src/core/lint/context.ts +2 -0
  20. package/src/core/lint/shared-styles.ts +26 -1
  21. package/src/core/lint/theme-css.ts +64 -3
  22. package/src/core/lint.ts +15 -2
  23. package/src/core/page.ts +1 -0
  24. package/src/core/plugin-host.ts +669 -0
  25. package/src/core/plugin.ts +436 -0
  26. package/src/core/sandbox.ts +1 -1
  27. package/src/core/sarif.ts +47 -5
  28. package/src/core/slide-measure.ts +1 -0
  29. package/src/core/spawn.ts +9 -7
  30. package/src/core/sync.ts +3 -1
  31. package/src/plugin-api.ts +126 -0
  32. package/src/plugins/study/check.ts +128 -0
  33. package/src/plugins/study/main.ts +47 -0
  34. package/src/plugins/study/manifest.ts +32 -0
  35. package/src/plugins/study/page.ts +295 -0
  36. package/src/plugins/study/place.ts +88 -0
  37. package/src/plugins/study/sarif.ts +30 -0
  38. package/src/plugins/study/study.ts +137 -0
  39. package/src/plugins/study/text.ts +55 -0
  40. package/src/plugins/study/yaml.ts +226 -0
  41. package/src/plugins/words/check.ts +109 -0
  42. package/src/plugins/words/lines.ts +26 -0
  43. package/src/plugins/words/list.ts +288 -0
  44. package/src/plugins/words/main.ts +50 -0
  45. package/src/plugins/words/manifest.ts +36 -0
  46. package/src/plugins/words/match.ts +60 -0
  47. package/src/plugins/words/sarif.ts +37 -0
  48. package/src/plugins/words/text.ts +352 -0
  49. package/src/plugins/words/yaml.ts +225 -0
  50. package/src/runtime/player.ts +18 -2
  51. package/src/runtime/plugins.ts +236 -0
  52. package/src/runtime/start.ts +16 -1
  53. package/src/runtime/window.ts +3 -0
  54. package/src/server/deck-snapshot.ts +32 -1
  55. package/src/server/dev.ts +9 -3
  56. package/src/server/lock.ts +39 -9
  57. package/src/server/pages.ts +9 -1
  58. package/tsconfig.json +1 -1
@@ -0,0 +1,126 @@
1
+ /// <reference lib="dom" />
2
+
3
+ /**
4
+ * The types a dek plugin is written against, published as `@hajimism/dek/plugin` (ADR 32). They
5
+ * are types only: a plugin runs in a process of its own and shares no code with dek, only these
6
+ * shapes, which travel as JSON. Each grows only by adding; a change to one is a new `schema` or
7
+ * `protocol`.
8
+ */
9
+
10
+ export type Severity = "error" | "warning";
11
+
12
+ /** One rule a check can report. Only `error` fails `dekc lint`. */
13
+ export type PluginRule = {
14
+ severity: Severity;
15
+ /** One line of fact, at most 120 characters; dek shows it only in SARIF's rule list. */
16
+ summary: string;
17
+ };
18
+
19
+ /** A plugin's rules by id: `<PREFIX>` and three digits, the prefix the name in capitals with `_` for `-`. */
20
+ export type PluginRules<Prefix extends string = string> = Record<`${Prefix}${number}`, PluginRule>;
21
+
22
+ /** What `dek-<name> manifest` prints: the promise the project pins in `plugins/<name>/plugin.json`. */
23
+ export type PluginManifest<Prefix extends string = string> = {
24
+ schema: 1;
25
+ /** The `<name>` of `dek-<name>`. */
26
+ name: string;
27
+ /** Letters, digits, `.`, `+`, and `-`, at most 64 characters. */
28
+ version: string;
29
+ /** Without it, dek runs no check. */
30
+ check?: { protocol: 1; rules: PluginRules<Prefix> };
31
+ /** A page in the player and the build, which calls `dek.plugin` (page API 1). */
32
+ page?: {
33
+ api: 1;
34
+ /** Single letters the page binds; none of dek's own (a f l m p s). */
35
+ keys?: string[];
36
+ };
37
+ /**
38
+ * Files the plugin reads, which AGENTS.md names: relative to the deck's folder, or, with
39
+ * `in: "project"`, to `plugins/<name>/` in the project, for data the whole project shares. Only
40
+ * the deck's are served to a page.
41
+ */
42
+ data?: Array<{ path: string; in?: "project" }>;
43
+ };
44
+
45
+ /** One slide of the deck, as `check` reads it. Each path is relative to the deck's folder, or null when the file is not there. */
46
+ export type CheckSlide = {
47
+ slug: string;
48
+ title: string;
49
+ html: string | null;
50
+ css: string | null;
51
+ ts: string | null;
52
+ };
53
+
54
+ /** What `dek-<name> check --protocol 1` reads on stdin, run in the deck's folder. */
55
+ export type CheckInput = {
56
+ protocol: 1;
57
+ /** The version of the dek that runs the check. */
58
+ dek: string;
59
+ deck: { name: string; lang: string; slides: CheckSlide[] };
60
+ /**
61
+ * The absolute path of `plugins/<name>/` in the project, where the project's data files are,
62
+ * whether or not the folder exists (ADR 35). An older dek does not send it: read that as no
63
+ * project data.
64
+ */
65
+ pluginDir?: string;
66
+ };
67
+
68
+ /** One finding in a check's SARIF: the fields dek reads, and no others. */
69
+ export type CheckResult<RuleId extends string = string> = {
70
+ /** A rule the manifest declares; its severity is the manifest's, whatever `level` says. */
71
+ ruleId: RuleId;
72
+ message: { text: string };
73
+ locations?: Array<{
74
+ physicalLocation: {
75
+ /**
76
+ * Relative to the deck's folder, or a `file:` URL inside it or inside `pluginDir`. dek drops
77
+ * a location anywhere else.
78
+ */
79
+ artifactLocation: { uri: string };
80
+ region?: { startLine: number; startColumn?: number };
81
+ };
82
+ }>;
83
+ /** What to do next. */
84
+ properties?: { hint?: string };
85
+ };
86
+
87
+ /** What `dek-<name> check` prints on stdout: SARIF 2.1.0, of which dek reads each run's results. */
88
+ export type CheckOutput<RuleId extends string = string> = {
89
+ version: "2.1.0";
90
+ $schema?: string;
91
+ runs: Array<{
92
+ tool?: { driver: { name: string; version?: string } };
93
+ results: Array<CheckResult<RuleId>>;
94
+ }>;
95
+ };
96
+
97
+ /** Where the deck stands: the slide, its index, and the beat (0 is the slide's arrival). */
98
+ export type PagePosition = { slug: string; index: number; beat: number };
99
+
100
+ /**
101
+ * A slide drawn on the stage, for a page to draw over. `root` is the slide, to read and never to
102
+ * change: lint checked what is in it. `layer` lies over the slide, in the slide's own pixels, and
103
+ * is the plugin's alone. A slide a live reload replaced is mounted again.
104
+ */
105
+ export type PageMount = { slug: string; root: HTMLElement; layer: ShadowRoot; view: "stage" };
106
+
107
+ /** What `dek.plugin` hands a plugin's page: page API 1. */
108
+ export type PageApi = {
109
+ deck: { name: string };
110
+ slides: Array<{ slug: string; title: string }>;
111
+ current(): PagePosition;
112
+ /** After the deck settles at a position, at the start and after every move, wherever it came from. */
113
+ on(event: "change", listener: (position: PagePosition) => void): void;
114
+ on(event: "mount", listener: (mount: PageMount) => void): void;
115
+ /** Whether `element` shows at the current beat: no beat that hides it is still to come. */
116
+ shown(element: Element): boolean;
117
+ /** A data file the manifest declares, as the project has it; `version` changes with the text. */
118
+ data(path: string): { text: string; version: string } | null;
119
+ /** Binds a key the manifest declares; false for any other. */
120
+ key(letter: string, listener: () => void): boolean;
121
+ };
122
+
123
+ /** The one global a plugin's page touches. */
124
+ export type DekPage = {
125
+ plugin(name: string, options: { api: 1 }, setup: (api: PageApi) => void): void;
126
+ };
@@ -0,0 +1,128 @@
1
+ import type { CheckInput } from "../../plugin-api.ts";
2
+ import { type RuleId, STUDY_FILE } from "./manifest.ts";
3
+ import { follows, normalize, occurrences, placeStickers } from "./place.ts";
4
+ import { readStudy, type Sticker } from "./study.ts";
5
+ import { slideText } from "./text.ts";
6
+
7
+ /** A finding, which can name only a rule the manifest declares. Paths are relative to the deck. */
8
+ export type Finding = { rule: RuleId; message: string; path: string; line: number; hint: string };
9
+
10
+ /** Reads a file of the deck by its path relative to the deck's folder; undefined when absent. */
11
+ export type ReadFile = (path: string) => Promise<string | undefined>;
12
+
13
+ /**
14
+ * Checks the deck's stickers against its slides: each sticker's words are on its slide (STUDY001),
15
+ * no other place on the slide shows them unhidden (STUDY002), and study.yaml reads (STUDY003). A
16
+ * deck with no study.yaml has nothing to check.
17
+ */
18
+ export async function check(input: CheckInput, read: ReadFile): Promise<Finding[]> {
19
+ const source = await read(STUDY_FILE);
20
+ if (source === undefined) {
21
+ return [];
22
+ }
23
+ const { stickers, problems } = readStudy(source);
24
+ const findings: Finding[] = problems.map((problem) => ({
25
+ rule: "STUDY003",
26
+ message: problem.message,
27
+ path: STUDY_FILE,
28
+ line: problem.line,
29
+ hint: "write study.yaml as https://hajimism.github.io/dek/reference/plugins.html#dek-study shows",
30
+ }));
31
+
32
+ const texts = new Map<string, string>();
33
+ const htmlPaths = new Map<string, string>();
34
+ for (const slide of input.deck.slides) {
35
+ const html = slide.html === null ? undefined : await read(slide.html);
36
+ texts.set(slide.slug, html === undefined ? "" : await slideText(html));
37
+ htmlPaths.set(slide.slug, slide.html ?? `slides/${slide.slug}.html`);
38
+ }
39
+
40
+ /** The one other slide the sticker would find its place on, prefix and all, if exactly one. */
41
+ const elsewhere = (sticker: Sticker): string | undefined => {
42
+ const found = [...texts].filter(
43
+ ([slug, text]) => slug !== sticker.slide && placeStickers(text, [sticker]).lost.length === 0,
44
+ );
45
+ return found.length === 1 ? found[0]?.[0] : undefined;
46
+ };
47
+ const lost = (sticker: Sticker, message: string, fallback: string): Finding => {
48
+ const other = elsewhere(sticker);
49
+ return {
50
+ rule: "STUDY001",
51
+ message,
52
+ path: STUDY_FILE,
53
+ line: sticker.line,
54
+ hint:
55
+ other === undefined
56
+ ? fallback
57
+ : `the words are on slide ${other}: set the sticker's \`slide: ${other}\``,
58
+ };
59
+ };
60
+
61
+ /**
62
+ * Why a sticker on a slide that is there found no place: its words are gone, no place of them
63
+ * follows its prefix, or the stickers before it took every place. Only gone words are looked
64
+ * for on other slides; words still on the slide are the sticker's to find there.
65
+ */
66
+ const lostOnSlide = (sticker: Sticker, slug: string, text: string, path: string): Finding => {
67
+ const quote = normalize(sticker.quote);
68
+ const places = occurrences(text, quote);
69
+ if (places.length === 0) {
70
+ return lost(
71
+ sticker,
72
+ `sticker ${sticker.id}: 「${sticker.quote}」 is not on slide ${slug}`,
73
+ `find where the words went in ${path} and set the sticker's quote and prefix to them, or delete the sticker`,
74
+ );
75
+ }
76
+ const prefix = sticker.prefix === undefined ? undefined : normalize(sticker.prefix);
77
+ if (prefix !== undefined && !places.some((place) => follows(text, place, prefix))) {
78
+ return {
79
+ rule: "STUDY001",
80
+ message: `sticker ${sticker.id}: 「${sticker.quote}」 is on slide ${slug}, but not after 「${sticker.prefix}」`,
81
+ path: STUDY_FILE,
82
+ line: sticker.line,
83
+ hint: `set the sticker's prefix to the words right before 「${sticker.quote}」 in ${path}, or leave the prefix out`,
84
+ };
85
+ }
86
+ return {
87
+ rule: "STUDY001",
88
+ message: `sticker ${sticker.id}: every 「${sticker.quote}」 on slide ${slug} has a sticker before this one`,
89
+ path: STUDY_FILE,
90
+ line: sticker.line,
91
+ hint: "delete the sticker, since another already hides these words",
92
+ };
93
+ };
94
+
95
+ const bySlide = Map.groupBy(stickers, (sticker) => sticker.slide);
96
+ for (const [slug, onSlide] of bySlide) {
97
+ const text = texts.get(slug);
98
+ if (text === undefined) {
99
+ for (const sticker of onSlide) {
100
+ findings.push(
101
+ lost(
102
+ sticker,
103
+ `sticker ${sticker.id}: slide ${slug} is not in this deck`,
104
+ "set the sticker's `slide` to the slide its words are on, or delete the sticker",
105
+ ),
106
+ );
107
+ }
108
+ continue;
109
+ }
110
+ const placement = placeStickers(text, onSlide);
111
+ for (const sticker of placement.lost) {
112
+ findings.push(lostOnSlide(sticker, slug, text, htmlPaths.get(slug) ?? ""));
113
+ }
114
+ for (const { first, count } of placement.shown) {
115
+ findings.push({
116
+ rule: "STUDY002",
117
+ message: `「${first.quote}」 is hidden by sticker ${first.id} but shows ${count} more ${count === 1 ? "time" : "times"} on slide ${slug}`,
118
+ path: STUDY_FILE,
119
+ line: first.line,
120
+ hint: `add a sticker for each other 「${first.quote}」 on the slide, with a prefix that tells it apart, or reword the others`,
121
+ });
122
+ }
123
+ }
124
+ // The same deck gives the same output, in the same order.
125
+ return findings.sort(
126
+ (a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.rule.localeCompare(b.rule),
127
+ );
128
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * dek-study, the plugin dek ships (ADR 34). dek runs it as any plugin (ADR 32), in a bun of its
4
+ * own: `manifest` to pin what it promises, and `check --protocol 1` in a deck's folder, with the
5
+ * deck on stdin, for SARIF. It shares only the types in plugin-api.ts with dek, never its code.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import type { CheckInput } from "../../plugin-api.ts";
9
+ import { check } from "./check.ts";
10
+ import { MANIFEST } from "./manifest.ts";
11
+ import { toSarif } from "./sarif.ts";
12
+
13
+ const USAGE = "usage: dek-study manifest | dek-study check --protocol 1 (dek runs both)";
14
+
15
+ async function main(args: string[]): Promise<number> {
16
+ switch (args[0]) {
17
+ case "manifest":
18
+ process.stdout.write(`${JSON.stringify(MANIFEST)}\n`);
19
+ return 0;
20
+ case "check": {
21
+ if (args[1] !== "--protocol" || args[2] !== "1") {
22
+ process.stderr.write(
23
+ `dek-study speaks check protocol 1; dek asked for ${args.slice(1).join(" ")}\n`,
24
+ );
25
+ return 2;
26
+ }
27
+ const input = JSON.parse(await Bun.stdin.text()) as CheckInput;
28
+ const findings = await check(input, async (path) => readIfThere(path));
29
+ process.stdout.write(`${JSON.stringify(toSarif(findings))}\n`);
30
+ return 0;
31
+ }
32
+ default:
33
+ process.stderr.write(`${USAGE}\n`);
34
+ return 2;
35
+ }
36
+ }
37
+
38
+ /** A file of the deck, relative to its folder, which is where dek runs the check. */
39
+ function readIfThere(path: string): string | undefined {
40
+ try {
41
+ return readFileSync(path, "utf8");
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ process.exit(await main(process.argv.slice(2)));
@@ -0,0 +1,32 @@
1
+ import type { PluginManifest, PluginRules } from "../../plugin-api.ts";
2
+
3
+ /**
4
+ * The one place each rule is declared: the manifest prints it, and a finding can name only a rule
5
+ * here, so dek-study cannot report an id its manifest does not declare.
6
+ */
7
+ const RULES = {
8
+ STUDY001: { severity: "error", summary: "a sticker's words are not on its slide" },
9
+ STUDY002: { severity: "warning", summary: "a sticker's words also show elsewhere on its slide" },
10
+ STUDY003: { severity: "error", summary: "study.yaml cannot be read" },
11
+ } as const satisfies PluginRules<"STUDY">;
12
+
13
+ export type RuleId = keyof typeof RULES;
14
+
15
+ /** The file of stickers, in each deck's folder. */
16
+ export const STUDY_FILE = "study.yaml";
17
+
18
+ /**
19
+ * dek-study's own version, apart from dek's: a project accepts a change to what it promises, and
20
+ * a dek release that leaves the rules as they were must not ask for that.
21
+ */
22
+ const VERSION = "0.1.0";
23
+
24
+ /** What `dek-study manifest` prints: the same every time, since dek pins it. */
25
+ export const MANIFEST = {
26
+ schema: 1,
27
+ name: "study",
28
+ version: VERSION,
29
+ check: { protocol: 1, rules: RULES },
30
+ page: { api: 1, keys: ["r"] },
31
+ data: [{ path: STUDY_FILE }],
32
+ } as const satisfies PluginManifest<"STUDY">;
@@ -0,0 +1,295 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * dek-study's page (ADR 34): a sheet of stickers over the words in study.yaml. `r` lays the sheet
4
+ * on and takes it off, and the page remembers which for the deck. The pointer on a sticker peels it
5
+ * back, and the words show through. It finds each sticker's words as the check does, with
6
+ * the same reader and the same placement, so a deck that passes lint is a deck the sheet covers.
7
+ */
8
+ import type { DekPage, PageApi } from "../../plugin-api.ts";
9
+ import { STUDY_FILE } from "./manifest.ts";
10
+ import { normalize, placeStickers } from "./place.ts";
11
+ import { readStudy, type Sticker } from "./study.ts";
12
+
13
+ declare const dek: DekPage;
14
+
15
+ /** How long a sticker takes to fade away under the pointer, and to come back after it. */
16
+ const FADE_OUT_MS = 180;
17
+ const FADE_IN_MS = 120;
18
+ /** How long the sheet's state shows after `r`, before it gets out of the slide's way. */
19
+ const TOAST_MS = 1500;
20
+ /** How far a sticker reaches past its words, in ems of the words it hides: a little to each side, barely above and below. */
21
+ const PAD_X = 0.18;
22
+ const PAD_Y = 0.04;
23
+ const UNSEEN = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT"]);
24
+
25
+ /**
26
+ * A sticker is a slip of pale yellow paper, as a sticky note is, with square corners and the same
27
+ * on every theme, so it reads as something laid on the slide rather than part of it. Under the
28
+ * pointer it fades away and leaves a yellow rule under the words, so the place still reads as a
29
+ * sticker's; it comes back once the pointer leaves.
30
+ */
31
+ const STYLE = `
32
+ .sheet { position: absolute; inset: 0; }
33
+ .sheet[hidden] { display: none; }
34
+ .sticker {
35
+ position: absolute;
36
+ pointer-events: auto;
37
+ background: rgb(255 240 160);
38
+ box-shadow: inset 0 calc(-1 * max(2px, 0.08em)) 0 rgb(240 206 72);
39
+ transition: background-color ${FADE_IN_MS}ms ease-in;
40
+ }
41
+ .sticker.peek {
42
+ background: rgb(255 240 160 / 0);
43
+ transition: background-color ${FADE_OUT_MS}ms ease-out;
44
+ }
45
+ @media (prefers-reduced-motion: reduce) {
46
+ .sticker, .sticker.peek, .toast, .toast.show { transition: none; }
47
+ }
48
+ .toast {
49
+ opacity: 0;
50
+ visibility: hidden;
51
+ transition: opacity ${FADE_OUT_MS}ms ease-in, visibility 0s ${FADE_OUT_MS}ms;
52
+ position: absolute;
53
+ top: 0;
54
+ right: 0;
55
+ padding: 6px 12px;
56
+ background: var(--study-ink);
57
+ color: var(--study-paper);
58
+ font: 600 13px/1.4 system-ui, sans-serif;
59
+ letter-spacing: 0.08em;
60
+ }
61
+ .toast.show {
62
+ opacity: 1;
63
+ visibility: visible;
64
+ transition: opacity ${FADE_IN_MS}ms ease-out, visibility 0s;
65
+ }
66
+ `;
67
+
68
+ dek.plugin("study", { api: 1 }, (api) => {
69
+ const file = api.data(STUDY_FILE);
70
+ const stickers = file ? readStudy(file.text).stickers : [];
71
+ const bySlide = Map.groupBy(stickers, (sticker) => sticker.slide);
72
+ const storage = `dek-study:${api.deck.name}:sheet`;
73
+ let on = readOn(storage);
74
+ const mounted = new Map<
75
+ string,
76
+ { root: HTMLElement; layer: ShadowRoot; sheet: HTMLElement; toast: HTMLElement }
77
+ >();
78
+ let toastTimer: ReturnType<typeof setTimeout> | undefined;
79
+
80
+ api.on("mount", ({ slug, root, layer }) => {
81
+ // The slide's own ink and paper, for the hairline and the notice, through the layer's host.
82
+ const slideStyle = getComputedStyle(root);
83
+ const host = layer.host as HTMLElement;
84
+ host.style.setProperty("--study-ink", slideStyle.color);
85
+ host.style.setProperty("--study-paper", paper(slideStyle.backgroundColor));
86
+ const style = document.createElement("style");
87
+ style.textContent = STYLE;
88
+ const sheet = document.createElement("div");
89
+ sheet.className = "sheet";
90
+ const toast = document.createElement("div");
91
+ toast.className = "toast";
92
+ layer.append(style, sheet, toast);
93
+ mounted.set(slug, { root, layer, sheet, toast });
94
+ });
95
+ api.on("change", ({ slug }) => draw(slug));
96
+ api.key("r", () => {
97
+ on = !on;
98
+ writeOn(storage, on);
99
+ const slug = api.current().slug;
100
+ draw(slug);
101
+ announce(slug);
102
+ });
103
+ // Text set in a web font moves once the font arrives.
104
+ void document.fonts?.ready.then(() => draw(api.current().slug));
105
+
106
+ /** Says for a moment whether the sheet is on. */
107
+ function announce(slug: string): void {
108
+ const toast = mounted.get(slug)?.toast;
109
+ if (!toast) {
110
+ return;
111
+ }
112
+ toast.textContent = on ? "SHEET ON" : "SHEET OFF";
113
+ toast.classList.add("show");
114
+ clearTimeout(toastTimer);
115
+ toastTimer = setTimeout(() => {
116
+ toast.classList.remove("show");
117
+ }, TOAST_MS);
118
+ }
119
+
120
+ function draw(slug: string): void {
121
+ const view = mounted.get(slug);
122
+ if (!view) {
123
+ return;
124
+ }
125
+ const { root, layer, sheet } = view;
126
+ sheet.replaceChildren();
127
+ sheet.hidden = !on;
128
+ if (!on) {
129
+ return;
130
+ }
131
+ const onSlide = bySlide.get(slug) ?? [];
132
+
133
+ const host = layer.host as HTMLElement;
134
+ const box = host.getBoundingClientRect();
135
+ const scale = host.offsetWidth > 0 ? box.width / host.offsetWidth : 1;
136
+ const words = readWords(root);
137
+ for (const { sticker, at } of placeStickers(words.text, onSlide).placed) {
138
+ const range = words.range(at, normalize(sticker.quote).length);
139
+ if (!range || !shown(api, range)) {
140
+ continue;
141
+ }
142
+ const ink = getComputedStyle(range.startContainer.parentElement ?? root);
143
+ const em = Number.parseFloat(ink.fontSize) || 16;
144
+ const pieces = lines(range.getClientRects()).map((rect) =>
145
+ stickerEl(sticker, ink, {
146
+ left: (rect.left - box.left) / scale - em * PAD_X,
147
+ top: (rect.top - box.top) / scale - em * PAD_Y,
148
+ width: (rect.right - rect.left) / scale + em * PAD_X * 2,
149
+ height: (rect.bottom - rect.top) / scale + em * PAD_Y * 2,
150
+ }),
151
+ );
152
+ peekTogether(pieces);
153
+ sheet.append(...pieces);
154
+ }
155
+ }
156
+ });
157
+
158
+ type Box = { left: number; top: number; right: number; bottom: number };
159
+
160
+ /**
161
+ * The words' boxes, one per line: a range across elements, such as `<strong>外部</strong>視点`,
162
+ * gives a box for each, and one sticker over the words reads as one.
163
+ */
164
+ function lines(rects: DOMRectList): Box[] {
165
+ const merged: Box[] = [];
166
+ const sorted = [...rects]
167
+ .filter((rect) => rect.width > 0 && rect.height > 0)
168
+ .sort((a, b) => a.top - b.top || a.left - b.left);
169
+ for (const rect of sorted) {
170
+ const last = merged.at(-1);
171
+ const sameLine = last && rect.top < last.bottom && rect.bottom > last.top;
172
+ if (last && sameLine && rect.left <= last.right + 1) {
173
+ last.left = Math.min(last.left, rect.left);
174
+ last.top = Math.min(last.top, rect.top);
175
+ last.right = Math.max(last.right, rect.right);
176
+ last.bottom = Math.max(last.bottom, rect.bottom);
177
+ } else {
178
+ merged.push({ left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom });
179
+ }
180
+ }
181
+ return merged;
182
+ }
183
+
184
+ /** A sticker in the color and size of the words under it, so its ems are theirs. */
185
+ function stickerEl(
186
+ sticker: Sticker,
187
+ ink: CSSStyleDeclaration,
188
+ box: { left: number; top: number; width: number; height: number },
189
+ ): HTMLElement {
190
+ const el = document.createElement("div");
191
+ el.className = "sticker";
192
+ el.dataset.id = sticker.id;
193
+ el.style.cssText = `left:${box.left}px;top:${box.top}px;width:${box.width}px;height:${box.height}px;font-size:${ink.fontSize}`;
194
+ return el;
195
+ }
196
+
197
+ /** The slide's background, or white where the slide leaves it transparent. */
198
+ function paper(background: string): string {
199
+ return background === "" || background === "transparent" || /rgba\(.*,\s*0\)$/.test(background)
200
+ ? "#fff"
201
+ : background;
202
+ }
203
+
204
+ /** A sticker that wraps onto two lines is one sticker: the pointer on either piece peels both. */
205
+ function peekTogether(pieces: HTMLElement[]): void {
206
+ const peek = (on: boolean) => {
207
+ for (const piece of pieces) {
208
+ piece.classList.toggle("peek", on);
209
+ }
210
+ };
211
+ for (const piece of pieces) {
212
+ piece.addEventListener("pointerenter", () => peek(true));
213
+ piece.addEventListener("pointerleave", () => peek(false));
214
+ }
215
+ }
216
+
217
+ /** A sticker covers words only once they show: one over a beat still to come would give it away. */
218
+ function shown(api: PageApi, range: Range): boolean {
219
+ const start = range.startContainer.parentElement;
220
+ const end = range.endContainer.parentElement;
221
+ return (!start || api.shown(start)) && (!end || api.shown(end));
222
+ }
223
+
224
+ /**
225
+ * The slide's words as the check reads them, whitespace collapsed, and a way back from a place in
226
+ * them to the DOM: each character kept remembers the text node and offset it came from.
227
+ */
228
+ function readWords(root: HTMLElement): {
229
+ text: string;
230
+ range: (at: number, length: number) => Range | undefined;
231
+ } {
232
+ const chars: Array<{ node: Text; offset: number }> = [];
233
+ let text = "";
234
+ let space = false;
235
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
236
+ acceptNode: (node) =>
237
+ unseen(node, root) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT,
238
+ });
239
+ for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) {
240
+ const data = node.data;
241
+ for (let offset = 0; offset < data.length; offset += 1) {
242
+ const char = data[offset] as string;
243
+ if (/\s/.test(char)) {
244
+ space = text.length > 0;
245
+ continue;
246
+ }
247
+ if (space) {
248
+ text += " ";
249
+ chars.push({ node, offset });
250
+ space = false;
251
+ }
252
+ text += char;
253
+ chars.push({ node, offset });
254
+ }
255
+ }
256
+ return {
257
+ text,
258
+ range(at, length) {
259
+ const first = chars[at];
260
+ const last = chars[at + length - 1];
261
+ if (!first || !last) {
262
+ return undefined;
263
+ }
264
+ const range = document.createRange();
265
+ range.setStart(first.node, first.offset);
266
+ range.setEnd(last.node, last.offset + 1);
267
+ return range;
268
+ },
269
+ };
270
+ }
271
+
272
+ function unseen(node: Node, root: HTMLElement): boolean {
273
+ for (let el = node.parentElement; el && el !== root; el = el.parentElement) {
274
+ if (UNSEEN.has(el.tagName)) {
275
+ return true;
276
+ }
277
+ }
278
+ return false;
279
+ }
280
+
281
+ function readOn(key: string): boolean {
282
+ try {
283
+ return localStorage.getItem(key) === "on";
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ function writeOn(key: string, on: boolean): void {
290
+ try {
291
+ localStorage.setItem(key, on ? "on" : "off");
292
+ } catch {
293
+ // A page whose storage is blocked forgets; the sheet still works for the visit.
294
+ }
295
+ }