@hajimism/dek 0.4.0 → 0.5.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 (77) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +8 -7
  5. package/src/cli/address.ts +59 -15
  6. package/src/cli/commands.ts +47 -14
  7. package/src/cli/contract.ts +49 -3
  8. package/src/cli/files.ts +1 -0
  9. package/src/cli/format.ts +5 -4
  10. package/src/cli/goto.ts +41 -10
  11. package/src/cli/keep-alive.ts +12 -6
  12. package/src/cli/lint.ts +32 -13
  13. package/src/cli/ls.ts +5 -1
  14. package/src/cli/main.ts +48 -13
  15. package/src/cli/new.ts +14 -1
  16. package/src/cli/ref.ts +1 -0
  17. package/src/cli/result.ts +42 -4
  18. package/src/cli/scope.ts +40 -1
  19. package/src/cli/shot.ts +77 -6
  20. package/src/cli/show.ts +15 -2
  21. package/src/cli/text.ts +61 -9
  22. package/src/cli/theme.ts +50 -16
  23. package/src/cli/tty.ts +16 -2
  24. package/src/cli/voice.ts +11 -6
  25. package/src/core/assets.ts +18 -10
  26. package/src/core/attribute-value.ts +188 -0
  27. package/src/core/build.ts +3 -3
  28. package/src/core/css-scan.ts +173 -39
  29. package/src/core/css-transform.ts +57 -11
  30. package/src/core/css.ts +19 -13
  31. package/src/core/foreign-text.ts +138 -0
  32. package/src/core/github.ts +1 -0
  33. package/src/core/html-entities.ts +318 -0
  34. package/src/core/html-scan.ts +49 -27
  35. package/src/core/html.ts +21 -15
  36. package/src/core/lint/asset-refs.ts +44 -6
  37. package/src/core/lint/context.ts +2 -0
  38. package/src/core/lint/shared-styles.ts +26 -1
  39. package/src/core/lint/slide-html.ts +21 -18
  40. package/src/core/lint/theme-css.ts +64 -3
  41. package/src/core/lint.ts +15 -2
  42. package/src/core/mv.ts +28 -18
  43. package/src/core/order.ts +14 -0
  44. package/src/core/path.ts +8 -2
  45. package/src/core/playwright-module.d.ts +12 -1
  46. package/src/core/playwright-motion.ts +1 -0
  47. package/src/core/playwright-sheet.ts +1 -0
  48. package/src/core/playwright-visual.ts +27 -10
  49. package/src/core/playwright.ts +77 -19
  50. package/src/core/pptx.ts +1 -0
  51. package/src/core/ref-name.ts +8 -3
  52. package/src/core/ref.ts +145 -47
  53. package/src/core/resolve.ts +3 -2
  54. package/src/core/rumdl.ts +51 -25
  55. package/src/core/safe-fs.ts +19 -2
  56. package/src/core/sandbox.ts +217 -0
  57. package/src/core/shot-cache.ts +1 -0
  58. package/src/core/slide-eval-worker.ts +1 -0
  59. package/src/core/slide-measure.ts +1 -0
  60. package/src/core/slide-script-eval.ts +2 -0
  61. package/src/core/spawn.ts +136 -35
  62. package/src/core/sync.ts +3 -12
  63. package/src/core/tar.ts +315 -0
  64. package/src/core/text-contrast.ts +2 -0
  65. package/src/core/toml-keys.ts +116 -51
  66. package/src/core/url-attributes.ts +27 -9
  67. package/src/core/visual.ts +28 -9
  68. package/src/core/voice.ts +2 -1
  69. package/src/server/auth.ts +21 -0
  70. package/src/server/dev.ts +13 -5
  71. package/src/server/lock.ts +43 -13
  72. package/src/server/serial.ts +8 -2
  73. package/src/video/mux.ts +27 -3
  74. package/src/video/recorder.ts +3 -2
  75. package/src/video/worker.ts +1 -0
  76. package/src/voice/engine.ts +81 -18
  77. package/tsconfig.json +1 -1
@@ -1,3 +1,9 @@
1
+ import {
2
+ attributeOf,
3
+ attributeTokens,
4
+ decodeAttribute,
5
+ trimAsciiWhitespace,
6
+ } from "./attribute-value.ts";
1
7
  import { hasSlideClass, uniqueMark } from "./html.ts";
2
8
  import { lineLocator } from "./lines.ts";
3
9
  import { attributeUrls, type UrlUse } from "./url-attributes.ts";
@@ -5,7 +11,7 @@ import { attributeUrls, type UrlUse } from "./url-attributes.ts";
5
11
  /** Where something is written: 1-based line, and 1-based column in UTF-16 units, as editors count. */
6
12
  export type SourceSpot = { line: number; column: number };
7
13
 
8
- /** One attribute as the parser read it, located at its name. */
14
+ /** One attribute, its value as the browser reads it, located at its name. */
9
15
  export type HtmlAttribute = SourceSpot & { name: string; value: string };
10
16
 
11
17
  /**
@@ -59,7 +65,8 @@ const UNDRAWN_TAGS = new Set(["title", "desc"]);
59
65
  /**
60
66
  * What a slide's markup uses and references, read in one pass of the real parser and located in
61
67
  * the source. The parser has no positions, so each start tag gets a marker in front of it; where
62
- * the markers land, less their own length, is where the tags were written.
68
+ * the markers land, less their own length, is where the tags were written. Each value is read as
69
+ * the browser reads it, its character references decoded.
63
70
  */
64
71
  export function scanSlideHtml(html: string): HtmlScan {
65
72
  const mark = uniqueMark(html);
@@ -81,9 +88,10 @@ export function scanSlideHtml(html: string): HtmlScan {
81
88
  element(el) {
82
89
  el.before(mark, { html: true });
83
90
  const tag = el.tagName.toLowerCase();
84
- const attributes = [...el.attributes].map(([name, value]): [string, string] => [
91
+ // As written: each is decoded once the tag is located, which maps a URL back into it.
92
+ const attributes = [...el.attributes].map(([name, written]): [string, string] => [
85
93
  name.toLowerCase(),
86
- value,
94
+ written,
87
95
  ]);
88
96
  if (CONTENT_TAGS.has(tag)) {
89
97
  for (const heading of open) {
@@ -99,7 +107,7 @@ export function scanSlideHtml(html: string): HtmlScan {
99
107
  undrawn--;
100
108
  });
101
109
  }
102
- } else if (el.getAttribute("aria-hidden")?.trim() === "true" && el.canHaveContent) {
110
+ } else if (isAriaHidden(attributeOf(el, "aria-hidden")) && el.canHaveContent) {
103
111
  const entry = { index: parsed.length, text: "" };
104
112
  hidden.push(entry);
105
113
  hiding = entry;
@@ -116,8 +124,8 @@ export function scanSlideHtml(html: string): HtmlScan {
116
124
  }
117
125
  } else if (
118
126
  !hiding &&
119
- el.getAttribute("aria-hidden")?.trim() !== "true" &&
120
- isPicture(tag, el.getAttribute("role"))
127
+ !isAriaHidden(attributeOf(el, "aria-hidden")) &&
128
+ isPicture(tag, attributeOf(el, "role"))
121
129
  ) {
122
130
  const entry = { index: parsed.length, text: false, titled: false };
123
131
  pictures.push(entry);
@@ -131,7 +139,7 @@ export function scanSlideHtml(html: string): HtmlScan {
131
139
  if (HEADING_TAGS.has(tag) && el.canHaveContent) {
132
140
  const heading = {
133
141
  index: parsed.length,
134
- content: (el.getAttribute("aria-label") ?? "").trim() !== "",
142
+ content: isName(attributeOf(el, "aria-label")),
135
143
  };
136
144
  headings.push(heading);
137
145
  open.push(heading);
@@ -139,7 +147,7 @@ export function scanSlideHtml(html: string): HtmlScan {
139
147
  open.splice(open.indexOf(heading), 1);
140
148
  });
141
149
  }
142
- if (tag === "section" && hasSlideClass(el.getAttribute("class")) && el.canHaveContent) {
150
+ if (tag === "section" && hasSlideClass(attributeOf(el, "class")) && el.canHaveContent) {
143
151
  slidesOpen++;
144
152
  el.onEndTag(() => {
145
153
  slidesOpen--;
@@ -181,23 +189,24 @@ export function scanSlideHtml(html: string): HtmlScan {
181
189
  inSlide: false,
182
190
  };
183
191
  const offsets = attributeOffsets(html, start);
192
+ const decoded = attributes.map(([name, written]) => ({ name, ...decodeAttribute(written) }));
184
193
  const element: HtmlElement = {
185
194
  tag,
186
195
  inSlide,
187
196
  ...spot(start),
188
- attributes: attributes.map(([name, value]) => ({
197
+ attributes: decoded.map(({ name, value }) => ({
189
198
  name,
190
199
  value,
191
200
  ...spot(offsets.get(name)?.name ?? start),
192
201
  })),
193
202
  };
194
203
  elements.push(element);
195
- for (const attribute of element.attributes) {
196
- const valueAt = offsets.get(attribute.name)?.value;
204
+ for (const { name, value, writtenAt } of decoded) {
205
+ const valueAt = offsets.get(name)?.value;
197
206
  refs.push(
198
- ...attributeRefs(element.tag, attribute).map(({ ref, offset }) => ({
207
+ ...attributeRefs(element.tag, name, value).map(({ ref, offset }) => ({
199
208
  ...ref,
200
- ...(valueAt === undefined ? spot(start) : spot(valueAt + offset)),
209
+ ...(valueAt === undefined ? spot(start) : spot(valueAt + writtenAt(offset))),
201
210
  })),
202
211
  );
203
212
  }
@@ -213,9 +222,7 @@ export function scanSlideHtml(html: string): HtmlScan {
213
222
  slides,
214
223
  ...(slug === undefined ? {} : { slug }),
215
224
  ...(layout === undefined ? {} : { layout }),
216
- classes: elements.flatMap((element) =>
217
- (attributeValue(element, "class") ?? "").split(/\s+/).filter(Boolean),
218
- ),
225
+ classes: elements.flatMap((element) => attributeTokens(attributeValue(element, "class"))),
219
226
  refs,
220
227
  emptyHeadings: headings
221
228
  .filter((heading) => !heading.content)
@@ -237,14 +244,28 @@ export function scanSlideHtml(html: string): HtmlScan {
237
244
  * or `"none"` says it is decoration, as `aria-hidden` does.
238
245
  */
239
246
  function isPicture(tag: string, role: string | null): boolean {
240
- const roles = (role ?? "").trim().toLowerCase().split(/\s+/);
241
- if (roles[0] === "presentation" || roles[0] === "none") {
247
+ const first = attributeTokens(role)[0]?.toLowerCase();
248
+ if (first === "presentation" || first === "none") {
242
249
  return false;
243
250
  }
244
- return tag === "img" || tag === "svg" || roles[0] === "img";
251
+ return tag === "img" || tag === "svg" || first === "img";
245
252
  }
246
253
 
247
- /** The value of `name` on `element`, as the parser read it. */
254
+ /** Whether `aria-hidden` marks decoration: `true`, give or take ASCII whitespace. */
255
+ function isAriaHidden(value: string | null): boolean {
256
+ return value !== null && trimAsciiWhitespace(value) === "true";
257
+ }
258
+
259
+ /**
260
+ * Whether a label gives a name. Chromium's accessible name keeps an `aria-label` of no-break
261
+ * spaces or other spaces outside ASCII, and drops one of ASCII whitespace alone; dek reads a
262
+ * `title` or an `aria-labelledby` of whitespace alone as no name too.
263
+ */
264
+ export function isName(label: string | null | undefined): boolean {
265
+ return label != null && trimAsciiWhitespace(label) !== "";
266
+ }
267
+
268
+ /** The value of `name` on `element`, as the browser reads it. */
248
269
  function attributeValue(element: HtmlElement, name: string): string | undefined {
249
270
  return element.attributes.find((attribute) => attribute.name === name)?.value;
250
271
  }
@@ -253,24 +274,25 @@ function isDefined<T>(value: T | undefined): value is T {
253
274
  return value !== undefined;
254
275
  }
255
276
 
256
- /** The URLs one attribute names, each with its offset into the value. */
277
+ /** The URLs one attribute names, each with its offset into the decoded value. */
257
278
  function attributeRefs(
258
279
  tag: string,
259
- attribute: HtmlAttribute,
280
+ attr: string,
281
+ value: string,
260
282
  ): Array<{ ref: Omit<HtmlRef, keyof SourceSpot>; offset: number }> {
261
- const named = attributeUrls(tag, attribute.name, attribute.value);
283
+ const named = attributeUrls(tag, attr, value);
262
284
  if (!named) {
263
285
  return [];
264
286
  }
265
287
  return named.urls.map(({ url, offset }) => ({
266
- ref: { tag, attr: attribute.name, value: url, use: named.use },
288
+ ref: { tag, attr, value: url, use: named.use },
267
289
  offset,
268
290
  }));
269
291
  }
270
292
 
271
293
  /**
272
- * Where each attribute of the start tag at `start` is written: its name, and its value when it
273
- * has one. Only positions come from here; names and values are the parser's.
294
+ * Where each attribute of the start tag at `start` is written: its name, and its value as
295
+ * written when it has one. Only positions come from here; names and values are the parser's.
274
296
  */
275
297
  function attributeOffsets(
276
298
  html: string,
package/src/core/html.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { cutText } from "./annotation-rows.ts";
2
2
  import { assetInliner } from "./assets.ts";
3
+ import {
4
+ attributeOf,
5
+ attributeTokens,
6
+ decodeAttribute,
7
+ trimAsciiWhitespace,
8
+ } from "./attribute-value.ts";
3
9
  import { deckPaths } from "./deck-paths.ts";
4
10
  import { escapeAttr, escapeHtml } from "./escape.ts";
5
11
  import { type LineColumn, lineLocator } from "./lines.ts";
@@ -33,7 +39,7 @@ export function htmlShell(options: {
33
39
  }
34
40
 
35
41
  export function hasSlideClass(className: string | null | undefined): boolean {
36
- return (className ?? "").split(/\s+/).some((token) => token.toLowerCase() === "slide");
42
+ return attributeTokens(className).some((token) => token.toLowerCase() === "slide");
37
43
  }
38
44
 
39
45
  /**
@@ -54,7 +60,7 @@ function locateSlideSection(html: string): { section: string; start: number } |
54
60
  const marked = rewriteHtml(html, (rewriter) => {
55
61
  rewriter.on("section", {
56
62
  element(el) {
57
- if (found || !hasSlideClass(el.getAttribute("class"))) {
63
+ if (found || !hasSlideClass(attributeOf(el, "class"))) {
58
64
  return;
59
65
  }
60
66
  found = true;
@@ -182,16 +188,17 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
182
188
  return rewriteHtml(html, (rewriter) => {
183
189
  rewriter.on("section", {
184
190
  element(el) {
185
- if (done || !hasSlideClass(el.getAttribute("class"))) {
191
+ if (done || !hasSlideClass(attributeOf(el, "class"))) {
186
192
  return;
187
193
  }
188
194
  done = true;
189
195
  if (el.getAttribute("data-slug") === null) {
190
196
  el.setAttribute("data-slug", stamp.slug);
191
197
  }
192
- // Ahead of the author's own style, so a slide can still set its own.
198
+ // Ahead of the author's own style, so a slide can still set its own. The style goes back
199
+ // into the markup, so it stays as written.
193
200
  const place = `--dek-slide-number: ${stamp.place.number}; --dek-slide-count: ${stamp.place.count}`;
194
- const own = el.getAttribute("style")?.trim();
201
+ const own = trimAsciiWhitespace(el.getAttribute("style") ?? "");
195
202
  el.setAttribute("style", own ? `${place}; ${own}` : place);
196
203
  if (stamp.shown) {
197
204
  addClass(el, "is-current");
@@ -206,7 +213,7 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
206
213
  if (shown) {
207
214
  rewriter.on("[data-step]", {
208
215
  element(el) {
209
- const step = el.getAttribute("data-step");
216
+ const step = attributeOf(el, "data-step");
210
217
  if (step !== null && shown.has(step)) {
211
218
  addClass(el, "is-shown");
212
219
  }
@@ -225,7 +232,7 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
225
232
  if (spot) {
226
233
  el.setAttribute("data-dek-source", `${spot.line}:${spot.column}`);
227
234
  if (spot.classes !== undefined) {
228
- el.setAttribute("data-dek-class", spot.classes);
235
+ el.setAttribute("data-dek-class", escapeAttr(spot.classes));
229
236
  }
230
237
  }
231
238
  },
@@ -235,8 +242,8 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
235
242
  }
236
243
 
237
244
  /**
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.
245
+ * Where a start tag is written, its tag, its classes as written and read as the browser reads
246
+ * them (none when it has no `class`), and the text inside it as written, whitespace collapsed.
240
247
  */
241
248
  type TagSpot = LineColumn & { tag: string; classes?: string; text: string };
242
249
 
@@ -254,7 +261,7 @@ function startTagSpots(html: string, source: SlideSource): TagSpot[] {
254
261
  element(el) {
255
262
  el.before(mark, { html: true });
256
263
  const index = tags.length;
257
- tags.push({ tag: el.tagName.toLowerCase(), classes: el.getAttribute("class"), text: "" });
264
+ tags.push({ tag: el.tagName.toLowerCase(), classes: attributeOf(el, "class"), text: "" });
258
265
  if (el.canHaveContent) {
259
266
  open.push(index);
260
267
  el.onEndTag(() => {
@@ -283,9 +290,7 @@ function startTagSpots(html: string, source: SlideSource): TagSpot[] {
283
290
  offset += before.length;
284
291
  const { tag = "", classes = null, text = "" } = tags[index] ?? {};
285
292
  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(" ") };
293
+ return classes === null ? found : { ...found, classes: attributeTokens(classes).join(" ") };
289
294
  });
290
295
  }
291
296
 
@@ -334,12 +339,13 @@ export function collectSlidesHtml(
334
339
  .join("");
335
340
  }
336
341
 
342
+ /** `className` added to the classes as written, unless the browser reads it among them already. */
337
343
  function addClass(el: HTMLRewriterTypes.Element, className: string): void {
338
344
  const current = el.getAttribute("class");
339
- const tokens = current ? current.split(/\s+/).filter(Boolean) : [];
340
- if (tokens.includes(className)) {
345
+ if (current !== null && attributeTokens(decodeAttribute(current).value).includes(className)) {
341
346
  return;
342
347
  }
348
+ const tokens = attributeTokens(current);
343
349
  tokens.push(className);
344
350
  el.setAttribute("class", tokens.join(" "));
345
351
  }
@@ -5,6 +5,7 @@ import { type Diagnostic, diag } from "../diagnostic.ts";
5
5
  import { isPlainWord } from "../escape.ts";
6
6
  import type { HtmlRef } from "../html-scan.ts";
7
7
  import { isInside } from "../path.ts";
8
+ import { trimUrl } from "../url-attributes.ts";
8
9
 
9
10
  /**
10
11
  * DEK020 to DEK023 for one reference, whether it comes from a slide's markup
@@ -56,9 +57,9 @@ export function assetRefDiagnostics(
56
57
  if (kind === "missing") {
57
58
  return [
58
59
  diag("DEK021", {
59
- message: `missing ${ref.tag === "img" ? "image" : "file"} "${ref.value}"`,
60
+ message: `missing ${ref.tag === "img" ? "image" : "file"} "${visibleSpaces(ref.value)}"`,
60
61
  ...location,
61
- ...missingImageHint(ref.value, deckDir),
62
+ ...(strayedSpaceHint(ref.value, deckDir, from) ?? missingImageHint(ref.value, deckDir)),
62
63
  data: { src: ref.value },
63
64
  }),
64
65
  ];
@@ -66,7 +67,7 @@ export function assetRefDiagnostics(
66
67
  if (!isCanonicalAssetPath(ref.value)) {
67
68
  return [
68
69
  diag("DEK023", {
69
- message: `asset "${ref.value}" must be referenced as assets/${posix.basename(ref.value.trim())}`,
70
+ message: `asset "${ref.value}" must be referenced as assets/${posix.basename(trimUrl(ref.value))}`,
70
71
  ...location,
71
72
  data: { src: ref.value },
72
73
  }),
@@ -76,19 +77,56 @@ export function assetRefDiagnostics(
76
77
  }
77
78
 
78
79
  function leavesFromEither(value: string, deckDir: string, from: string): boolean {
79
- const path = value.trim().split(/[?#]/)[0] ?? "";
80
+ const path = trimUrl(value).split(/[?#]/)[0] ?? "";
80
81
  return (
81
82
  path !== "" &&
82
83
  [resolve(deckDir, path), resolve(from, path)].some((target) => !isInside(target, deckDir))
83
84
  );
84
85
  }
85
86
 
87
+ /** `value` with each space but the plain one written as `\uXXXX`, so a message shows it. */
88
+ function visibleSpaces(value: string): string {
89
+ return value.replace(
90
+ /[^\S ]/g,
91
+ (char) => `\\u${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`,
92
+ );
93
+ }
94
+
95
+ /**
96
+ * The hint for a path that names a deck file once the spaces outside ASCII at its ends are gone,
97
+ * as `&nbsp;assets/a.png` does: the browser keeps those spaces, so the path names nothing.
98
+ */
99
+ function strayedSpaceHint(
100
+ value: string,
101
+ deckDir: string,
102
+ from: string,
103
+ ): { hint: string } | undefined {
104
+ const url = trimUrl(value);
105
+ const bare = url.trim();
106
+ if (bare === url || classifyAssetRef(bare, { deckDir, from }).kind !== "file") {
107
+ return undefined;
108
+ }
109
+ const at = url.indexOf(bare);
110
+ const before = url.slice(0, at);
111
+ const after = url.slice(at + bare.length);
112
+ const where = before && after ? "around" : before ? "before" : "after";
113
+ const spaces = [...new Set(before + after)].map((char) => {
114
+ const point = `U+${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
115
+ return char === "\u00A0" ? `no-break space (${point})` : `space (${point})`;
116
+ });
117
+ // The path is the slide's text: one that is no plain word is left out of the hint.
118
+ const path = isPlainWord(bare) ? bare : "the path";
119
+ return {
120
+ hint: `remove the ${spaces.join(" and the ")} ${where} ${path}: the browser keeps ${spaces.length > 1 ? "them" : "it"} as part of the path, which then names no file`,
121
+ };
122
+ }
123
+
86
124
  /**
87
125
  * Decks copy what they use from the project's `assets/` (so a deck stays whole when moved);
88
126
  * when the project has the file, the fix is that copy.
89
127
  */
90
128
  function missingImageHint(value: string, deckDir: string): { hint?: string } {
91
- const name = value.trim().split(/[?#]/)[0] ?? "";
129
+ const name = trimUrl(value).split(/[?#]/)[0] ?? "";
92
130
  if (!name.startsWith("assets/")) {
93
131
  return {};
94
132
  }
@@ -104,7 +142,7 @@ function missingImageHint(value: string, deckDir: string): { hint?: string } {
104
142
  function remoteHint(value: string): string {
105
143
  let name = "";
106
144
  try {
107
- name = posix.basename(new URL(value.trim(), "https://dek.invalid").pathname);
145
+ name = posix.basename(new URL(trimUrl(value), "https://dek.invalid").pathname);
108
146
  } catch {}
109
147
  // The name is the remote URL's: one that is no plain word is left out of the hint.
110
148
  return name && isPlainWord(name)
@@ -26,6 +26,8 @@ type SlideSource = { html: string; scan: HtmlScan; skeleton: boolean };
26
26
  export type LintContext = DeckFiles & {
27
27
  /** DEK016 and DEK017 of each slide script, by slug: evaluated before the rules run. */
28
28
  scripts: Map<string, Diagnostic[]>;
29
+ /** The source of each slide script lint evaluated, by slug. */
30
+ scriptCode: Map<string, string>;
29
31
  };
30
32
 
31
33
  /** The deck as read from disk, each file once. */
@@ -2,6 +2,7 @@ import { isKeyframesPrelude, selectorClasses, splitSelectorList, startsAtSlide }
2
2
  import { deckPaths } from "../deck-paths.ts";
3
3
  import { type Diagnostic, diag } from "../diagnostic.ts";
4
4
  import { listed } from "../prose.ts";
5
+ import { RUNTIME_CLASSES } from "../theme-facts.ts";
5
6
  import type { LintContext } from "./context.ts";
6
7
 
7
8
  /** How many slides may style something alike before it belongs in the theme: the third copy. */
@@ -56,6 +57,21 @@ function alsoReaches(ctx: LintContext, selector: string, setters: string[]): str
56
57
  });
57
58
  }
58
59
 
60
+ /**
61
+ * How many classes theme.css would define with a rule under `selector` moved into it, when that
62
+ * is more than the deck's `max_classes` (DEK013); nothing while the move stays within it.
63
+ */
64
+ function overBudget(ctx: LintContext, selector: string): number | undefined {
65
+ const defined = ctx.theme?.classes ?? new Set<string>();
66
+ const added = new Set(
67
+ selectorClasses(themeSelector(selector)).filter(
68
+ (name) => !defined.has(name) && !RUNTIME_CLASSES.has(name),
69
+ ),
70
+ );
71
+ const after = defined.size + added.size;
72
+ return added.size > 0 && after > ctx.deck.deck.max_classes ? after : undefined;
73
+ }
74
+
59
75
  /**
60
76
  * DEK026: the same declaration under the same selector in the stylesheets of SHARED_BY slides
61
77
  * or more. Each slide fixed alike on its own, as agents working on one slide each will, is one
@@ -146,18 +162,27 @@ export function sharedStyleDiagnostics(ctx: LintContext): Diagnostic[] {
146
162
  .join(
147
163
  "",
148
164
  )} without it, so check ${reaches.length === 1 ? "that slide" : "those slides"} after the move`;
165
+ // Following the hint must not trade this warning for DEK013's error.
166
+ const themeClasses = overBudget(ctx, selector);
167
+ const budget =
168
+ themeClasses === undefined
169
+ ? ""
170
+ : `; theme.css would then define ${themeClasses} classes, past max_classes ${ctx.deck.deck.max_classes}, so set \`max_classes: ${themeClasses}\` in the frontmatter too, or leave the rule in the slides`;
149
171
  return diag("DEK026", {
150
172
  message: `${rule} is also in ${listed(others)}`,
151
173
  path: paths.slide(slug, ".css"),
152
174
  line,
153
175
  slug,
154
- hint: `write ${themeRule} once in theme.css and delete it from ${listed(slugs.map(cssOf))}${affected}`,
176
+ hint: `write ${themeRule} once in theme.css and delete it from ${listed(slugs.map(cssOf))}${affected}${budget}`,
155
177
  data: {
156
178
  selector,
157
179
  declarations,
158
180
  slides: slugs,
159
181
  ...(atPath.length > 0 ? { atRules: atPath } : {}),
160
182
  ...(reaches.length > 0 ? { alsoReaches: reaches } : {}),
183
+ ...(themeClasses !== undefined
184
+ ? { themeClasses, limit: ctx.deck.deck.max_classes }
185
+ : {}),
161
186
  },
162
187
  });
163
188
  }),
@@ -1,6 +1,13 @@
1
+ import { attributeTokens } from "../attribute-value.ts";
1
2
  import { type Diagnostic, diag } from "../diagnostic.ts";
2
3
  import { isPlainWord } from "../escape.ts";
3
- import type { HtmlAttribute, HtmlScan, Picture, SourceSpot } from "../html-scan.ts";
4
+ import {
5
+ type HtmlAttribute,
6
+ type HtmlScan,
7
+ isName,
8
+ type Picture,
9
+ type SourceSpot,
10
+ } from "../html-scan.ts";
4
11
  import type { Section } from "../schema.ts";
5
12
  import { beatAt, formatStepChoices, resolveStep, stepChoices } from "../step.ts";
6
13
  import { suggest } from "../suggest.ts";
@@ -368,24 +375,22 @@ const EVENT_HANDLER_RE = /^on[a-z]+$/;
368
375
  /** Tags that load a URL as a document of its own, so an SVG there runs its scripts. */
369
376
  const DOCUMENT_TAGS = new Set(["iframe", "frame", "object", "embed"]);
370
377
 
378
+ /** What a relative URL resolves against; only a URL's own scheme can run script. */
379
+ const SOME_PAGE = "https://dek.invalid/";
380
+
371
381
  /**
372
382
  * The scheme of a URL that runs script, or undefined: `javascript:` and `vbscript:` always, and a
373
383
  * `data:` URL holding HTML, or SVG where a tag loads it as a document. An image's `data:` stays.
374
- * Read the way the URL parser reads it: tabs and newlines dropped, leading space trimmed, any case.
384
+ * The value comes decoded, and the URL parser reads it as the browser's does: C0 controls and
385
+ * spaces trimmed, tabs and newlines dropped anywhere, the scheme in any case.
375
386
  */
376
387
  function scriptUrlScheme(tag: string, value: string): string | undefined {
377
- const url = value
378
- .replace(/[\t\n\r]/g, "")
379
- .trimStart()
380
- .toLowerCase();
381
- if (url.startsWith("javascript:")) {
382
- return "javascript:";
383
- }
384
- if (url.startsWith("vbscript:")) {
385
- return "vbscript:";
388
+ const url = URL.parse(value, SOME_PAGE);
389
+ if (url?.protocol === "javascript:" || url?.protocol === "vbscript:") {
390
+ return url.protocol;
386
391
  }
387
- if (url.startsWith("data:")) {
388
- const type = url.slice("data:".length).split(/[;,]/, 1)[0]?.trim();
392
+ if (url?.protocol === "data:") {
393
+ const type = url.href.slice("data:".length).split(/[;,]/, 1)[0]?.trim().toLowerCase();
389
394
  const runs =
390
395
  type === "text/html" ||
391
396
  type === "application/xhtml+xml" ||
@@ -478,14 +483,12 @@ function unnamedPicture({
478
483
  }: Picture): { message: string; hint: string; data: Record<string, string> } | undefined {
479
484
  const value = (name: string): string | undefined =>
480
485
  element.attributes.find((attribute) => attribute.name === name)?.value;
481
- const named = [value("aria-label"), value("aria-labelledby"), value("title")].some(
482
- (name) => name !== undefined && name.trim() !== "",
483
- );
486
+ const named = [value("aria-label"), value("aria-labelledby"), value("title")].some(isName);
484
487
  const { tag } = element;
485
488
  if (named || (tag === "img" && value("alt") !== undefined)) {
486
489
  return undefined;
487
490
  }
488
- if ((value("role") ?? "").trim().toLowerCase().split(/\s+/)[0] === "img") {
491
+ if (attributeTokens(value("role"))[0]?.toLowerCase() === "img") {
489
492
  if (titled) {
490
493
  return undefined;
491
494
  }
@@ -543,7 +546,7 @@ function unknownClassDiagnostics(
543
546
  : `define it in slides/${section.slug}.css, or use one of: ${shown.join(", ")}${scriptHint}`;
544
547
  const unknown = new Map<string, HtmlAttribute>();
545
548
  for (const attribute of attributesNamed(scan, "class")) {
546
- for (const name of attribute.value.split(/\s+/).filter(Boolean)) {
549
+ for (const name of attributeTokens(attribute.value)) {
547
550
  if (!classes.has(name) && !unknown.has(name)) {
548
551
  unknown.set(name, attribute);
549
552
  }
@@ -12,6 +12,8 @@ import {
12
12
  } from "../css.ts";
13
13
  import { deckPaths } from "../deck-paths.ts";
14
14
  import { type Diagnostic, diag } from "../diagnostic.ts";
15
+ import { escapeRegExp } from "../escape.ts";
16
+ import { listed } from "../prose.ts";
15
17
  import { assetRefDiagnostics } from "./asset-refs.ts";
16
18
  import type { LintContext } from "./context.ts";
17
19
  import { isRawThemeValue, isRawTokenValue, REQUIRED_TOKENS, tokenSuggestion } from "./tokens.ts";
@@ -24,14 +26,18 @@ function missingThemeDiagnostic(ctx: LintContext): Diagnostic {
24
26
  return diag("DEK018", {
25
27
  message: "theme.css not found",
26
28
  path: deckPaths(ctx.deck.dir).theme,
27
- hint: "copy theme.css from the project root or another deck into the deck directory",
29
+ hint: "copy the project's template, ../../theme.css, or another deck's theme into the deck directory",
28
30
  });
29
31
  }
30
32
 
31
33
  /** DEK012, DEK013, DEK015, DEK014, and what its `url()`s load (DEK020 to DEK023) for theme.css. */
32
34
  function lintTheme(
33
35
  { path, sheet, classes, tokens }: NonNullable<LintContext["theme"]>,
34
- { maxClasses, deckDir }: { maxClasses: number; deckDir: string },
36
+ {
37
+ maxClasses,
38
+ deckDir,
39
+ usedBy,
40
+ }: { maxClasses: number; deckDir: string; usedBy: Map<string, string[]> | undefined },
35
41
  ): Diagnostic[] {
36
42
  const diagnostics: Diagnostic[] = [];
37
43
  // A rule inside `@media` reaches the page as surely as one outside it.
@@ -56,6 +62,7 @@ function lintTheme(
56
62
  diag("DEK013", {
57
63
  message: `theme.css has ${classCount} classes; limit is ${maxClasses}`,
58
64
  path,
65
+ hint: classBudgetHint(classCount, usedBy),
59
66
  data: { classes: classCount, limit: maxClasses },
60
67
  }),
61
68
  );
@@ -287,9 +294,63 @@ function themeScopeHint(selector: string): string {
287
294
  return `write it as ${scoped.join(", ")}`;
288
295
  }
289
296
 
297
+ /**
298
+ * How to bring the theme back within its class budget: first what frees a class without changing
299
+ * a slide, a class no slide uses and one only one slide uses, then raising the budget itself.
300
+ */
301
+ function classBudgetHint(classCount: number, usedBy: Map<string, string[]> | undefined): string {
302
+ if (usedBy === undefined) {
303
+ return `set \`max_classes: ${classCount}\` in the frontmatter`;
304
+ }
305
+ const unused = [...usedBy].filter(([, slugs]) => slugs.length === 0).map(([name]) => `.${name}`);
306
+ const single = [...usedBy].flatMap(([name, slugs]) =>
307
+ slugs.length === 1 ? [`.${name} into slides/${slugs[0]}.css`] : [],
308
+ );
309
+ return [
310
+ ...(unused.length > 0 ? [`delete ${listed(unused)}, which no slide uses`] : []),
311
+ ...(single.length > 0
312
+ ? [`move each class only one slide uses into that slide's stylesheet: ${listed(single)}`]
313
+ : []),
314
+ `set \`max_classes: ${classCount}\` in the frontmatter`,
315
+ ].join("; or ");
316
+ }
317
+
318
+ /**
319
+ * Each class the theme defines but `.slide`, which every slide is, to the slides that use it, in
320
+ * script order: in their markup, or anywhere in their script, which may set it with `classList`.
321
+ * None while a section has no HTML yet or only its skeleton: what it will use is unknown.
322
+ */
323
+ function slidesUsing(ctx: LintContext, classes: Set<string>): Map<string, string[]> | undefined {
324
+ const usedBy = new Map(
325
+ [...classes].filter((name) => name !== "slide").map((name) => [name, [] as string[]]),
326
+ );
327
+ for (const slug of ctx.sectionsBySlug.keys()) {
328
+ const source = ctx.slideSource(slug);
329
+ if (source === undefined || source.skeleton) {
330
+ return undefined;
331
+ }
332
+ const code = ctx.scriptCode.get(slug);
333
+ for (const [name, slugs] of usedBy) {
334
+ if (source.scan.classes.includes(name) || (code !== undefined && namesClass(code, name))) {
335
+ slugs.push(slug);
336
+ }
337
+ }
338
+ }
339
+ return usedBy;
340
+ }
341
+
342
+ /** Whether `code` holds `name` as a class token: a word of its own, wherever it stands. */
343
+ function namesClass(code: string, name: string): boolean {
344
+ return new RegExp(`(^|[^\\w-])${escapeRegExp(name)}($|[^\\w-])`).test(code);
345
+ }
346
+
290
347
  /** The theme's own findings, or DEK018 when there is no theme. */
291
348
  export function themeDiagnostics(ctx: LintContext): Diagnostic[] {
292
349
  return ctx.theme
293
- ? lintTheme(ctx.theme, { maxClasses: ctx.deck.deck.max_classes, deckDir: ctx.deck.dir })
350
+ ? lintTheme(ctx.theme, {
351
+ maxClasses: ctx.deck.deck.max_classes,
352
+ deckDir: ctx.deck.dir,
353
+ usedBy: slidesUsing(ctx, ctx.theme.classes),
354
+ })
294
355
  : [missingThemeDiagnostic(ctx)];
295
356
  }