@hajimism/dek 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.ja.md +2 -2
  3. package/README.md +2 -2
  4. package/package.json +5 -3
  5. package/skills/dek/SKILL.md +10 -3
  6. package/skills/dek-design/SKILL.md +42 -0
  7. package/src/cli/address.ts +95 -18
  8. package/src/cli/build.ts +2 -2
  9. package/src/cli/check.ts +2 -1
  10. package/src/cli/commands.ts +57 -19
  11. package/src/cli/contract.ts +58 -4
  12. package/src/cli/files.ts +2 -5
  13. package/src/cli/flags.ts +1 -1
  14. package/src/cli/format.ts +5 -4
  15. package/src/cli/goto.ts +44 -11
  16. package/src/cli/init.ts +10 -5
  17. package/src/cli/lint.ts +36 -16
  18. package/src/cli/ls.ts +5 -1
  19. package/src/cli/main.ts +51 -14
  20. package/src/cli/new.ts +28 -8
  21. package/src/cli/pptx.ts +9 -4
  22. package/src/cli/ref.ts +92 -9
  23. package/src/cli/result.ts +47 -6
  24. package/src/cli/scope.ts +57 -29
  25. package/src/cli/shot.ts +78 -6
  26. package/src/cli/show.ts +15 -2
  27. package/src/cli/text.ts +88 -11
  28. package/src/cli/theme.ts +58 -10
  29. package/src/cli/tty.ts +16 -2
  30. package/src/cli/voice.ts +15 -9
  31. package/src/core/alignment.ts +320 -0
  32. package/src/core/annotation-rows.ts +16 -4
  33. package/src/core/annotations.ts +19 -1
  34. package/src/core/assets.ts +18 -10
  35. package/src/core/attribute-value.ts +188 -0
  36. package/src/core/build.ts +3 -3
  37. package/src/core/config.ts +61 -12
  38. package/src/core/css-scan.ts +173 -39
  39. package/src/core/css-transform.ts +57 -11
  40. package/src/core/css.ts +19 -13
  41. package/src/core/diagnostic.ts +5 -1
  42. package/src/core/escape.ts +43 -0
  43. package/src/core/foreign-text.ts +138 -0
  44. package/src/core/github.ts +1 -0
  45. package/src/core/html-entities.ts +318 -0
  46. package/src/core/html-scan.ts +49 -27
  47. package/src/core/html.ts +21 -15
  48. package/src/core/line-break.ts +275 -0
  49. package/src/core/lint/asset-refs.ts +50 -8
  50. package/src/core/lint/files.ts +30 -5
  51. package/src/core/lint/keys.ts +7 -3
  52. package/src/core/lint/slide-html.ts +24 -19
  53. package/src/core/lint/slide-script.ts +7 -5
  54. package/src/core/mv.ts +57 -27
  55. package/src/core/order.ts +14 -0
  56. package/src/core/pack.ts +2 -2
  57. package/src/core/path.ts +8 -2
  58. package/src/core/playwright-module.d.ts +13 -1
  59. package/src/core/playwright-motion.ts +1 -0
  60. package/src/core/playwright-sheet.ts +1 -0
  61. package/src/core/playwright-visual.ts +207 -30
  62. package/src/core/playwright.ts +279 -44
  63. package/src/core/pptx-package.ts +461 -33
  64. package/src/core/pptx-paint.ts +1168 -0
  65. package/src/core/pptx-plan.ts +879 -0
  66. package/src/core/pptx-text.ts +204 -47
  67. package/src/core/pptx-theme.ts +222 -0
  68. package/src/core/pptx.ts +56 -8
  69. package/src/core/prose.ts +23 -0
  70. package/src/core/pseudo-text.ts +32 -10
  71. package/src/core/ref-name.ts +52 -8
  72. package/src/core/ref.ts +308 -54
  73. package/src/core/resolve.ts +23 -11
  74. package/src/core/rumdl.ts +51 -25
  75. package/src/core/safe-fs.ts +19 -2
  76. package/src/core/sandbox.ts +217 -0
  77. package/src/core/shot/morph.ts +1 -1
  78. package/src/core/shot/motion.ts +2 -3
  79. package/src/core/shot/still.ts +2 -2
  80. package/src/core/shot-cache.ts +26 -17
  81. package/src/core/slide-eval-worker.ts +1 -0
  82. package/src/core/slide-measure.ts +51 -1
  83. package/src/core/slide-script-eval.ts +12 -8
  84. package/src/core/slide-script.ts +114 -20
  85. package/src/core/spawn.ts +131 -32
  86. package/src/core/sync.ts +5 -14
  87. package/src/core/tar.ts +315 -0
  88. package/src/core/text-contrast.ts +2 -0
  89. package/src/core/toml-keys.ts +116 -51
  90. package/src/core/url-attributes.ts +27 -9
  91. package/src/core/visual-cache.ts +2 -0
  92. package/src/core/visual.ts +182 -24
  93. package/src/core/voice.ts +5 -6
  94. package/src/core/zod.ts +9 -1
  95. package/src/server/auth.ts +21 -0
  96. package/src/server/dev.ts +26 -7
  97. package/src/server/lock.ts +50 -7
  98. package/src/server/routes.ts +7 -2
  99. package/src/server/serial.ts +8 -2
  100. package/src/theme/default.css +4 -0
  101. package/src/video/bake.ts +6 -2
  102. package/src/video/mux.ts +27 -3
  103. package/src/video/recorder.ts +3 -2
  104. package/src/video/worker.ts +1 -0
  105. package/src/voice/engine.ts +81 -18
@@ -0,0 +1,275 @@
1
+ /**
2
+ * One run of text as the page breaks it into lines: an element's own text with the text of the
3
+ * inline elements in it, as `<strong>` or `<a>`, which share its lines.
4
+ */
5
+ export type TextFlow = {
6
+ /** Its element's index among the slide's elements, as `measureSlideInPage` lists them. */
7
+ element: number;
8
+ /** Its text as written, a `<br>` or a block inside it read as a newline. */
9
+ text: string;
10
+ /** Where each line starts in `text`, and whether a `<br>` or a block started it. */
11
+ lines: Array<{ start: number; forced: boolean }>;
12
+ /** Where each line would start, broken phrase by phrase; only where that is elsewhere. */
13
+ phrased?: number[];
14
+ };
15
+
16
+ /** What `measureTextFlowsInPage` finds: each text with more than one line, and how the slide breaks. */
17
+ export type MeasuredFlows = {
18
+ /** The slide's own `word-break`, which every text inherits unless a rule sets its own. */
19
+ wordBreak: string;
20
+ flows: Array<TextFlow & { textWrap: string }>;
21
+ };
22
+
23
+ /**
24
+ * Lays each text on the first `.slide` out in lines, as `TextFlow` says. Where a line ends inside
25
+ * a phrase is what the page itself says: each text whose `word-break` is `normal` is laid out
26
+ * again beside itself with only `word-break: auto-phrase` changed, and where that puts its lines
27
+ * elsewhere, `phrased` says where. Chromium breaks by phrase only where the page's `lang` is one
28
+ * it knows phrases for, such as `ja`, so other text is never laid out elsewhere. The copies are
29
+ * taken away before it returns, so the page is as it was. It runs inside the page through
30
+ * `page.evaluate`, which serializes only this function, so it must not reference anything outside
31
+ * its own body.
32
+ */
33
+ export function measureTextFlowsInPage(): MeasuredFlows {
34
+ const slide = document.querySelector(".slide");
35
+ if (!slide) {
36
+ return { wordBreak: "normal", flows: [] };
37
+ }
38
+ // Code keeps its own lines, and a drawing or a formula its own text.
39
+ const code = "pre, code, kbd, samp";
40
+ const drawings = new Set(["svg", "math"]);
41
+ // A text node, whose characters each take their place on a line, or an inline box such as an
42
+ // image or an inline-block, which takes its place whole.
43
+ type Piece = { at: number } & ({ node: Text } | { box: Element });
44
+ type Read = { text: string; pieces: Piece[]; forced: number[] };
45
+ // The text an element lays out in its own lines: its text nodes and those of the inline elements
46
+ // in it, and an inline box as the text it holds, or U+FFFC when it holds none. A <br>, a block
47
+ // between them, or a newline that white-space keeps, as pre-line does, starts a line.
48
+ const read = (root: Element): Read => {
49
+ const found: Read = { text: "", pieces: [], forced: [] };
50
+ const walk = (el: Element) => {
51
+ for (const child of el.childNodes) {
52
+ if (child instanceof Text) {
53
+ const at = found.text.length;
54
+ found.pieces.push({ at, node: child });
55
+ found.text += child.data;
56
+ if (/^(?:pre|pre-wrap|pre-line|break-spaces)$/.test(getComputedStyle(el).whiteSpace)) {
57
+ for (const kept of child.data.matchAll(/\n/g)) {
58
+ found.forced.push(at + kept.index);
59
+ }
60
+ }
61
+ continue;
62
+ }
63
+ if (!(child instanceof Element)) {
64
+ continue;
65
+ }
66
+ const display = getComputedStyle(child).display;
67
+ if (display === "none") {
68
+ continue;
69
+ }
70
+ const tag = child.tagName.toLowerCase();
71
+ if (tag === "br" || (!display.startsWith("inline") && display !== "contents")) {
72
+ found.forced.push(found.text.length);
73
+ found.text += "\n";
74
+ } else if ((display === "inline" || display === "contents") && !drawings.has(tag)) {
75
+ walk(child);
76
+ } else {
77
+ found.pieces.push({ at: found.text.length, box: child });
78
+ found.text += child.textContent?.trim() || "";
79
+ }
80
+ }
81
+ };
82
+ walk(root);
83
+ return found;
84
+ };
85
+ // What a piece draws, each with its place in the text: a text node's characters but its white
86
+ // space, which draws nothing of its own, or an inline box whole.
87
+ const drawn = (piece: Piece): Array<{ offset: number; rect: DOMRect }> => {
88
+ if ("box" in piece) {
89
+ return [{ offset: piece.at, rect: piece.box.getBoundingClientRect() }];
90
+ }
91
+ const { node, at } = piece;
92
+ const marks: Array<{ offset: number; rect: DOMRect }> = [];
93
+ for (let end = 0; end < node.data.length; ) {
94
+ const begin = end;
95
+ end += (node.data.codePointAt(begin) ?? 0) > 0xffff ? 2 : 1;
96
+ if (/\s/.test(node.data.slice(begin, end))) {
97
+ continue;
98
+ }
99
+ const range = document.createRange();
100
+ range.setStart(node, begin);
101
+ range.setEnd(node, end);
102
+ marks.push({ offset: at + begin, rect: range.getBoundingClientRect() });
103
+ }
104
+ return marks;
105
+ };
106
+ // Where each line starts: at the first thing drawn below the line before.
107
+ const lineStarts = ({ pieces, forced }: Read): Array<{ start: number; forced: boolean }> => {
108
+ const lines: Array<{ start: number; forced: boolean }> = [];
109
+ let bottom = Number.NEGATIVE_INFINITY;
110
+ let previous = -1;
111
+ for (const { offset, rect } of pieces.flatMap(drawn)) {
112
+ if (rect.width === 0 && rect.height === 0) {
113
+ continue;
114
+ }
115
+ if (rect.top >= bottom - 1) {
116
+ lines.push({
117
+ start: offset,
118
+ forced: lines.length > 0 && forced.some((at) => at > previous && at < offset),
119
+ });
120
+ bottom = rect.bottom;
121
+ } else {
122
+ bottom = Math.max(bottom, rect.bottom);
123
+ }
124
+ previous = offset;
125
+ }
126
+ return lines;
127
+ };
128
+
129
+ const all = [...slide.querySelectorAll("*")];
130
+ const measured = all.flatMap((el, element) => {
131
+ const display = getComputedStyle(el).display;
132
+ if (
133
+ !(el instanceof HTMLElement) ||
134
+ display === "inline" ||
135
+ display === "contents" ||
136
+ display === "none" ||
137
+ el.closest(code) !== null ||
138
+ el.closest("svg, math") !== null ||
139
+ el.closest('[aria-hidden="true"]') !== null
140
+ ) {
141
+ return [];
142
+ }
143
+ const flow = read(el);
144
+ const lines = lineStarts(flow);
145
+ if (lines.length < 2) {
146
+ return [];
147
+ }
148
+ const style = getComputedStyle(el);
149
+ const soft = lines.some(({ forced }, at) => at > 0 && !forced);
150
+ return [
151
+ {
152
+ el,
153
+ element,
154
+ flow,
155
+ lines,
156
+ textWrap: style.textWrap,
157
+ rewrap: soft && style.wordBreak === "normal",
158
+ },
159
+ ];
160
+ });
161
+ // Every line is read before any copy goes in, and every copy is in before any is read, so the
162
+ // page is laid out once for each. A copy sits where its text is, out of the flow, with only its
163
+ // word-break changed.
164
+ const copies = measured.map(({ el, rewrap }) => {
165
+ if (!rewrap) {
166
+ return undefined;
167
+ }
168
+ const copy = el.cloneNode(true) as HTMLElement;
169
+ Object.assign(copy.style, {
170
+ position: "absolute",
171
+ visibility: "hidden",
172
+ left: "0px",
173
+ top: "0px",
174
+ margin: "0px",
175
+ boxSizing: "border-box",
176
+ width: `${el.offsetWidth}px`,
177
+ minWidth: "0px",
178
+ maxWidth: "none",
179
+ wordBreak: "auto-phrase",
180
+ });
181
+ el.after(copy);
182
+ return copy;
183
+ });
184
+ const flows = measured.map(({ element, flow, lines, textWrap }, at) => {
185
+ const copy = copies[at];
186
+ const phrased = copy && lineStarts(read(copy)).map(({ start }) => start);
187
+ copy?.remove();
188
+ const moved =
189
+ phrased !== undefined &&
190
+ (phrased.length !== lines.length || phrased.some((start, at) => start !== lines[at]?.start));
191
+ return {
192
+ element,
193
+ text: flow.text,
194
+ lines,
195
+ ...(moved ? { phrased } : {}),
196
+ textWrap,
197
+ };
198
+ });
199
+ return { wordBreak: getComputedStyle(slide).wordBreak, flows };
200
+ }
201
+
202
+ /** What reads wrong about a text's line breaks. */
203
+ export type LineBreakKind =
204
+ | {
205
+ /** A line ends inside a phrase. */
206
+ kind: "split";
207
+ /** The lines broken phrase by phrase. */
208
+ phrased: string[];
209
+ }
210
+ | {
211
+ /** A paragraph's last line holds two letters or fewer. */
212
+ kind: "short";
213
+ tail: string;
214
+ };
215
+
216
+ /** A line break the audience trips over, in the flow of the element at `element`. */
217
+ export type LineBreak = {
218
+ element: number;
219
+ /** Its lines as the audience reads them. */
220
+ lines: string[];
221
+ } & LineBreakKind;
222
+
223
+ /** A last line with no more letters or digits than this reads as a remnant. */
224
+ const MAX_SHORT = 2;
225
+
226
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
227
+
228
+ /** The letters and digits in `text`, an inline box that holds no text counted as one. */
229
+ function letters(text: string): number {
230
+ return [...graphemes.segment(text)].filter(({ segment }) => /[\p{L}\p{N}]/u.test(segment))
231
+ .length;
232
+ }
233
+
234
+ /** The lines of `text` that start at `starts`, as read: white space collapsed. */
235
+ function readLines(text: string, starts: number[]): string[] {
236
+ return starts.map((start, at) =>
237
+ text
238
+ .slice(start, starts[at + 1] ?? text.length)
239
+ .replace(/\s+/g, " ")
240
+ .trim(),
241
+ );
242
+ }
243
+
244
+ /**
245
+ * The line breaks in each flow that read wrong: a line that ends inside a phrase, which `phrased`
246
+ * shows where the page breaks it phrase by phrase, and a paragraph whose last line holds two
247
+ * letters or fewer. A short line a `<br>` or a block starts is the author's, and passes. Where a
248
+ * phrase is broken, every line after it moves once the text breaks by phrase, so that is all a
249
+ * flow reports.
250
+ */
251
+ export function findLineBreaks(flows: TextFlow[]): LineBreak[] {
252
+ return flows.flatMap((flow): LineBreak[] => {
253
+ const lines = readLines(
254
+ flow.text,
255
+ flow.lines.map(({ start }) => start),
256
+ );
257
+ if (flow.phrased) {
258
+ return [
259
+ {
260
+ element: flow.element,
261
+ kind: "split",
262
+ lines,
263
+ phrased: readLines(flow.text, flow.phrased),
264
+ },
265
+ ];
266
+ }
267
+ return flow.lines.flatMap(({ forced }, at): LineBreak[] => {
268
+ const last = at === flow.lines.length - 1 || flow.lines[at + 1]?.forced === true;
269
+ const tail = lines[at] ?? "";
270
+ return at > 0 && !forced && last && letters(tail) <= MAX_SHORT
271
+ ? [{ element: flow.element, kind: "short", lines, tail }]
272
+ : [];
273
+ });
274
+ });
275
+ }
@@ -2,8 +2,10 @@ import { existsSync } from "node:fs";
2
2
  import { dirname, join, posix, resolve } from "node:path";
3
3
  import { classifyAssetRef, isCanonicalAssetPath } from "../assets.ts";
4
4
  import { type Diagnostic, diag } from "../diagnostic.ts";
5
+ import { isPlainWord } from "../escape.ts";
5
6
  import type { HtmlRef } from "../html-scan.ts";
6
7
  import { isInside } from "../path.ts";
8
+ import { trimUrl } from "../url-attributes.ts";
7
9
 
8
10
  /**
9
11
  * DEK020 to DEK023 for one reference, whether it comes from a slide's markup
@@ -55,9 +57,9 @@ export function assetRefDiagnostics(
55
57
  if (kind === "missing") {
56
58
  return [
57
59
  diag("DEK021", {
58
- message: `missing ${ref.tag === "img" ? "image" : "file"} "${ref.value}"`,
60
+ message: `missing ${ref.tag === "img" ? "image" : "file"} "${visibleSpaces(ref.value)}"`,
59
61
  ...location,
60
- ...missingImageHint(ref.value, deckDir),
62
+ ...(strayedSpaceHint(ref.value, deckDir, from) ?? missingImageHint(ref.value, deckDir)),
61
63
  data: { src: ref.value },
62
64
  }),
63
65
  ];
@@ -65,7 +67,7 @@ export function assetRefDiagnostics(
65
67
  if (!isCanonicalAssetPath(ref.value)) {
66
68
  return [
67
69
  diag("DEK023", {
68
- 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))}`,
69
71
  ...location,
70
72
  data: { src: ref.value },
71
73
  }),
@@ -75,19 +77,56 @@ export function assetRefDiagnostics(
75
77
  }
76
78
 
77
79
  function leavesFromEither(value: string, deckDir: string, from: string): boolean {
78
- const path = value.trim().split(/[?#]/)[0] ?? "";
80
+ const path = trimUrl(value).split(/[?#]/)[0] ?? "";
79
81
  return (
80
82
  path !== "" &&
81
83
  [resolve(deckDir, path), resolve(from, path)].some((target) => !isInside(target, deckDir))
82
84
  );
83
85
  }
84
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
+
85
124
  /**
86
125
  * Decks copy what they use from the project's `assets/` (so a deck stays whole when moved);
87
126
  * when the project has the file, the fix is that copy.
88
127
  */
89
128
  function missingImageHint(value: string, deckDir: string): { hint?: string } {
90
- const name = value.trim().split(/[?#]/)[0] ?? "";
129
+ const name = trimUrl(value).split(/[?#]/)[0] ?? "";
91
130
  if (!name.startsWith("assets/")) {
92
131
  return {};
93
132
  }
@@ -95,15 +134,18 @@ function missingImageHint(value: string, deckDir: string): { hint?: string } {
95
134
  if (!existsSync(projectFile)) {
96
135
  return {};
97
136
  }
98
- return { hint: `the project has it: copy ../../${name} into the deck's assets/` };
137
+ // The name is the slide's text: one that is no plain word is left out of the hint.
138
+ const copy = isPlainWord(name) ? `../../${name}` : "it from the project's assets/";
139
+ return { hint: `the project has it: copy ${copy} into the deck's assets/` };
99
140
  }
100
141
 
101
142
  function remoteHint(value: string): string {
102
143
  let name = "";
103
144
  try {
104
- name = posix.basename(new URL(value.trim(), "https://dek.invalid").pathname);
145
+ name = posix.basename(new URL(trimUrl(value), "https://dek.invalid").pathname);
105
146
  } catch {}
106
- return name
147
+ // The name is the remote URL's: one that is no plain word is left out of the hint.
148
+ return name && isPlainWord(name)
107
149
  ? `download it into assets/ and use assets/${name}`
108
150
  : "download it into assets/ and reference it from there";
109
151
  }
@@ -1,6 +1,8 @@
1
1
  import { type Diagnostic, diag } from "../diagnostic.ts";
2
+ import { shellName } from "../escape.ts";
2
3
  import { either } from "../prose.ts";
3
4
  import { listSlideFiles, SLIDE_SIDECARS } from "../resolve.ts";
5
+ import { Id } from "../schema.ts";
4
6
  import type { LintContext } from "./context.ts";
5
7
 
6
8
  /** DEK001 for a section without slides/<slug>.html; DEK002 for a slide file without its section. */
@@ -60,9 +62,13 @@ export function pairingDiagnostics(ctx: LintContext): Diagnostic[] {
60
62
 
61
63
  /**
62
64
  * What to do with a slide file the script does not name, when no rename explains it: give it a
63
- * section, or take it out of the talk. Which one is the author's call.
65
+ * section, or take it out of the talk. Which one is the author's call. A name that is no section
66
+ * id cannot head one, and is the file's text, so the hint names it nowhere.
64
67
  */
65
68
  function orphanHint(slug: string, file: string): string {
69
+ if (!Id.safeParse(slug).success) {
70
+ return "its name is no section id, which uses a-z, 0-9, and -: rename it to one and add that section to script.md, or remove it if the slide is gone from the talk";
71
+ }
66
72
  const title = slug.charAt(0).toUpperCase() + slug.slice(1).replaceAll("-", " ");
67
73
  return `add a section for it to script.md, like \`## ${title} {#${slug}}\`, or remove ${file} if the slide is gone from the talk`;
68
74
  }
@@ -91,10 +97,16 @@ export function suggestRename(diagnostics: Diagnostic[], ctx: LintContext): Diag
91
97
  }
92
98
  const [orphan] = orphans;
93
99
  const [target] = targets;
94
- if (orphans.size === 1 && targets.length === 1 && orphan?.slug && target) {
100
+ if (
101
+ orphans.size === 1 &&
102
+ targets.length === 1 &&
103
+ orphan?.slug &&
104
+ target &&
105
+ movable(orphan.slug)
106
+ ) {
95
107
  // An orphan beside a section without its own slide is a rename, made in the script or in the
96
108
  // files: which one is the author's to say, and each command finishes it from this state.
97
- const hint = `run \`dekc mv ${orphan.slug} ${target}\` to move the files to the script's id, or \`dekc mv ${target} ${orphan.slug}\` to give the section the files' id`;
109
+ const hint = `run ${mvCommand(orphan.slug, target)} to move the files to the script's id, or ${mvCommand(target, orphan.slug)} to give the section the files' id`;
98
110
  return diagnostics.map((diagnostic) =>
99
111
  diagnostic === orphan
100
112
  ? { ...diagnostic, hint, data: { ...diagnostic.data, renames: targets } }
@@ -104,10 +116,11 @@ export function suggestRename(diagnostics: Diagnostic[], ctx: LintContext): Diag
104
116
  );
105
117
  }
106
118
  return diagnostics.map((diagnostic) => {
107
- if (!orphans.has(diagnostic) || !diagnostic.slug) {
119
+ const file = diagnostic.slug;
120
+ if (!orphans.has(diagnostic) || !file || !movable(file)) {
108
121
  return diagnostic;
109
122
  }
110
- const commands = targets.map((slug) => `\`dekc mv ${diagnostic.slug} ${slug}\``);
123
+ const commands = targets.map((slug) => mvCommand(file, slug));
111
124
  return {
112
125
  ...diagnostic,
113
126
  hint: `run ${either(commands)} to move the files to the section they now belong to`,
@@ -115,3 +128,15 @@ export function suggestRename(diagnostics: Diagnostic[], ctx: LintContext): Diag
115
128
  };
116
129
  });
117
130
  }
131
+
132
+ /** Whether a slide file's name can be written in a command: see `shellName`. */
133
+ function movable(slug: string): boolean {
134
+ return shellName(slug) !== undefined;
135
+ }
136
+
137
+ /** `dekc mv from to`, with `--` first when a name starts with a dash, which would read as a flag. */
138
+ function mvCommand(from: string, to: string): string {
139
+ const words = [from, to].map((slug) => shellName(slug) ?? slug);
140
+ const end = [from, to].some((slug) => slug.startsWith("-")) ? "-- " : "";
141
+ return `\`dekc mv ${end}${words.join(" ")}\``;
142
+ }
@@ -5,8 +5,10 @@ import {
5
5
  unknownTomlKeys,
6
6
  } from "../config-keys.ts";
7
7
  import { type Diagnostic, diag } from "../diagnostic.ts";
8
+ import { isPlainWord } from "../escape.ts";
8
9
  import { readFrontmatterYaml } from "../parse.ts";
9
- import { type Project, readTextIfExists } from "../resolve.ts";
10
+ import type { Project } from "../resolve.ts";
11
+ import { readSourceIfExists } from "../safe-fs.ts";
10
12
  import type { DeckSettings } from "../schema.ts";
11
13
  import type { LintContext } from "./context.ts";
12
14
 
@@ -17,7 +19,7 @@ import type { LintContext } from "./context.ts";
17
19
 
18
20
  /** DEK008 for dek.toml: a project finding, the same for every deck. */
19
21
  export function tomlKeyDiagnostics(project: Project): Diagnostic[] {
20
- return unknownTomlKeys(readTextIfExists(project.configPath) ?? "").map((key) =>
22
+ return unknownTomlKeys(readSourceIfExists(project.configPath, project.root) ?? "").map((key) =>
21
23
  unknownKeyDiagnostic(key, { file: "dek.toml", path: project.configPath }),
22
24
  );
23
25
  }
@@ -79,8 +81,10 @@ function unknownKeyDiagnostic(
79
81
  where.file === "dek.toml"
80
82
  ? `see ${REFERENCE_URL}#dek-toml`
81
83
  : `the keys are ${FRONTMATTER_KEYS.join(", ")}; see ${REFERENCE_URL}#frontmatter`;
84
+ // The key is the file's text, which a cloned project brings along: see formatZodIssues.
85
+ const shown = isPlainWord(key) ? key : "<key>";
82
86
  return diag("DEK008", {
83
- message: `unknown key ${key} in ${place}; dek ignores it`,
87
+ message: `unknown key ${shown} in ${place}; dek ignores it`,
84
88
  path: where.path,
85
89
  line,
86
90
  hint: suggestion ? `did you mean ${suggestion}?` : known,
@@ -1,5 +1,13 @@
1
+ import { attributeTokens } from "../attribute-value.ts";
1
2
  import { type Diagnostic, diag } from "../diagnostic.ts";
2
- import type { HtmlAttribute, HtmlScan, Picture, SourceSpot } from "../html-scan.ts";
3
+ import { isPlainWord } from "../escape.ts";
4
+ import {
5
+ type HtmlAttribute,
6
+ type HtmlScan,
7
+ isName,
8
+ type Picture,
9
+ type SourceSpot,
10
+ } from "../html-scan.ts";
3
11
  import type { Section } from "../schema.ts";
4
12
  import { beatAt, formatStepChoices, resolveStep, stepChoices } from "../step.ts";
5
13
  import { suggest } from "../suggest.ts";
@@ -159,7 +167,8 @@ function layoutDiagnostics({ section, path, scan }: SlideHtml, layouts: Set<stri
159
167
  return [];
160
168
  }
161
169
  const known = [...layouts].sort();
162
- const guess = suggest(scan.layout, known);
170
+ // A layout's name is the theme's text: one that is no plain word is never a guess.
171
+ const guess = suggest(scan.layout, known.filter(isPlainWord));
163
172
  return [
164
173
  diag("DEK019", {
165
174
  message: `data-layout "${scan.layout}" is not a layout of the theme`,
@@ -366,24 +375,22 @@ const EVENT_HANDLER_RE = /^on[a-z]+$/;
366
375
  /** Tags that load a URL as a document of its own, so an SVG there runs its scripts. */
367
376
  const DOCUMENT_TAGS = new Set(["iframe", "frame", "object", "embed"]);
368
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
+
369
381
  /**
370
382
  * The scheme of a URL that runs script, or undefined: `javascript:` and `vbscript:` always, and a
371
383
  * `data:` URL holding HTML, or SVG where a tag loads it as a document. An image's `data:` stays.
372
- * 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.
373
386
  */
374
387
  function scriptUrlScheme(tag: string, value: string): string | undefined {
375
- const url = value
376
- .replace(/[\t\n\r]/g, "")
377
- .trimStart()
378
- .toLowerCase();
379
- if (url.startsWith("javascript:")) {
380
- return "javascript:";
381
- }
382
- if (url.startsWith("vbscript:")) {
383
- return "vbscript:";
388
+ const url = URL.parse(value, SOME_PAGE);
389
+ if (url?.protocol === "javascript:" || url?.protocol === "vbscript:") {
390
+ return url.protocol;
384
391
  }
385
- if (url.startsWith("data:")) {
386
- 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();
387
394
  const runs =
388
395
  type === "text/html" ||
389
396
  type === "application/xhtml+xml" ||
@@ -476,14 +483,12 @@ function unnamedPicture({
476
483
  }: Picture): { message: string; hint: string; data: Record<string, string> } | undefined {
477
484
  const value = (name: string): string | undefined =>
478
485
  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
- );
486
+ const named = [value("aria-label"), value("aria-labelledby"), value("title")].some(isName);
482
487
  const { tag } = element;
483
488
  if (named || (tag === "img" && value("alt") !== undefined)) {
484
489
  return undefined;
485
490
  }
486
- if ((value("role") ?? "").trim().toLowerCase().split(/\s+/)[0] === "img") {
491
+ if (attributeTokens(value("role"))[0]?.toLowerCase() === "img") {
487
492
  if (titled) {
488
493
  return undefined;
489
494
  }
@@ -541,7 +546,7 @@ function unknownClassDiagnostics(
541
546
  : `define it in slides/${section.slug}.css, or use one of: ${shown.join(", ")}${scriptHint}`;
542
547
  const unknown = new Map<string, HtmlAttribute>();
543
548
  for (const attribute of attributesNamed(scan, "class")) {
544
- for (const name of attribute.value.split(/\s+/).filter(Boolean)) {
549
+ for (const name of attributeTokens(attribute.value)) {
545
550
  if (!classes.has(name) && !unknown.has(name)) {
546
551
  unknown.set(name, attribute);
547
552
  }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { type Diagnostic, diag } from "../diagnostic.ts";
3
+ import { shellName } from "../escape.ts";
3
4
  import { listSlideFiles } from "../resolve.ts";
4
5
  import type { Section } from "../schema.ts";
5
6
  import type { SlideScriptProblem } from "../slide-script.ts";
@@ -54,15 +55,16 @@ export function slideScriptDiagnostics(
54
55
 
55
56
  /** DEK016 for a slides/<slug>.js, which dek does not load: motion is written in TypeScript. */
56
57
  export function javascriptDiagnostics(ctx: LintContext): Diagnostic[] {
57
- return listSlideFiles(ctx.deck.dir, ".js").map((file) =>
58
- diag("DEK016", {
58
+ return listSlideFiles(ctx.deck.dir, ".js").map((file) => {
59
+ const [from, to] = [`slides/${file.slug}.js`, `slides/${file.slug}.ts`].map(shellName);
60
+ return diag("DEK016", {
59
61
  message: `slide scripts are TypeScript; rename ${file.slug}.js to ${file.slug}.ts`,
60
62
  path: file.path,
61
63
  slug: file.slug,
62
- hint: `run \`mv slides/${file.slug}.js slides/${file.slug}.ts\`; plain JavaScript is valid TypeScript`,
64
+ hint: `${from && to ? `run \`mv ${from} ${to}\`` : "rename it to .ts"}; plain JavaScript is valid TypeScript`,
63
65
  data: { file: `slides/${file.slug}.js` },
64
- }),
65
- );
66
+ });
67
+ });
66
68
  }
67
69
 
68
70
  export type SeekProblem = {