@hajimism/dek 0.2.0 → 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.
- package/CHANGELOG.md +32 -0
- package/README.ja.md +3 -1
- package/README.md +3 -1
- package/package.json +3 -1
- package/skills/dek/SKILL.md +44 -0
- package/src/cli/annotations.ts +26 -0
- package/src/cli/commands.ts +66 -0
- package/src/cli/contract.ts +75 -4
- package/src/cli/files.ts +3 -3
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +3 -0
- package/src/cli/pptx.ts +20 -0
- package/src/cli/serve.ts +1 -1
- package/src/cli/show.ts +17 -2
- package/src/cli/text.ts +43 -0
- package/src/cli/theme.ts +7 -1
- package/src/core/annotation-rows.ts +176 -0
- package/src/core/annotations.ts +362 -0
- package/src/core/chrome.ts +3 -1
- package/src/core/diagnostic.ts +1 -0
- package/src/core/document.ts +17 -2
- package/src/core/html-scan.ts +54 -0
- package/src/core/html.ts +144 -14
- package/src/core/lint/slide-html.ts +92 -2
- package/src/core/lint.ts +8 -1
- package/src/core/live-protocol.ts +4 -1
- package/src/core/page.ts +7 -0
- package/src/core/playwright-module.d.ts +4 -1
- package/src/core/playwright-visual.ts +74 -1
- package/src/core/playwright.ts +51 -1
- package/src/core/pptx-package.ts +312 -0
- package/src/core/pptx-text.ts +363 -0
- package/src/core/pptx.ts +75 -0
- package/src/core/slide-measure.ts +1 -0
- package/src/core/sync.ts +13 -5
- package/src/core/theme-excerpt.ts +61 -9
- package/src/core/theme-facts.ts +12 -2
- package/src/core/zip.ts +77 -0
- package/src/runtime/annotate-pick.ts +144 -0
- package/src/runtime/annotate-place.ts +46 -0
- package/src/runtime/annotate-view.ts +752 -0
- package/src/runtime/annotate.ts +19 -0
- package/src/runtime/browser.ts +5 -1
- package/src/runtime/live.ts +1 -0
- package/src/runtime/player.ts +15 -7
- package/src/runtime/routes.ts +3 -0
- package/src/server/auth.ts +3 -1
- package/src/server/dev.ts +2 -1
- package/src/server/pages.ts +2 -1
- package/src/server/routes.ts +77 -4
package/src/core/html-scan.ts
CHANGED
|
@@ -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;
|
package/src/core/html.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { cutText } from "./annotation-rows.ts";
|
|
1
2
|
import { assetInliner } from "./assets.ts";
|
|
2
3
|
import { deckPaths } from "./deck-paths.ts";
|
|
3
4
|
import { escapeAttr, escapeHtml } from "./escape.ts";
|
|
5
|
+
import { type LineColumn, lineLocator } from "./lines.ts";
|
|
4
6
|
import { isInside } from "./path.ts";
|
|
5
7
|
import { type ProjectDeck, readDeckFile } from "./resolve.ts";
|
|
6
8
|
import { FALLBACK_LANG, type Section } from "./schema.ts";
|
|
@@ -41,6 +43,11 @@ export function hasSlideClass(className: string | null | undefined): boolean {
|
|
|
41
43
|
* written. A slide that never closes runs to `</body>`, or to the end.
|
|
42
44
|
*/
|
|
43
45
|
export function extractSlideSection(html: string): string | undefined {
|
|
46
|
+
return locateSlideSection(html)?.section;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The first slide section, as `extractSlideSection` reads it, and the offset it starts at. */
|
|
50
|
+
function locateSlideSection(html: string): { section: string; start: number } | undefined {
|
|
44
51
|
const mark = uniqueMark(html);
|
|
45
52
|
let found = false;
|
|
46
53
|
let closed = false;
|
|
@@ -68,10 +75,13 @@ export function extractSlideSection(html: string): string | undefined {
|
|
|
68
75
|
: undefined;
|
|
69
76
|
// The parser closes an unclosed slide at the end of its parent, past `</body>`.
|
|
70
77
|
if (slide !== undefined && /<\/section\s*>$/i.test(slide)) {
|
|
71
|
-
return slide;
|
|
78
|
+
return { section: slide, start };
|
|
72
79
|
}
|
|
73
80
|
const bodyClose = html.slice(start).search(/<\/body>/i);
|
|
74
|
-
return
|
|
81
|
+
return {
|
|
82
|
+
section: bodyClose >= 0 ? html.slice(start, start + bodyClose) : html.slice(start),
|
|
83
|
+
start,
|
|
84
|
+
};
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
/** A deck's slide markup, each file read at most once. */
|
|
@@ -86,32 +96,43 @@ export type DeckSlides = {
|
|
|
86
96
|
* stops on them, because at the venue a deck that shows beats one that does not.
|
|
87
97
|
*/
|
|
88
98
|
section(slug: string): string;
|
|
99
|
+
/** Where the written section is in `slides/<slug>.html`; none for a skeleton. */
|
|
100
|
+
source(slug: string): SlideSource | undefined;
|
|
89
101
|
};
|
|
90
102
|
|
|
103
|
+
/** A slide file and the offset its section starts at: the section is `file` from `start`. */
|
|
104
|
+
type SlideSource = { file: string; start: number };
|
|
105
|
+
|
|
91
106
|
export function deckSlides(deck: ProjectDeck): DeckSlides {
|
|
92
107
|
const paths = deckPaths(deck.dir);
|
|
93
|
-
const read = new Map<string, string | undefined>();
|
|
108
|
+
const read = new Map<string, (SlideSource & { section: string }) | undefined>();
|
|
94
109
|
let layouts: SkeletonLayouts | undefined;
|
|
95
110
|
const skeleton = (slug: string): string | undefined => {
|
|
96
111
|
layouts ??= deckLayouts(deck.dir);
|
|
97
112
|
return skeletonHtml(deck.deck, slug, layouts);
|
|
98
113
|
};
|
|
99
|
-
const
|
|
114
|
+
const located = (slug: string): (SlideSource & { section: string }) | undefined => {
|
|
100
115
|
if (!read.has(slug)) {
|
|
101
116
|
const path = paths.slide(slug, ".html");
|
|
102
117
|
// A slug from a URL may try to climb out of slides/.
|
|
103
|
-
const
|
|
118
|
+
const file =
|
|
104
119
|
isSafeSlideSlug(slug) && isInside(path, paths.slides)
|
|
105
120
|
? readDeckFile(deck.dir, path)
|
|
106
121
|
: undefined;
|
|
107
|
-
|
|
122
|
+
const found = file === undefined ? undefined : locateSlideSection(file);
|
|
123
|
+
read.set(slug, file !== undefined && found ? { file, ...found } : undefined);
|
|
108
124
|
}
|
|
109
125
|
return read.get(slug);
|
|
110
126
|
};
|
|
127
|
+
const written = (slug: string): string | undefined => located(slug)?.section;
|
|
111
128
|
return {
|
|
112
129
|
has: (slug) => written(slug) !== undefined,
|
|
113
130
|
written,
|
|
114
131
|
section: (slug) => written(slug) ?? extractSlideSection(skeleton(slug) ?? "") ?? "",
|
|
132
|
+
source(slug) {
|
|
133
|
+
const found = located(slug);
|
|
134
|
+
return found && { file: found.file, start: found.start };
|
|
135
|
+
},
|
|
115
136
|
};
|
|
116
137
|
}
|
|
117
138
|
|
|
@@ -140,15 +161,24 @@ export type SlideStamp = {
|
|
|
140
161
|
beat?: { index: number; step: string };
|
|
141
162
|
/** Inline the deck's files as data: URIs, for a page that stands alone. */
|
|
142
163
|
inline?: { deckDir: string };
|
|
164
|
+
/**
|
|
165
|
+
* Where the section is written, for the dev server's pages: every start tag then says where
|
|
166
|
+
* in the file it is, as `data-dek-source="<line>:<column>"`, and the classes it is written
|
|
167
|
+
* with, as `data-dek-class`, so annotate mode can name it as the file does whatever a script
|
|
168
|
+
* adds to it.
|
|
169
|
+
*/
|
|
170
|
+
source?: SlideSource;
|
|
143
171
|
};
|
|
144
172
|
|
|
145
173
|
/**
|
|
146
174
|
* One slide section with what the page needs on it, in one parser pass: its `data-slug` unless
|
|
147
|
-
* it names its own, its place as `--dek-slide-number` and `--dek-slide-count`,
|
|
148
|
-
*
|
|
175
|
+
* it names its own, its place as `--dek-slide-number` and `--dek-slide-count`, for a still page
|
|
176
|
+
* the classes and beat the player would have set, and for the dev server where each tag is
|
|
177
|
+
* written.
|
|
149
178
|
*/
|
|
150
179
|
export function stampSlide(html: string, stamp: SlideStamp): string {
|
|
151
180
|
let done = false;
|
|
181
|
+
const spots = stamp.source && startTagSpots(html, stamp.source);
|
|
152
182
|
return rewriteHtml(html, (rewriter) => {
|
|
153
183
|
rewriter.on("section", {
|
|
154
184
|
element(el) {
|
|
@@ -186,21 +216,121 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
|
|
|
186
216
|
if (stamp.inline) {
|
|
187
217
|
rewriter.on("*", assetInliner(stamp.inline.deckDir));
|
|
188
218
|
}
|
|
219
|
+
if (spots) {
|
|
220
|
+
// The same parser over the same markup meets the start tags in the same order.
|
|
221
|
+
let index = 0;
|
|
222
|
+
rewriter.on("*", {
|
|
223
|
+
element(el) {
|
|
224
|
+
const spot = spots[index++];
|
|
225
|
+
if (spot) {
|
|
226
|
+
el.setAttribute("data-dek-source", `${spot.line}:${spot.column}`);
|
|
227
|
+
if (spot.classes !== undefined) {
|
|
228
|
+
el.setAttribute("data-dek-class", spot.classes);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Where a start tag is written, its tag, its classes as written (none when it has no `class`),
|
|
239
|
+
* and the text inside it as written, whitespace collapsed.
|
|
240
|
+
*/
|
|
241
|
+
type TagSpot = LineColumn & { tag: string; classes?: string; text: string };
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Where each start tag of `html` is written in the file it was taken from, in document order,
|
|
245
|
+
* read before anything else touches the markup. A marker goes in front of each tag; where the
|
|
246
|
+
* markers land, less their own length, is where the tags were.
|
|
247
|
+
*/
|
|
248
|
+
function startTagSpots(html: string, source: SlideSource): TagSpot[] {
|
|
249
|
+
const mark = uniqueMark(html);
|
|
250
|
+
const tags: Array<{ tag: string; classes: string | null; text: string }> = [];
|
|
251
|
+
const open: number[] = [];
|
|
252
|
+
const marked = rewriteHtml(html, (rewriter) => {
|
|
253
|
+
rewriter.on("*", {
|
|
254
|
+
element(el) {
|
|
255
|
+
el.before(mark, { html: true });
|
|
256
|
+
const index = tags.length;
|
|
257
|
+
tags.push({ tag: el.tagName.toLowerCase(), classes: el.getAttribute("class"), text: "" });
|
|
258
|
+
if (el.canHaveContent) {
|
|
259
|
+
open.push(index);
|
|
260
|
+
el.onEndTag(() => {
|
|
261
|
+
open.splice(open.lastIndexOf(index), 1);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
rewriter.onDocument({
|
|
267
|
+
text(chunk) {
|
|
268
|
+
for (const index of open) {
|
|
269
|
+
const tag = tags[index];
|
|
270
|
+
if (tag) {
|
|
271
|
+
tag.text += chunk.text;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
});
|
|
189
276
|
});
|
|
277
|
+
const spot = lineLocator(source.file);
|
|
278
|
+
let offset = source.start;
|
|
279
|
+
return marked
|
|
280
|
+
.split(mark)
|
|
281
|
+
.slice(0, -1)
|
|
282
|
+
.map((before, index) => {
|
|
283
|
+
offset += before.length;
|
|
284
|
+
const { tag = "", classes = null, text = "" } = tags[index] ?? {};
|
|
285
|
+
const found = { ...spot(offset), tag, text: text.replace(/\s+/g, " ").trim() };
|
|
286
|
+
return classes === null
|
|
287
|
+
? found
|
|
288
|
+
: { ...found, classes: classes.split(/\s+/).filter(Boolean).join(" ") };
|
|
289
|
+
});
|
|
190
290
|
}
|
|
191
291
|
|
|
192
|
-
/**
|
|
193
|
-
|
|
292
|
+
/**
|
|
293
|
+
* An element of a written slide as annotate mode names it, whatever a script adds to it: where
|
|
294
|
+
* its start tag is written, `<line>:<column>`, its tag and classes as written, as `div.chevron`,
|
|
295
|
+
* and its text as written, cut short. The slide itself has no text: all of the slide names
|
|
296
|
+
* nothing.
|
|
297
|
+
*/
|
|
298
|
+
export type WrittenElement = { source: string; name: string; text: string };
|
|
299
|
+
|
|
300
|
+
/** Every element of the slide `slides/<slug>.html` writes, in document order; none for a skeleton. */
|
|
301
|
+
export function writtenElements(slides: DeckSlides, slug: string): WrittenElement[] | undefined {
|
|
302
|
+
const html = slides.written(slug);
|
|
303
|
+
const source = slides.source(slug);
|
|
304
|
+
if (html === undefined || source === undefined) {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
return startTagSpots(html, source).map((spot, index) => ({
|
|
308
|
+
source: `${spot.line}:${spot.column}`,
|
|
309
|
+
name: [spot.tag, ...(spot.classes ?? "").split(" ").filter(Boolean)].join("."),
|
|
310
|
+
text: index === 0 ? "" : cutText(spot.text),
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Every slide of the deck in script order, as the player pages hold them. `sources` says where
|
|
316
|
+
* each tag is written, for the dev server's pages.
|
|
317
|
+
*/
|
|
318
|
+
export function collectSlidesHtml(
|
|
319
|
+
deck: ProjectDeck,
|
|
320
|
+
options: { inline: boolean; sources?: boolean },
|
|
321
|
+
): string {
|
|
194
322
|
const slides = deckSlides(deck);
|
|
195
323
|
const { sections } = deck.deck;
|
|
196
324
|
return sections
|
|
197
|
-
.map((section, index) =>
|
|
198
|
-
|
|
325
|
+
.map((section, index) => {
|
|
326
|
+
const source = options.sources ? slides.source(section.slug) : undefined;
|
|
327
|
+
return stampSlide(slides.section(section.slug), {
|
|
199
328
|
slug: section.slug,
|
|
200
329
|
place: slidePlace(sections, index),
|
|
201
330
|
...(options.inline ? { inline: { deckDir: deck.dir } } : {}),
|
|
202
|
-
|
|
203
|
-
|
|
331
|
+
...(source ? { source } : {}),
|
|
332
|
+
});
|
|
333
|
+
})
|
|
204
334
|
.join("");
|
|
205
335
|
}
|
|
206
336
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
2
|
-
import type { HtmlAttribute, HtmlScan, SourceSpot } from "../html-scan.ts";
|
|
2
|
+
import type { HtmlAttribute, HtmlScan, Picture, SourceSpot } from "../html-scan.ts";
|
|
3
3
|
import type { Section } from "../schema.ts";
|
|
4
4
|
import { beatAt, formatStepChoices, resolveStep, stepChoices } from "../step.ts";
|
|
5
5
|
import { suggest } from "../suggest.ts";
|
|
6
|
+
import { RUNTIME_CLASSES } from "../theme-facts.ts";
|
|
6
7
|
import { isUrlAttribute } from "../url-attributes.ts";
|
|
7
8
|
import { assetRefDiagnostics } from "./asset-refs.ts";
|
|
8
9
|
import { RESERVED_MORPHS } from "./morph.ts";
|
|
@@ -52,6 +53,7 @@ export function lintSlideHtml(
|
|
|
52
53
|
...presentationDiagnostics(slide),
|
|
53
54
|
...emptyHeadingDiagnostics(slide),
|
|
54
55
|
...hiddenTextDiagnostics(slide),
|
|
56
|
+
...pictureDiagnostics(slide),
|
|
55
57
|
...(options.classes
|
|
56
58
|
? unknownClassDiagnostics(slide, options.classes, options.hasScript === true)
|
|
57
59
|
: []),
|
|
@@ -442,7 +444,85 @@ function hiddenTextDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[]
|
|
|
442
444
|
});
|
|
443
445
|
}
|
|
444
446
|
|
|
445
|
-
/**
|
|
447
|
+
/**
|
|
448
|
+
* DEK034: a picture with nothing for a screen reader to say: an `<img>` without `alt`, an `<svg>`
|
|
449
|
+
* with no text and no name, or an element whose `role="img"` has no name. Each is either content,
|
|
450
|
+
* which wants words, or decoration, which says so: `alt=""` or `aria-hidden="true"`. An SVG with
|
|
451
|
+
* text of its own is read as it is, so the hint never sends a labeled figure to `aria-hidden`,
|
|
452
|
+
* where DEK029 would warn of it.
|
|
453
|
+
*/
|
|
454
|
+
function pictureDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[] {
|
|
455
|
+
return scan.pictures.flatMap((picture) => {
|
|
456
|
+
const found = unnamedPicture(picture);
|
|
457
|
+
return found
|
|
458
|
+
? [
|
|
459
|
+
diag("DEK034", {
|
|
460
|
+
message: found.message,
|
|
461
|
+
path,
|
|
462
|
+
...spotOf(picture.element),
|
|
463
|
+
slug: section.slug,
|
|
464
|
+
hint: found.hint,
|
|
465
|
+
data: found.data,
|
|
466
|
+
}),
|
|
467
|
+
]
|
|
468
|
+
: [];
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function unnamedPicture({
|
|
473
|
+
element,
|
|
474
|
+
text,
|
|
475
|
+
titled,
|
|
476
|
+
}: Picture): { message: string; hint: string; data: Record<string, string> } | undefined {
|
|
477
|
+
const value = (name: string): string | undefined =>
|
|
478
|
+
element.attributes.find((attribute) => attribute.name === name)?.value;
|
|
479
|
+
const named = [value("aria-label"), value("aria-labelledby"), value("title")].some(
|
|
480
|
+
(name) => name !== undefined && name.trim() !== "",
|
|
481
|
+
);
|
|
482
|
+
const { tag } = element;
|
|
483
|
+
if (named || (tag === "img" && value("alt") !== undefined)) {
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
if ((value("role") ?? "").trim().toLowerCase().split(/\s+/)[0] === "img") {
|
|
487
|
+
if (titled) {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
const data = { tag, role: "img" };
|
|
491
|
+
return text
|
|
492
|
+
? {
|
|
493
|
+
message: `<${tag} role="img"> has no name, and role="img" keeps a screen reader from reading its text`,
|
|
494
|
+
hint: 'give it aria-label="…" saying what it shows, or remove role="img" so its text is read',
|
|
495
|
+
data,
|
|
496
|
+
}
|
|
497
|
+
: {
|
|
498
|
+
message: `<${tag} role="img"> has no name, so a screen reader cannot say what it shows`,
|
|
499
|
+
hint: 'give it aria-label="…" saying what it shows, or replace role="img" with aria-hidden="true" if it is decoration',
|
|
500
|
+
data,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
if (tag === "img") {
|
|
504
|
+
const src = value("src");
|
|
505
|
+
return {
|
|
506
|
+
message: `<img${src === undefined ? "" : ` src="${src}"`}> has no alt, so a screen reader cannot say what it shows`,
|
|
507
|
+
hint: 'add alt="…" saying what the picture shows, or alt="" if it is decoration',
|
|
508
|
+
data: src === undefined ? { tag } : { tag, src },
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
if (text) {
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
message: `<${tag}> has nothing to read and no name, so a screen reader skips what it shows`,
|
|
516
|
+
hint: 'give it role="img" and aria-label="…" saying what it shows, or aria-hidden="true" if it is decoration',
|
|
517
|
+
data: { tag },
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* DEK010: a class neither theme.css nor the slide's own stylesheet defines, or a state class the
|
|
523
|
+
* player sets, which markup that writes it would claim before the player does. `classes` holds
|
|
524
|
+
* neither.
|
|
525
|
+
*/
|
|
446
526
|
function unknownClassDiagnostics(
|
|
447
527
|
{ section, path, scan }: SlideHtml,
|
|
448
528
|
classes: Set<string>,
|
|
@@ -468,6 +548,16 @@ function unknownClassDiagnostics(
|
|
|
468
548
|
}
|
|
469
549
|
}
|
|
470
550
|
return [...unknown].map(([name, attribute]) => {
|
|
551
|
+
if (RUNTIME_CLASSES.has(name)) {
|
|
552
|
+
return diag("DEK010", {
|
|
553
|
+
message: `class "${name}" is a state the player sets at runtime, not a class for markup`,
|
|
554
|
+
path,
|
|
555
|
+
...spotOf(attribute),
|
|
556
|
+
slug: section.slug,
|
|
557
|
+
hint: `remove "${name}" from the class attribute: the player puts is-current on the slide on screen and is-shown on each data-step element once its beat plays. To style that state, select it in theme.css or slides/${section.slug}.css, as .slide.is-current [data-step].is-shown`,
|
|
558
|
+
data: { class: name, state: true },
|
|
559
|
+
});
|
|
560
|
+
}
|
|
471
561
|
const suggestion = suggest(name, known);
|
|
472
562
|
return diag("DEK010", {
|
|
473
563
|
message: `class "${name}" is not defined in theme.css`,
|
package/src/core/lint.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { voiceDiagnostics } from "./lint/voice.ts";
|
|
|
19
19
|
import { asResolvedDeck, type Project, type ProjectDeck } from "./resolve.ts";
|
|
20
20
|
import type { Section } from "./schema.ts";
|
|
21
21
|
import { evaluateSlideScripts, slideScriptsProblems } from "./slide-script-eval.ts";
|
|
22
|
+
import { RUNTIME_CLASSES } from "./theme-facts.ts";
|
|
22
23
|
|
|
23
24
|
export { unreadableScriptDiagnostics } from "./lint/script.ts";
|
|
24
25
|
export { silentCueDiagnostics } from "./lint/voice.ts";
|
|
@@ -122,7 +123,13 @@ function slideDiagnostics(ctx: LintContext, section: Section): Diagnostic[] {
|
|
|
122
123
|
deckDir: ctx.deck.dir,
|
|
123
124
|
skeleton: source.skeleton,
|
|
124
125
|
hasScript: script !== undefined,
|
|
125
|
-
classes:
|
|
126
|
+
classes:
|
|
127
|
+
ctx.theme &&
|
|
128
|
+
new Set(
|
|
129
|
+
[...ctx.theme.classes, ...(sheet ? cssClassNames(sheet) : [])].filter(
|
|
130
|
+
(name) => !RUNTIME_CLASSES.has(name),
|
|
131
|
+
),
|
|
132
|
+
),
|
|
126
133
|
// A theme with no layouts at all has nothing to check a data-layout against.
|
|
127
134
|
...(ctx.theme && ctx.theme.layouts.length > 0
|
|
128
135
|
? {
|
|
@@ -12,7 +12,9 @@ export type LiveEvent =
|
|
|
12
12
|
| { type: "reload-theme" }
|
|
13
13
|
| { type: "reload-script"; slugs: string[] }
|
|
14
14
|
| { type: "diagnostics"; diagnostics: Diagnostic[] }
|
|
15
|
-
| { type: "timeline" }
|
|
15
|
+
| { type: "timeline" }
|
|
16
|
+
/** The deck's annotations changed; a page that shows them asks for them again. */
|
|
17
|
+
| { type: "annotations" };
|
|
16
18
|
|
|
17
19
|
export type LiveEventType = LiveEvent["type"];
|
|
18
20
|
|
|
@@ -32,6 +34,7 @@ const FIELDS_OK: Record<LiveEventType, (event: Record<string, unknown>) => boole
|
|
|
32
34
|
"reload-script": (event) => isStrings(event.slugs),
|
|
33
35
|
diagnostics: (event) => Array.isArray(event.diagnostics),
|
|
34
36
|
timeline: () => true,
|
|
37
|
+
annotations: () => true,
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
/** Whether a message read off the stream is one this build of the page understands. */
|
package/src/core/page.ts
CHANGED
|
@@ -28,10 +28,17 @@ export const PAGE_ID = {
|
|
|
28
28
|
laser: "dek-laser",
|
|
29
29
|
laserToggle: "dek-laser-toggle",
|
|
30
30
|
markToggle: "dek-mark-toggle",
|
|
31
|
+
annotateToggle: "dek-annotate-toggle",
|
|
31
32
|
hint: "dek-hint",
|
|
32
33
|
announce: "dek-announce",
|
|
33
34
|
} as const;
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The event the player fires on `document` when the dev server says the deck's annotations
|
|
38
|
+
* changed, for annotate mode, which is compiled apart from the player, to ask for them again.
|
|
39
|
+
*/
|
|
40
|
+
export const ANNOTATIONS_CHANGED = "dek-annotations";
|
|
41
|
+
|
|
35
42
|
export type PageMode = "player" | "presenter" | "video";
|
|
36
43
|
|
|
37
44
|
/** The page's settings, written on <body> as `data-*` and read back once by the runtime. */
|
|
@@ -34,7 +34,10 @@ declare module "playwright" {
|
|
|
34
34
|
};
|
|
35
35
|
export type Browser = {
|
|
36
36
|
newPage(options?: { viewport?: Viewport }): Promise<Page>;
|
|
37
|
-
newContext(options?: {
|
|
37
|
+
newContext(options?: {
|
|
38
|
+
viewport?: Viewport;
|
|
39
|
+
deviceScaleFactor?: number;
|
|
40
|
+
}): Promise<BrowserContext>;
|
|
38
41
|
close(): Promise<void>;
|
|
39
42
|
};
|
|
40
43
|
export const chromium: {
|
|
@@ -21,12 +21,15 @@ import type {
|
|
|
21
21
|
PagesRequest,
|
|
22
22
|
PagesResponse,
|
|
23
23
|
PdfRequest,
|
|
24
|
+
PptxRequest,
|
|
25
|
+
PptxResponse,
|
|
24
26
|
ResponseTo,
|
|
25
27
|
VisualPage,
|
|
26
28
|
VisualRequest,
|
|
27
29
|
} from "./playwright.ts";
|
|
28
30
|
import { captureMotion } from "./playwright-motion.ts";
|
|
29
31
|
import { renderSheets } from "./playwright-sheet.ts";
|
|
32
|
+
import { measurePptxTextsInPage, PPTX_OWNER_ATTR } from "./pptx-text.ts";
|
|
30
33
|
import { pseudoTextBoxes } from "./pseudo-text.ts";
|
|
31
34
|
import { motionSheets } from "./sheet.ts";
|
|
32
35
|
import {
|
|
@@ -47,7 +50,11 @@ export async function runVisualRequest<R extends VisualRequest>(
|
|
|
47
50
|
browser: Browser,
|
|
48
51
|
request: R,
|
|
49
52
|
): Promise<ResponseTo<R>> {
|
|
50
|
-
|
|
53
|
+
// A PPTX's pictures are shown full screen, so they are drawn at twice the slide's size.
|
|
54
|
+
const context = await browser.newContext({
|
|
55
|
+
viewport: request.viewport,
|
|
56
|
+
...(request.kind === "pptx" ? { deviceScaleFactor: 2 } : {}),
|
|
57
|
+
});
|
|
51
58
|
try {
|
|
52
59
|
return (await answer(context, request)) as ResponseTo<R>;
|
|
53
60
|
} finally {
|
|
@@ -65,6 +72,8 @@ function answer(context: BrowserContext, request: VisualRequest) {
|
|
|
65
72
|
return shootMorph(context, request);
|
|
66
73
|
case "motion":
|
|
67
74
|
return shootMotion(context, request);
|
|
75
|
+
case "pptx":
|
|
76
|
+
return shootPptx(context, request);
|
|
68
77
|
default: {
|
|
69
78
|
const unknown: never = request;
|
|
70
79
|
throw new Error(`unknown visual request ${JSON.stringify(unknown)}`);
|
|
@@ -86,6 +95,70 @@ async function printPdf(context: BrowserContext, request: PdfRequest): Promise<D
|
|
|
86
95
|
return {};
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
/** Each slide's text measured and taken out, and the slide shot without it, page by page. */
|
|
99
|
+
async function shootPptx(context: BrowserContext, request: PptxRequest): Promise<PptxResponse> {
|
|
100
|
+
const slides = await visitInPages(
|
|
101
|
+
request.pages,
|
|
102
|
+
() => context.newPage(),
|
|
103
|
+
async (page, pageReq) => {
|
|
104
|
+
await page.setContent(pageReq.html, { waitUntil: "load" });
|
|
105
|
+
await page.evaluate(() => document.fonts.ready.then(() => undefined));
|
|
106
|
+
const measured = await page.evaluate(measurePptxTextsInPage);
|
|
107
|
+
const fonts = await platformFonts(page);
|
|
108
|
+
await page.screenshot({ path: pageReq.screenshotPath, fullPage: false });
|
|
109
|
+
return {
|
|
110
|
+
slug: pageReq.slug,
|
|
111
|
+
description: measured.description,
|
|
112
|
+
boxes: measured.boxes.map((box) => ({
|
|
113
|
+
...box,
|
|
114
|
+
runs: box.runs.map(({ owner, ...run }) => {
|
|
115
|
+
const font = fonts.get(owner);
|
|
116
|
+
return font === undefined ? run : { ...run, font };
|
|
117
|
+
}),
|
|
118
|
+
})),
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
return { slides };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The font Chromium drew each marked element's text with, by the element's number: the one that
|
|
127
|
+
* drew most of its glyphs, as the DevTools protocol reports it. A theme names a stack, or only
|
|
128
|
+
* `sans-serif`; this is the face that was found.
|
|
129
|
+
*/
|
|
130
|
+
async function platformFonts(page: Page): Promise<Map<number, string>> {
|
|
131
|
+
const fonts = new Map<number, string>();
|
|
132
|
+
const cdp = await page.context().newCDPSession(page);
|
|
133
|
+
try {
|
|
134
|
+
await cdp.send("DOM.enable");
|
|
135
|
+
await cdp.send("CSS.enable");
|
|
136
|
+
const { root } = (await cdp.send("DOM.getDocument", { depth: -1 })) as {
|
|
137
|
+
root: { nodeId: number };
|
|
138
|
+
};
|
|
139
|
+
const { nodeIds } = (await cdp.send("DOM.querySelectorAll", {
|
|
140
|
+
nodeId: root.nodeId,
|
|
141
|
+
selector: `[${PPTX_OWNER_ATTR}]`,
|
|
142
|
+
})) as { nodeIds: number[] };
|
|
143
|
+
for (const nodeId of nodeIds) {
|
|
144
|
+
const { attributes } = (await cdp.send("DOM.getAttributes", { nodeId })) as {
|
|
145
|
+
attributes: string[];
|
|
146
|
+
};
|
|
147
|
+
const owner = Number(attributes[attributes.indexOf(PPTX_OWNER_ATTR) + 1]);
|
|
148
|
+
const { fonts: used } = (await cdp.send("CSS.getPlatformFontsForNode", { nodeId })) as {
|
|
149
|
+
fonts: Array<{ familyName: string; glyphCount: number }>;
|
|
150
|
+
};
|
|
151
|
+
const main = [...used].sort((a, b) => b.glyphCount - a.glyphCount)[0];
|
|
152
|
+
if (main && Number.isInteger(owner)) {
|
|
153
|
+
fonts.set(owner, main.familyName);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
await cdp.detach();
|
|
158
|
+
}
|
|
159
|
+
return fonts;
|
|
160
|
+
}
|
|
161
|
+
|
|
89
162
|
/** The move between two slides, frozen at a moment and shot. */
|
|
90
163
|
async function shootMorph(context: BrowserContext, request: MorphRequest): Promise<DoneResponse> {
|
|
91
164
|
const page = await context.newPage();
|