@hajimism/dek 0.5.1 → 0.6.1

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 (48) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +10 -3
  3. package/src/cli/build.ts +3 -1
  4. package/src/cli/check.ts +20 -11
  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/lint.ts +53 -1
  9. package/src/cli/sync.ts +21 -6
  10. package/src/cli/text.ts +20 -2
  11. package/src/core/build.ts +4 -0
  12. package/src/core/config-keys.ts +7 -2
  13. package/src/core/config.ts +63 -0
  14. package/src/core/diagnostic.ts +9 -1
  15. package/src/core/document.ts +28 -3
  16. package/src/core/lint/script.ts +2 -4
  17. package/src/core/page.ts +1 -0
  18. package/src/core/plugin-host.ts +709 -0
  19. package/src/core/plugin.ts +442 -0
  20. package/src/core/sarif.ts +47 -5
  21. package/src/core/script-lines.ts +11 -0
  22. package/src/core/sync.ts +4 -2
  23. package/src/plugin-api.ts +126 -0
  24. package/src/plugins/study/check.ts +128 -0
  25. package/src/plugins/study/main.ts +47 -0
  26. package/src/plugins/study/manifest.ts +32 -0
  27. package/src/plugins/study/page.ts +295 -0
  28. package/src/plugins/study/place.ts +88 -0
  29. package/src/plugins/study/sarif.ts +30 -0
  30. package/src/plugins/study/study.ts +137 -0
  31. package/src/plugins/study/text.ts +55 -0
  32. package/src/plugins/study/yaml.ts +226 -0
  33. package/src/plugins/words/check.ts +109 -0
  34. package/src/plugins/words/lines.ts +26 -0
  35. package/src/plugins/words/list.ts +288 -0
  36. package/src/plugins/words/main.ts +50 -0
  37. package/src/plugins/words/manifest.ts +36 -0
  38. package/src/plugins/words/match.ts +60 -0
  39. package/src/plugins/words/sarif.ts +37 -0
  40. package/src/plugins/words/text.ts +352 -0
  41. package/src/plugins/words/yaml.ts +225 -0
  42. package/src/runtime/player.ts +18 -2
  43. package/src/runtime/plugins.ts +236 -0
  44. package/src/runtime/start.ts +16 -1
  45. package/src/runtime/window.ts +3 -0
  46. package/src/server/deck-snapshot.ts +32 -1
  47. package/src/server/dev.ts +7 -1
  48. package/src/server/pages.ts +9 -1
@@ -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
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Where each sticker sits in a slide's words, decided the same in the check and in the page, so a
3
+ * deck that passes lint is a deck whose stickers the page finds.
4
+ */
5
+
6
+ /** What a sticker needs to be placed: its words, the words right before them, and its line. */
7
+ export type Placeable = { quote: string; prefix?: string; line: number };
8
+
9
+ export type Placement<S extends Placeable> = {
10
+ /** Each sticker that found its place, with where its words start in the text. */
11
+ placed: Array<{ sticker: S; at: number }>;
12
+ /** Each sticker whose words are not where it says. */
13
+ lost: S[];
14
+ /** Words a sticker hides that show again with no sticker over them: how often, and the first sticker. */
15
+ shown: Array<{ quote: string; first: S; count: number }>;
16
+ };
17
+
18
+ /** Text with each run of whitespace as one space, trimmed, as words are compared. */
19
+ export function normalize(text: string): string {
20
+ return text.replace(/\s+/g, " ").trim();
21
+ }
22
+
23
+ /**
24
+ * Places each sticker in `text`, a slide's words as `normalize` leaves them. Stickers over the same
25
+ * words take their places in order: one with a prefix the first place that follows it, then one
26
+ * without the first place left.
27
+ */
28
+ export function placeStickers<S extends Placeable>(text: string, stickers: S[]): Placement<S> {
29
+ const placement: Placement<S> = { placed: [], lost: [], shown: [] };
30
+ const inOrder = stickers.toSorted((a, b) => a.line - b.line);
31
+ for (const [quote, same] of Map.groupBy(inOrder, (sticker) => normalize(sticker.quote))) {
32
+ const places = occurrences(text, quote);
33
+ const taken = new Set<number>();
34
+ const placed: S[] = [];
35
+ for (const sticker of [...same.filter((s) => s.prefix), ...same.filter((s) => !s.prefix)]) {
36
+ const prefix = sticker.prefix === undefined ? undefined : normalize(sticker.prefix);
37
+ const at = places.find(
38
+ (place) => !taken.has(place) && (prefix === undefined || follows(text, place, prefix)),
39
+ );
40
+ if (at === undefined) {
41
+ placement.lost.push(sticker);
42
+ continue;
43
+ }
44
+ taken.add(at);
45
+ placed.push(sticker);
46
+ placement.placed.push({ sticker, at });
47
+ }
48
+ const count = places.length - taken.size;
49
+ const first = placed.toSorted((a, b) => a.line - b.line)[0];
50
+ if (count > 0 && first) {
51
+ placement.shown.push({ quote, first, count });
52
+ }
53
+ }
54
+ placement.placed.sort((a, b) => a.sticker.line - b.sticker.line);
55
+ return placement;
56
+ }
57
+
58
+ /** Whether the words right before `place` in `text` end with `prefix`, as normalized. */
59
+ export function follows(text: string, place: number, prefix: string): boolean {
60
+ return text.slice(0, place).trimEnd().endsWith(prefix);
61
+ }
62
+
63
+ /**
64
+ * A letter or digit of a script written with spaces between words. Next to one, a quote that
65
+ * starts or ends with one is part of a longer word: `is` is not in "This". Japanese, Chinese,
66
+ * Korean, and Thai run on without spaces, so a quote is any run of their words.
67
+ */
68
+ const SPACED = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}]/u;
69
+
70
+ /** Where `quote` starts in `text`, each place it does, without overlaps, as whole words. */
71
+ export function occurrences(text: string, quote: string): number[] {
72
+ const found: number[] = [];
73
+ if (quote === "") {
74
+ return found;
75
+ }
76
+ const end = quote.length;
77
+ const opens = SPACED.test(quote[0] ?? "");
78
+ const closes = SPACED.test(quote[end - 1] ?? "");
79
+ for (let at = text.indexOf(quote); at !== -1; ) {
80
+ const whole =
81
+ !(opens && SPACED.test(text[at - 1] ?? "")) && !(closes && SPACED.test(text[at + end] ?? ""));
82
+ if (whole) {
83
+ found.push(at);
84
+ }
85
+ at = text.indexOf(quote, whole ? at + end : at + 1);
86
+ }
87
+ return found;
88
+ }
@@ -0,0 +1,30 @@
1
+ import type { CheckOutput } from "../../plugin-api.ts";
2
+ import type { Finding } from "./check.ts";
3
+ import type { RuleId } from "./manifest.ts";
4
+ import { MANIFEST } from "./manifest.ts";
5
+
6
+ /** Findings as the SARIF dek reads: the rule, the message, the place, and the hint. */
7
+ export function toSarif(findings: Finding[]): CheckOutput<RuleId> {
8
+ return {
9
+ version: "2.1.0",
10
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
11
+ runs: [
12
+ {
13
+ tool: { driver: { name: "dek-study", version: MANIFEST.version } },
14
+ results: findings.map((finding) => ({
15
+ ruleId: finding.rule,
16
+ message: { text: finding.message },
17
+ locations: [
18
+ {
19
+ physicalLocation: {
20
+ artifactLocation: { uri: finding.path },
21
+ region: { startLine: finding.line },
22
+ },
23
+ },
24
+ ],
25
+ properties: { hint: finding.hint },
26
+ })),
27
+ },
28
+ ],
29
+ };
30
+ }