@hajimism/dek 0.2.1 → 0.2.2

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 (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.ja.md +3 -1
  3. package/README.md +3 -1
  4. package/package.json +3 -1
  5. package/skills/dek/SKILL.md +44 -0
  6. package/src/cli/annotations.ts +26 -0
  7. package/src/cli/commands.ts +66 -0
  8. package/src/cli/contract.ts +75 -4
  9. package/src/cli/files.ts +3 -3
  10. package/src/cli/flags.ts +1 -1
  11. package/src/cli/format.ts +3 -0
  12. package/src/cli/pptx.ts +20 -0
  13. package/src/cli/show.ts +17 -2
  14. package/src/cli/text.ts +43 -0
  15. package/src/cli/theme.ts +7 -1
  16. package/src/core/annotation-rows.ts +176 -0
  17. package/src/core/annotations.ts +362 -0
  18. package/src/core/diagnostic.ts +1 -0
  19. package/src/core/html-scan.ts +54 -0
  20. package/src/core/html.ts +53 -8
  21. package/src/core/lint/slide-html.ts +92 -2
  22. package/src/core/lint.ts +8 -1
  23. package/src/core/live-protocol.ts +4 -1
  24. package/src/core/page.ts +6 -0
  25. package/src/core/playwright-module.d.ts +4 -1
  26. package/src/core/playwright-visual.ts +74 -1
  27. package/src/core/playwright.ts +51 -1
  28. package/src/core/pptx-package.ts +312 -0
  29. package/src/core/pptx-text.ts +363 -0
  30. package/src/core/pptx.ts +75 -0
  31. package/src/core/slide-measure.ts +1 -0
  32. package/src/core/sync.ts +13 -5
  33. package/src/core/theme-excerpt.ts +61 -9
  34. package/src/core/theme-facts.ts +12 -2
  35. package/src/core/zip.ts +77 -0
  36. package/src/runtime/annotate-pick.ts +2 -6
  37. package/src/runtime/annotate-view.ts +115 -101
  38. package/src/runtime/annotate.ts +5 -1
  39. package/src/runtime/browser.ts +5 -1
  40. package/src/runtime/live.ts +1 -0
  41. package/src/runtime/routes.ts +3 -0
  42. package/src/server/auth.ts +3 -1
  43. package/src/server/routes.ts +68 -1
  44. package/src/runtime/annotate-notes.ts +0 -207
@@ -0,0 +1,176 @@
1
+ // What annotate mode hands over, shared by the dev server, `dekc annotations`, and the page: a
2
+ // note on elements of a slide as the files have them now, how one is matched again after an
3
+ // edit, and the Markdown Copy writes. No DOM and no file system here, so all of it is decided by
4
+ // these functions alone.
5
+
6
+ /** A box on the slide, in logical pixels, from its top left. */
7
+ export type Box = { x: number; y: number; width: number; height: number };
8
+
9
+ /** A point on the slide, in logical pixels, from its top left. */
10
+ export type Point = { x: number; y: number };
11
+
12
+ /**
13
+ * Whether a note still stands as written: `open` while the slide's own files are as they were
14
+ * when it was written, `edited` once one of them changed, and `gone` once none of its elements is
15
+ * on the slide, or the slide is gone from the script.
16
+ */
17
+ export type AnnotationStatus = "open" | "edited" | "gone";
18
+
19
+ /** One element a note is about, where the file has it now. */
20
+ export type TargetRow = {
21
+ /** The slide's HTML file. */
22
+ path: string;
23
+ /** Where its start tag is now; where it was last found, when it is not found. */
24
+ line: number;
25
+ column: number;
26
+ found: boolean;
27
+ /** Its tag and its classes as written where it was last found, as `div.chevron`. */
28
+ name: string;
29
+ /** Its text as written when the note was written, cut short; empty for a shape. */
30
+ was: string;
31
+ /** Its text as written now; null when it is not found. */
32
+ text: string | null;
33
+ /** Its box on the slide when the note was written. */
34
+ box: Box;
35
+ /** Where the slide itself was clicked, when the note is about a place rather than an element. */
36
+ point?: Point;
37
+ };
38
+
39
+ /** One note as it stands now, numbered as the page's markers and Copy number it. */
40
+ export type AnnotationRow = {
41
+ number: number;
42
+ id: string;
43
+ slug: string;
44
+ /** The stop as `dekc shot --step` takes it: `0` as the slide arrives, else the beat. */
45
+ step: string;
46
+ status: AnnotationStatus;
47
+ /** What the human wrote; it may be empty, when the words go in the chat. */
48
+ text: string;
49
+ targets: TargetRow[];
50
+ /** The slide's own files that changed since the note was written. */
51
+ changed: string[];
52
+ /** The command that shows the slide at the note's beat. */
53
+ shot: string;
54
+ createdAt: string;
55
+ };
56
+
57
+ /** How long an element's text may be before it is cut short. */
58
+ const TEXT_MAX = 40;
59
+
60
+ /** An element's text, whitespace collapsed, as a note quotes it. */
61
+ export function cutText(text: string): string {
62
+ const squashed = text.replace(/\s+/g, " ").trim();
63
+ return squashed.length > TEXT_MAX ? `${squashed.slice(0, TEXT_MAX)}…` : squashed;
64
+ }
65
+
66
+ /** An element as a note names it, to match a note's target against. */
67
+ type Named = { source: string; name: string; text: string };
68
+
69
+ /**
70
+ * The element a note's target is now, after an edit: the one at the same place with the same
71
+ * name and text; else the only one with that name and text, wherever it moved; else the one at
72
+ * the same place with the same name, whose text was rewritten, as an agent does when it acts on
73
+ * the note; else the one at the same place with the same tag, whose classes were changed, as an
74
+ * agent does to restyle it. None when it cannot be told.
75
+ */
76
+ export function reanchor<T extends Named>(target: Named, present: readonly T[]): T | undefined {
77
+ const same = present.find(
78
+ (el) => el.source === target.source && el.name === target.name && el.text === target.text,
79
+ );
80
+ if (same) {
81
+ return same;
82
+ }
83
+ const moved = present.filter((el) => el.name === target.name && el.text === target.text);
84
+ if (moved.length === 1) {
85
+ return moved[0];
86
+ }
87
+ const named = present.find((el) => el.source === target.source && el.name === target.name);
88
+ if (named) {
89
+ return named;
90
+ }
91
+ const tag = (name: string): string => name.split(".")[0] ?? "";
92
+ return present.find((el) => el.source === target.source && tag(el.name) === tag(target.name));
93
+ }
94
+
95
+ /** A slide as the page lists it. */
96
+ export type SlideInfo = { slug: string; title: string };
97
+
98
+ /**
99
+ * The notes as one Markdown block for an agent, in the order given, grouped by slide and beat:
100
+ * each element with the file and line it is written at now, and how to see the slide at that
101
+ * beat. Written for an agent that has no dev server to ask, so it says what `dekc annotations`
102
+ * would.
103
+ */
104
+ export function formatNotes(
105
+ deck: string,
106
+ slides: readonly SlideInfo[],
107
+ rows: readonly AnnotationRow[],
108
+ ): string {
109
+ if (rows.length === 0) {
110
+ return "";
111
+ }
112
+ const groups = new Map<string, AnnotationRow[]>();
113
+ for (const row of rows) {
114
+ const key = `${row.slug}\n${row.step}`;
115
+ groups.set(key, [...(groups.get(key) ?? []), row]);
116
+ }
117
+
118
+ const lines = [
119
+ `## Notes on decks/${deck}`,
120
+ "",
121
+ "Boxes and points are in the slide's own pixels, from its top left.",
122
+ ];
123
+ for (const group of groups.values()) {
124
+ const first = group[0];
125
+ if (!first) {
126
+ continue;
127
+ }
128
+ const index = slides.findIndex((slide) => slide.slug === first.slug);
129
+ const title = slides[index]?.title;
130
+ lines.push(
131
+ "",
132
+ `### Slide ${index + 1} of ${slides.length}, ${first.slug}${title ? ` (${JSON.stringify(title)})` : ""}, ${beatLabel(first.step)}`,
133
+ );
134
+ for (const row of group) {
135
+ const where = (target: TargetRow): string =>
136
+ `${describe(target)} at decks/${deck}/slides/${row.slug}.html:${target.line}:${target.column} (${measure(target)})${target.found ? "" : ", as written before an edit; the line may have moved"}`;
137
+ lines.push("");
138
+ if (row.targets.length === 1 && row.targets[0]) {
139
+ lines.push(`${row.number}. ${where(row.targets[0])}`);
140
+ } else {
141
+ lines.push(`${row.number}. ${row.targets.length} elements:`);
142
+ lines.push(...row.targets.map((target) => ` - ${where(target)}`));
143
+ }
144
+ if (row.text.trim() !== "") {
145
+ lines.push(
146
+ ...row.text
147
+ .trim()
148
+ .split(/\r?\n/)
149
+ .map((line) => ` > ${line}`.trimEnd()),
150
+ );
151
+ }
152
+ }
153
+ lines.push("", `To see it: \`${first.shot}\``);
154
+ }
155
+ return `${lines.join("\n")}\n`;
156
+ }
157
+
158
+ function beatLabel(step: string): string {
159
+ if (step === "0") {
160
+ return "as it arrives";
161
+ }
162
+ return /^\d+$/.test(step) ? `at beat ${step}` : `at beat \`${step}\``;
163
+ }
164
+
165
+ function describe(target: TargetRow): string {
166
+ const text = target.text ?? target.was;
167
+ return text === "" ? target.name : `${target.name} ${JSON.stringify(text)}`;
168
+ }
169
+
170
+ function measure(target: TargetRow): string {
171
+ if (target.point) {
172
+ return `point ${target.point.x},${target.point.y}`;
173
+ }
174
+ const { x, y, width, height } = target.box;
175
+ return `box ${x},${y} ${width}×${height}`;
176
+ }
@@ -0,0 +1,362 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import {
5
+ type AnnotationRow,
6
+ type AnnotationStatus,
7
+ reanchor,
8
+ type TargetRow,
9
+ } from "./annotation-rows.ts";
10
+ import { deckPaths } from "./deck-paths.ts";
11
+ import { DekError } from "./error.ts";
12
+ import { deckSlides, type WrittenElement, writtenElements } from "./html.ts";
13
+ import { type ProjectDeck, readDeckFile } from "./resolve.ts";
14
+ import { readSourceIfExists, writeInside } from "./safe-fs.ts";
15
+ import { resolveStop } from "./step.ts";
16
+ import { formatZodIssues } from "./zod.ts";
17
+
18
+ const Box = z.object({
19
+ x: z.number().finite(),
20
+ y: z.number().finite(),
21
+ width: z.number().finite(),
22
+ height: z.number().finite(),
23
+ });
24
+ const Point = z.object({ x: z.number().finite(), y: z.number().finite() });
25
+
26
+ /**
27
+ * One element a note is about. `was` is its text as the file wrote it when the note was written;
28
+ * `source`, `name`, and `text` are where and what it was last found as, so a note follows its
29
+ * element through one edit after another.
30
+ */
31
+ const Target = z.object({
32
+ source: z.string(),
33
+ name: z.string(),
34
+ was: z.string(),
35
+ text: z.string(),
36
+ box: Box,
37
+ point: Point.optional(),
38
+ });
39
+
40
+ /** The slide's own files, each by a hash of what it held when the note was written. */
41
+ const SLIDE_FILES = [".html", ".css", ".ts"] as const;
42
+
43
+ /**
44
+ * A note as it is kept: what the human wrote on which elements of a slide at which beat, and the
45
+ * slide's own files as they were then, so a reader can tell an edited slide from one untouched.
46
+ */
47
+ const Annotation = z.object({
48
+ id: z.string().min(1),
49
+ slug: z.string(),
50
+ step: z.string(),
51
+ text: z.string(),
52
+ createdAt: z.string(),
53
+ files: z.record(z.string(), z.string()),
54
+ targets: z.array(Target).min(1),
55
+ });
56
+
57
+ export type Annotation = z.infer<typeof Annotation>;
58
+
59
+ /** Every deck's notes, by deck name, in one file of the project's. */
60
+ const AnnotationsFile = z.object({
61
+ version: z.literal(1),
62
+ decks: z.record(z.string(), z.array(Annotation)),
63
+ });
64
+
65
+ type AnnotationsFile = z.infer<typeof AnnotationsFile>;
66
+
67
+ /**
68
+ * What the page asks of the notes: add one on elements of the slide, given by where each start
69
+ * tag is written; change one's words; remove one; clear the deck's; or put back what a clear took.
70
+ */
71
+ export const AnnotationOp = z.discriminatedUnion("op", [
72
+ z.object({
73
+ op: z.literal("add"),
74
+ slug: z.string(),
75
+ step: z.string(),
76
+ text: z.string(),
77
+ targets: z.array(z.object({ source: z.string(), box: Box, point: Point.optional() })).min(1),
78
+ }),
79
+ z.object({ op: z.literal("edit"), id: z.string(), text: z.string() }),
80
+ z.object({ op: z.literal("remove"), id: z.string() }),
81
+ z.object({ op: z.literal("clear") }),
82
+ z.object({ op: z.literal("restore"), annotations: z.array(Annotation) }),
83
+ ]);
84
+
85
+ export type AnnotationOp = z.infer<typeof AnnotationOp>;
86
+
87
+ /** Where the notes are kept: beside the marks, out of every deck. */
88
+ export function annotationsPath(root: string): string {
89
+ return join(root, ".dek", "annotations.json");
90
+ }
91
+
92
+ /**
93
+ * Do what the page asks, and answer with the deck's notes as they stand now; a clear also hands
94
+ * back what it took, to put back. Input that names nothing on the slide as it is now, as a page
95
+ * opened before an edit can send, is refused and changes nothing.
96
+ */
97
+ export function applyAnnotationOp(
98
+ root: string,
99
+ deck: ProjectDeck,
100
+ op: AnnotationOp,
101
+ now: Date = new Date(),
102
+ ): { annotations: AnnotationRow[]; removed?: Annotation[] } {
103
+ const file = readAnnotationsFile(root);
104
+ const notes = file.decks[deck.name] ?? [];
105
+ const known = (id: string): Annotation => {
106
+ const found = notes.find((note) => note.id === id);
107
+ if (!found) {
108
+ throw new DekError(`no note ${id} on deck "${deck.name}"`, {
109
+ hint: "reload the page: the notes changed since it read them",
110
+ });
111
+ }
112
+ return found;
113
+ };
114
+ let next: Annotation[];
115
+ let removed: Annotation[] | undefined;
116
+ switch (op.op) {
117
+ case "add":
118
+ next = [...notes, newAnnotation(deck, op, now)];
119
+ break;
120
+ case "edit": {
121
+ const target = known(op.id);
122
+ next = notes.map((note) => (note === target ? { ...note, text: op.text } : note));
123
+ break;
124
+ }
125
+ case "remove": {
126
+ const target = known(op.id);
127
+ next = notes.filter((note) => note !== target);
128
+ break;
129
+ }
130
+ case "clear":
131
+ next = [];
132
+ removed = notes;
133
+ break;
134
+ case "restore": {
135
+ const ids = new Set(notes.map((note) => note.id));
136
+ next = [...notes, ...op.annotations.filter((note) => !ids.has(note.id))];
137
+ break;
138
+ }
139
+ }
140
+ writeAnnotationsFile(root, deck.name, next, file);
141
+ return { annotations: listAnnotations(root, deck), ...(removed ? { removed } : {}) };
142
+ }
143
+
144
+ /**
145
+ * The deck's notes in talk order, each element where the file has it now. Where each was found
146
+ * is kept for the next reader, so a note follows its element through edit after edit.
147
+ */
148
+ export function listAnnotations(root: string, deck: ProjectDeck): AnnotationRow[] {
149
+ const file = readAnnotationsFile(root);
150
+ const notes = file.decks[deck.name] ?? [];
151
+ const slides = deckSlides(deck);
152
+ const present = new Map<string, WrittenElement[] | undefined>();
153
+ const elementsOf = (slug: string): WrittenElement[] | undefined => {
154
+ if (!present.has(slug)) {
155
+ const inScript = deck.deck.sections.some((section) => section.slug === slug);
156
+ present.set(slug, inScript ? writtenElements(slides, slug) : undefined);
157
+ }
158
+ return present.get(slug);
159
+ };
160
+ const followed = notes.map((note) => follow(note, elementsOf(note.slug)));
161
+ const moved = followed.some(({ note }, index) => note !== notes[index]);
162
+ if (moved) {
163
+ writeAnnotationsFile(
164
+ root,
165
+ deck.name,
166
+ followed.map(({ note }) => note),
167
+ file,
168
+ );
169
+ }
170
+ return order(deck, followed).map(({ note, found }, index) => toRow(deck, note, found, index + 1));
171
+ }
172
+
173
+ /** Drop the deck's notes, the other decks' kept; how many there were. */
174
+ export function clearAnnotations(root: string, deck: ProjectDeck): number {
175
+ const file = readAnnotationsFile(root);
176
+ const count = file.decks[deck.name]?.length ?? 0;
177
+ if (count > 0) {
178
+ writeAnnotationsFile(root, deck.name, [], file);
179
+ }
180
+ return count;
181
+ }
182
+
183
+ type AddOp = Extract<AnnotationOp, { op: "add" }>;
184
+
185
+ /** A note as the slide's files have it now, refused when they do not have what it names. */
186
+ function newAnnotation(deck: ProjectDeck, op: AddOp, now: Date): Annotation {
187
+ const section = deck.deck.sections.find((entry) => entry.slug === op.slug);
188
+ if (!section) {
189
+ throw new DekError(`no slide "${op.slug}" in deck "${deck.name}"`, {
190
+ hint: "reload the page: the script changed since it opened",
191
+ });
192
+ }
193
+ if (resolveStop(section.beats, op.step) === undefined) {
194
+ throw new DekError(`no beat "${op.step}" on slide "${op.slug}"`, {
195
+ hint: "reload the page: the script changed since it opened",
196
+ });
197
+ }
198
+ const elements = writtenElements(deckSlides(deck), op.slug) ?? [];
199
+ const targets = op.targets.map((target) => {
200
+ const element = elements.find((el) => el.source === target.source);
201
+ if (!element) {
202
+ throw new DekError(`slides/${op.slug}.html has no element at ${target.source}`, {
203
+ hint: "pick it again: the slide changed since the page drew it",
204
+ });
205
+ }
206
+ return {
207
+ source: element.source,
208
+ name: element.name,
209
+ was: element.text,
210
+ text: element.text,
211
+ box: target.box,
212
+ ...(target.point ? { point: target.point } : {}),
213
+ };
214
+ });
215
+ return {
216
+ id: crypto.randomUUID(),
217
+ slug: op.slug,
218
+ step: op.step,
219
+ text: op.text,
220
+ createdAt: now.toISOString(),
221
+ files: slideFileHashes(deck, op.slug),
222
+ targets,
223
+ };
224
+ }
225
+
226
+ /** A note matched again to the slide as it is now, and which of its elements were found. */
227
+ type Followed = { note: Annotation; found: boolean[] };
228
+
229
+ function follow(note: Annotation, present: WrittenElement[] | undefined): Followed {
230
+ const matches = note.targets.map((target) => (present ? reanchor(target, present) : undefined));
231
+ const changed = note.targets.some((target, index) => {
232
+ const match = matches[index];
233
+ return (
234
+ match !== undefined &&
235
+ (match.source !== target.source || match.name !== target.name || match.text !== target.text)
236
+ );
237
+ });
238
+ return {
239
+ note: changed
240
+ ? {
241
+ ...note,
242
+ targets: note.targets.map((target, index) => {
243
+ const match = matches[index];
244
+ return match
245
+ ? { ...target, source: match.source, name: match.name, text: match.text }
246
+ : target;
247
+ }),
248
+ }
249
+ : note,
250
+ found: matches.map((match) => match !== undefined),
251
+ };
252
+ }
253
+
254
+ /** By slide and beat in talk order, then as they were written; past the deck, at the end. */
255
+ function order(deck: ProjectDeck, followed: Followed[]): Followed[] {
256
+ const { sections } = deck.deck;
257
+ const rank = ({ note }: Followed): [number, number] => {
258
+ const slide = sections.findIndex((section) => section.slug === note.slug);
259
+ const stop = slide < 0 ? undefined : resolveStop(sections[slide]?.beats ?? [], note.step);
260
+ return [slide < 0 ? sections.length : slide, stop ?? Number.MAX_SAFE_INTEGER];
261
+ };
262
+ return followed
263
+ .map((entry, index) => ({ entry, index, rank: rank(entry) }))
264
+ .sort((a, b) => a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1] || a.index - b.index)
265
+ .map(({ entry }) => entry);
266
+ }
267
+
268
+ function toRow(
269
+ deck: ProjectDeck,
270
+ note: Annotation,
271
+ found: boolean[],
272
+ number: number,
273
+ ): AnnotationRow {
274
+ const paths = deckPaths(deck.dir);
275
+ const html = paths.slide(note.slug, ".html");
276
+ const targets: TargetRow[] = note.targets.map((target, index) => {
277
+ const [line = 0, column = 0] = target.source.split(":").map(Number);
278
+ const isFound = found[index] === true;
279
+ return {
280
+ path: html,
281
+ line,
282
+ column,
283
+ found: isFound,
284
+ name: target.name,
285
+ was: target.was,
286
+ text: isFound ? target.text : null,
287
+ box: target.box,
288
+ ...(target.point ? { point: target.point } : {}),
289
+ };
290
+ });
291
+ const now = slideFileHashes(deck, note.slug);
292
+ const changed = SLIDE_FILES.filter((ext) => now[ext] !== note.files[ext]).map((ext) =>
293
+ paths.slide(note.slug, ext),
294
+ );
295
+ const status: AnnotationStatus = !found.some(Boolean)
296
+ ? "gone"
297
+ : changed.length > 0
298
+ ? "edited"
299
+ : "open";
300
+ return {
301
+ number,
302
+ id: note.id,
303
+ slug: note.slug,
304
+ step: note.step,
305
+ status,
306
+ text: note.text,
307
+ targets,
308
+ changed,
309
+ shot: `dekc shot ${deck.name} ${note.slug} --step ${note.step}`,
310
+ createdAt: note.createdAt,
311
+ };
312
+ }
313
+
314
+ /** A hash of each of the slide's own files there is, by its extension. */
315
+ function slideFileHashes(deck: ProjectDeck, slug: string): Record<string, string> {
316
+ const paths = deckPaths(deck.dir);
317
+ return Object.fromEntries(
318
+ SLIDE_FILES.flatMap((ext) => {
319
+ const text = readDeckFile(deck.dir, paths.slide(slug, ext));
320
+ return text === undefined
321
+ ? []
322
+ : [[ext, createHash("sha256").update(text).digest("hex").slice(0, 16)]];
323
+ }),
324
+ );
325
+ }
326
+
327
+ function readAnnotationsFile(root: string): AnnotationsFile {
328
+ const path = annotationsPath(root);
329
+ const text = readSourceIfExists(path, root);
330
+ if (text === undefined) {
331
+ return { version: 1, decks: {} };
332
+ }
333
+ let value: unknown;
334
+ try {
335
+ value = JSON.parse(text);
336
+ } catch (error) {
337
+ throw unreadable(path, error instanceof Error ? error.message : String(error));
338
+ }
339
+ const parsed = AnnotationsFile.safeParse(value);
340
+ if (!parsed.success) {
341
+ throw unreadable(path, formatZodIssues(parsed.error));
342
+ }
343
+ return parsed.data;
344
+ }
345
+
346
+ function unreadable(path: string, why: string): DekError {
347
+ return new DekError(`.dek/annotations.json does not read: ${why}`, {
348
+ path,
349
+ hint: "delete .dek/annotations.json to start over; every deck's notes go with it",
350
+ });
351
+ }
352
+
353
+ function writeAnnotationsFile(
354
+ root: string,
355
+ deck: string,
356
+ notes: Annotation[],
357
+ file: AnnotationsFile,
358
+ ): void {
359
+ const { [deck]: _old, ...others } = file.decks;
360
+ const decks = notes.length > 0 ? { ...others, [deck]: notes } : others;
361
+ writeInside(annotationsPath(root), `${JSON.stringify({ version: 1, decks }, null, 2)}\n`, root);
362
+ }
@@ -53,6 +53,7 @@ export const RULES = {
53
53
  DEK031: { severity: "error", scopes: ["slide"] },
54
54
  DEK032: { severity: "error", scopes: ["slide"] },
55
55
  DEK033: { severity: "error", scopes: ["slide"] },
56
+ DEK034: { severity: "error", scopes: ["slide"] },
56
57
  DEK040: { severity: "warning", scopes: ["slide"] },
57
58
  DEK041: { severity: "warning", scopes: ["deck"] },
58
59
  DEK042: { severity: "warning", scopes: ["slide"] },
@@ -39,8 +39,17 @@ export type HtmlScan = {
39
39
  * collapsed. An SVG's `<title>` and `<desc>` describe it and are never drawn, so they hold none.
40
40
  */
41
41
  hiddenTexts: Array<{ element: HtmlElement; text: string }>;
42
+ /**
43
+ * Each outermost picture a screen reader is not told to skip: an `<img>`, an `<svg>`, or an
44
+ * element with `role="img"`, none of them under `aria-hidden="true"`. `text` says whether it
45
+ * holds any text to read, and `titled` whether a `<title>` inside it names it.
46
+ */
47
+ pictures: Picture[];
42
48
  };
43
49
 
50
+ /** A picture in the markup, with what it holds that a screen reader could say. */
51
+ export type Picture = { element: HtmlElement; text: boolean; titled: boolean };
52
+
44
53
  const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
45
54
  /** Elements that give a heading something to show without any text. */
46
55
  const CONTENT_TAGS = new Set(["img", "svg", "picture", "video", "canvas", "object", "math"]);
@@ -62,6 +71,10 @@ export function scanSlideHtml(html: string): HtmlScan {
62
71
  // The outermost aria-hidden element still open, and how many undrawn elements are open in it.
63
72
  let hiding: { index: number; text: string } | undefined;
64
73
  let undrawn = 0;
74
+ // The outermost picture still open, and whether a <title> inside it is open.
75
+ const pictures: Array<{ index: number; text: boolean; titled: boolean }> = [];
76
+ let picture: { index: number; text: boolean; titled: boolean } | undefined;
77
+ let titles = 0;
65
78
 
66
79
  const transformed = new HTMLRewriter()
67
80
  .on("*", {
@@ -94,6 +107,27 @@ export function scanSlideHtml(html: string): HtmlScan {
94
107
  hiding = undefined;
95
108
  });
96
109
  }
110
+ if (picture) {
111
+ if (tag === "title" && el.canHaveContent) {
112
+ titles++;
113
+ el.onEndTag(() => {
114
+ titles--;
115
+ });
116
+ }
117
+ } else if (
118
+ !hiding &&
119
+ el.getAttribute("aria-hidden")?.trim() !== "true" &&
120
+ isPicture(tag, el.getAttribute("role"))
121
+ ) {
122
+ const entry = { index: parsed.length, text: false, titled: false };
123
+ pictures.push(entry);
124
+ if (el.canHaveContent) {
125
+ picture = entry;
126
+ el.onEndTag(() => {
127
+ picture = undefined;
128
+ });
129
+ }
130
+ }
97
131
  if (HEADING_TAGS.has(tag) && el.canHaveContent) {
98
132
  const heading = {
99
133
  index: parsed.length,
@@ -119,6 +153,10 @@ export function scanSlideHtml(html: string): HtmlScan {
119
153
  if (hiding && undrawn === 0) {
120
154
  hiding.text += chunk.text;
121
155
  }
156
+ if (picture && !hiding && chunk.text.trim() !== "") {
157
+ picture.text = true;
158
+ picture.titled ||= titles > 0;
159
+ }
122
160
  if (open.length > 0 && chunk.text.trim() !== "") {
123
161
  for (const heading of open) {
124
162
  heading.content = true;
@@ -187,9 +225,25 @@ export function scanSlideHtml(html: string): HtmlScan {
187
225
  const squashed = text.replace(/\s+/g, " ").trim();
188
226
  return element && squashed !== "" ? [{ element, text: squashed }] : [];
189
227
  }),
228
+ pictures: pictures.flatMap(({ index, text, titled }) => {
229
+ const element = elements[index];
230
+ return element ? [{ element, text, titled }] : [];
231
+ }),
190
232
  };
191
233
  }
192
234
 
235
+ /**
236
+ * Whether an element draws a picture a screen reader must be told about. `role="presentation"`
237
+ * or `"none"` says it is decoration, as `aria-hidden` does.
238
+ */
239
+ function isPicture(tag: string, role: string | null): boolean {
240
+ const roles = (role ?? "").trim().toLowerCase().split(/\s+/);
241
+ if (roles[0] === "presentation" || roles[0] === "none") {
242
+ return false;
243
+ }
244
+ return tag === "img" || tag === "svg" || roles[0] === "img";
245
+ }
246
+
193
247
  /** The value of `name` on `element`, as the parser read it. */
194
248
  function attributeValue(element: HtmlElement, name: string): string | undefined {
195
249
  return element.attributes.find((attribute) => attribute.name === name)?.value;