@hajimism/dek 0.3.2 → 0.4.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 (77) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +2 -1
  5. package/skills/dek/SKILL.md +10 -3
  6. package/skills/dek-design/SKILL.md +42 -0
  7. package/src/cli/address.ts +84 -14
  8. package/src/cli/build.ts +2 -2
  9. package/src/cli/check.ts +2 -1
  10. package/src/cli/commands.ts +54 -13
  11. package/src/cli/contract.ts +18 -1
  12. package/src/cli/files.ts +9 -6
  13. package/src/cli/flags.ts +1 -1
  14. package/src/cli/goto.ts +3 -1
  15. package/src/cli/init.ts +10 -5
  16. package/src/cli/lint.ts +4 -3
  17. package/src/cli/main.ts +7 -5
  18. package/src/cli/new.ts +41 -10
  19. package/src/cli/pack.ts +21 -0
  20. package/src/cli/pptx.ts +9 -4
  21. package/src/cli/ref.ts +243 -9
  22. package/src/cli/result.ts +5 -2
  23. package/src/cli/scope.ts +25 -27
  24. package/src/cli/shot.ts +2 -1
  25. package/src/cli/text.ts +45 -4
  26. package/src/cli/theme.ts +16 -2
  27. package/src/cli/voice.ts +4 -3
  28. package/src/core/alignment.ts +320 -0
  29. package/src/core/annotation-rows.ts +16 -4
  30. package/src/core/annotations.ts +19 -1
  31. package/src/core/config-keys.ts +6 -2
  32. package/src/core/config.ts +88 -12
  33. package/src/core/diagnostic.ts +5 -1
  34. package/src/core/escape.ts +43 -0
  35. package/src/core/licenses/CC-BY-4.0.txt +396 -0
  36. package/src/core/licenses/CC0-1.0.txt +121 -0
  37. package/src/core/licenses.ts +20 -0
  38. package/src/core/line-break.ts +275 -0
  39. package/src/core/lint/asset-refs.ts +6 -2
  40. package/src/core/lint/files.ts +30 -5
  41. package/src/core/lint/keys.ts +7 -3
  42. package/src/core/lint/slide-html.ts +3 -1
  43. package/src/core/lint/slide-script.ts +7 -5
  44. package/src/core/mv.ts +30 -10
  45. package/src/core/pack.ts +137 -0
  46. package/src/core/playwright-module.d.ts +1 -0
  47. package/src/core/playwright-visual.ts +180 -20
  48. package/src/core/playwright.ts +202 -25
  49. package/src/core/pptx-package.ts +461 -33
  50. package/src/core/pptx-paint.ts +1168 -0
  51. package/src/core/pptx-plan.ts +879 -0
  52. package/src/core/pptx-text.ts +204 -47
  53. package/src/core/pptx-theme.ts +222 -0
  54. package/src/core/pptx.ts +55 -8
  55. package/src/core/prose.ts +23 -0
  56. package/src/core/pseudo-text.ts +32 -10
  57. package/src/core/ref-name.ts +100 -17
  58. package/src/core/ref.ts +318 -34
  59. package/src/core/resolve.ts +20 -9
  60. package/src/core/shot/morph.ts +1 -1
  61. package/src/core/shot/motion.ts +2 -3
  62. package/src/core/shot/still.ts +2 -2
  63. package/src/core/shot-cache.ts +25 -17
  64. package/src/core/slide-measure.ts +51 -1
  65. package/src/core/slide-script-eval.ts +10 -8
  66. package/src/core/slide-script.ts +114 -20
  67. package/src/core/sync.ts +3 -3
  68. package/src/core/visual-cache.ts +2 -0
  69. package/src/core/visual.ts +154 -15
  70. package/src/core/voice.ts +3 -5
  71. package/src/core/zod.ts +9 -1
  72. package/src/runtime/annotate-view.ts +4 -2
  73. package/src/server/dev.ts +15 -4
  74. package/src/server/lock.ts +48 -5
  75. package/src/server/routes.ts +7 -2
  76. package/src/theme/default.css +4 -0
  77. package/src/video/bake.ts +6 -2
@@ -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,6 +2,7 @@ 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";
7
8
 
@@ -95,7 +96,9 @@ function missingImageHint(value: string, deckDir: string): { hint?: string } {
95
96
  if (!existsSync(projectFile)) {
96
97
  return {};
97
98
  }
98
- return { hint: `the project has it: copy ../../${name} into the deck's assets/` };
99
+ // The name is the slide's text: one that is no plain word is left out of the hint.
100
+ const copy = isPlainWord(name) ? `../../${name}` : "it from the project's assets/";
101
+ return { hint: `the project has it: copy ${copy} into the deck's assets/` };
99
102
  }
100
103
 
101
104
  function remoteHint(value: string): string {
@@ -103,7 +106,8 @@ function remoteHint(value: string): string {
103
106
  try {
104
107
  name = posix.basename(new URL(value.trim(), "https://dek.invalid").pathname);
105
108
  } catch {}
106
- return name
109
+ // The name is the remote URL's: one that is no plain word is left out of the hint.
110
+ return name && isPlainWord(name)
107
111
  ? `download it into assets/ and use assets/${name}`
108
112
  : "download it into assets/ and reference it from there";
109
113
  }
@@ -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,4 +1,5 @@
1
1
  import { type Diagnostic, diag } from "../diagnostic.ts";
2
+ import { isPlainWord } from "../escape.ts";
2
3
  import type { HtmlAttribute, HtmlScan, Picture, SourceSpot } from "../html-scan.ts";
3
4
  import type { Section } from "../schema.ts";
4
5
  import { beatAt, formatStepChoices, resolveStep, stepChoices } from "../step.ts";
@@ -159,7 +160,8 @@ function layoutDiagnostics({ section, path, scan }: SlideHtml, layouts: Set<stri
159
160
  return [];
160
161
  }
161
162
  const known = [...layouts].sort();
162
- const guess = suggest(scan.layout, known);
163
+ // A layout's name is the theme's text: one that is no plain word is never a guess.
164
+ const guess = suggest(scan.layout, known.filter(isPlainWord));
163
165
  return [
164
166
  diag("DEK019", {
165
167
  message: `data-layout "${scan.layout}" is not a layout of the theme`,
@@ -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 = {
package/src/core/mv.ts CHANGED
@@ -1,10 +1,16 @@
1
- import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { deckPaths } from "./deck-paths.ts";
4
4
  import { DekError } from "./error.ts";
5
- import { escapeRegExp } from "./escape.ts";
5
+ import { escapeRegExp, isPlainWord, shellName } from "./escape.ts";
6
6
  import { joinLines, sectionChunks, splitLines } from "./lines.ts";
7
- import { asResolvedDeck, type ResolvedDeck, requireSection, SLIDE_SIDECARS } from "./resolve.ts";
7
+ import {
8
+ asResolvedDeck,
9
+ type ResolvedDeck,
10
+ readDeckFile,
11
+ requireSection,
12
+ SLIDE_SIDECARS,
13
+ } from "./resolve.ts";
8
14
  import { Id } from "./schema.ts";
9
15
  import { deckLayouts, skeletonHtml } from "./skeleton.ts";
10
16
  import { skeletonRecord } from "./skeleton-record.ts";
@@ -38,7 +44,7 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
38
44
  });
39
45
  }
40
46
 
41
- const source = readFileSync(deck.scriptPath, "utf8");
47
+ const source = readDeckFile(deck.dir, deck.scriptPath) ?? "";
42
48
  const lines = splitLines(source);
43
49
  const headingIndex = section.line - 1;
44
50
  const heading = lines[headingIndex];
@@ -69,10 +75,11 @@ function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): F
69
75
  const record = skeletonRecord(deck.dir);
70
76
  const read = (slug: string): SlideFile | undefined => {
71
77
  const path = paths.slide(slug, ".html");
72
- if (!existsSync(path)) {
78
+ // What is read here may be written under the other id: a link out of the project is refused.
79
+ const html = readDeckFile(deck.dir, path);
80
+ if (html === undefined) {
73
81
  return undefined;
74
82
  }
75
- const html = readFileSync(path, "utf8");
76
83
  return { path, html, authored: !record.owns(html, skeletonHtml(deck.deck, slug, layouts)) };
77
84
  };
78
85
  const source = read(from);
@@ -131,10 +138,23 @@ function bothHold(fromPath: string, toPath: string, from: string, to: string): D
131
138
  const what = fromPath.endsWith(".html") ? "hold a slide you wrote" : "exist";
132
139
  return new DekError(`${fromName} and ${toName} both ${what}`, {
133
140
  path: fromPath,
134
- hint: `merge them into ${toName} and remove ${fromName}, then run \`dekc mv ${from} ${to}\` again`,
141
+ hint: `merge them into ${toName} and remove ${isPlainWord(from) ? fromName : "the other"}, then run ${again(from, to)}`,
135
142
  });
136
143
  }
137
144
 
145
+ /**
146
+ * The rename to run again: as a command when each name has a word every shell reads alike, after
147
+ * `--` when one starts with a dash; else as the one that was run.
148
+ */
149
+ function again(from: string, to: string): string {
150
+ const [source, target] = [shellName(from), shellName(to)];
151
+ if (source === undefined || target === undefined) {
152
+ return "that `dekc mv` again";
153
+ }
154
+ const end = from.startsWith("-") || to.startsWith("-") ? "-- " : "";
155
+ return `\`dekc mv ${end}${source} ${target}\` again`;
156
+ }
157
+
138
158
  /** One file change `dekc mv` makes, with how to take it back. */
139
159
  type FileStep = { path: string; apply: () => void; undo: () => void };
140
160
 
@@ -192,10 +212,10 @@ function planVoiceBeatKeys(
192
212
  to: string,
193
213
  ): { path: string; source: string; next: string } | undefined {
194
214
  const path = deckPaths(deckDir).voiceToml;
195
- if (!existsSync(path)) {
215
+ const source = readDeckFile(deckDir, path);
216
+ if (source === undefined) {
196
217
  return undefined;
197
218
  }
198
- const source = readFileSync(path, "utf8");
199
219
  let parsed: Record<string, unknown>;
200
220
  try {
201
221
  parsed = Bun.TOML.parse(source) as Record<string, unknown>;
@@ -259,7 +279,7 @@ export function reorderSection(
259
279
  requireSection(deck, target);
260
280
  }
261
281
 
262
- const source = readFileSync(deck.scriptPath, "utf8");
282
+ const source = readDeckFile(deck.dir, deck.scriptPath) ?? "";
263
283
  const lines = splitLines(source);
264
284
  const firstLine = sections[0]?.line;
265
285
  if (firstLine === undefined) {