@hajimism/dek 0.3.3 → 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 (71) hide show
  1. package/CHANGELOG.md +63 -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 +11 -6
  11. package/src/cli/contract.ts +9 -1
  12. package/src/cli/files.ts +1 -5
  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 +14 -7
  19. package/src/cli/pptx.ts +9 -4
  20. package/src/cli/ref.ts +91 -9
  21. package/src/cli/result.ts +5 -2
  22. package/src/cli/scope.ts +17 -28
  23. package/src/cli/shot.ts +2 -1
  24. package/src/cli/text.ts +27 -2
  25. package/src/cli/theme.ts +16 -2
  26. package/src/cli/voice.ts +4 -3
  27. package/src/core/alignment.ts +320 -0
  28. package/src/core/annotation-rows.ts +16 -4
  29. package/src/core/annotations.ts +19 -1
  30. package/src/core/config.ts +61 -12
  31. package/src/core/diagnostic.ts +5 -1
  32. package/src/core/escape.ts +43 -0
  33. package/src/core/line-break.ts +275 -0
  34. package/src/core/lint/asset-refs.ts +6 -2
  35. package/src/core/lint/files.ts +30 -5
  36. package/src/core/lint/keys.ts +7 -3
  37. package/src/core/lint/slide-html.ts +3 -1
  38. package/src/core/lint/slide-script.ts +7 -5
  39. package/src/core/mv.ts +30 -10
  40. package/src/core/pack.ts +2 -2
  41. package/src/core/playwright-module.d.ts +1 -0
  42. package/src/core/playwright-visual.ts +180 -20
  43. package/src/core/playwright.ts +202 -25
  44. package/src/core/pptx-package.ts +461 -33
  45. package/src/core/pptx-paint.ts +1168 -0
  46. package/src/core/pptx-plan.ts +879 -0
  47. package/src/core/pptx-text.ts +204 -47
  48. package/src/core/pptx-theme.ts +222 -0
  49. package/src/core/pptx.ts +55 -8
  50. package/src/core/prose.ts +23 -0
  51. package/src/core/pseudo-text.ts +32 -10
  52. package/src/core/ref-name.ts +47 -8
  53. package/src/core/ref.ts +172 -16
  54. package/src/core/resolve.ts +20 -9
  55. package/src/core/shot/morph.ts +1 -1
  56. package/src/core/shot/motion.ts +2 -3
  57. package/src/core/shot/still.ts +2 -2
  58. package/src/core/shot-cache.ts +25 -17
  59. package/src/core/slide-measure.ts +51 -1
  60. package/src/core/slide-script-eval.ts +10 -8
  61. package/src/core/slide-script.ts +114 -20
  62. package/src/core/sync.ts +2 -2
  63. package/src/core/visual-cache.ts +2 -0
  64. package/src/core/visual.ts +154 -15
  65. package/src/core/voice.ts +3 -5
  66. package/src/core/zod.ts +9 -1
  67. package/src/server/dev.ts +15 -4
  68. package/src/server/lock.ts +48 -5
  69. package/src/server/routes.ts +7 -2
  70. package/src/theme/default.css +4 -0
  71. package/src/video/bake.ts +6 -2
@@ -0,0 +1,320 @@
1
+ import type { Box, MeasuredElement } from "./slide-measure.ts";
2
+
3
+ /** What lining up needs to know of an element. */
4
+ export type AlignedElement = Pick<
5
+ MeasuredElement,
6
+ | "box"
7
+ | "parent"
8
+ | "rect"
9
+ | "textRects"
10
+ | "baseline"
11
+ | "opacity"
12
+ | "decorative"
13
+ | "inline"
14
+ | "tilted"
15
+ | "paints"
16
+ | "picture"
17
+ | "fontSize"
18
+ >;
19
+
20
+ /** An edge items side by side share (`top`, `middle`, `bottom`), or items stacked (`left`, …). */
21
+ export type AlignedEdge = "top" | "middle" | "bottom" | "left" | "center" | "right";
22
+
23
+ /**
24
+ * Two items that nearly line up and do not: `element` is the one off the line, `other` the one it
25
+ * misses, both indices into the elements measured. `of` says whether their boxes miss each other
26
+ * or the text that starts inside them.
27
+ */
28
+ export type Misalignment = {
29
+ element: number;
30
+ other: number;
31
+ edge: AlignedEdge;
32
+ of: "box" | "text";
33
+ /** How far apart, in the slide's own pixels. */
34
+ by: number;
35
+ };
36
+
37
+ /** Edges this close are one line; layout rounds to fractions of a pixel nobody sees. */
38
+ const SAME = 0.5;
39
+ /** Edges closer than this look like one, on a projector as on a laptop. */
40
+ const SEEN = 1.5;
41
+ /** Edges this far apart, or farther, read as a choice; nearer, as a mistake. */
42
+ const NEAR = 8;
43
+
44
+ type Side = "start" | "center" | "end";
45
+ const SIDES: Side[] = ["start", "center", "end"];
46
+ type Axis = Record<Side, AlignedEdge>;
47
+ /** Items side by side, compared down the slide. */
48
+ const ROW: Axis = { start: "top", center: "middle", end: "bottom" };
49
+ /** Items stacked, compared across it. */
50
+ const COLUMN: Axis = { start: "left", center: "center", end: "right" };
51
+
52
+ function along(rect: Box, axis: Axis): Record<Side, number> {
53
+ return axis === ROW
54
+ ? { start: rect.top, center: (rect.top + rect.bottom) / 2, end: rect.bottom }
55
+ : { start: rect.left, center: (rect.left + rect.right) / 2, end: rect.right };
56
+ }
57
+
58
+ /** Side by side when only their heights overlap, stacked when only their widths do. */
59
+ function relation(a: Box, b: Box): Axis | undefined {
60
+ const across = Math.min(a.right, b.right) - Math.max(a.left, b.left);
61
+ const down = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
62
+ if (down > 0 && across <= 0) {
63
+ return ROW;
64
+ }
65
+ if (across > 0 && down <= 0) {
66
+ return COLUMN;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function union(rects: Box[]): Box {
72
+ return {
73
+ left: Math.min(...rects.map((rect) => rect.left)),
74
+ top: Math.min(...rects.map((rect) => rect.top)),
75
+ right: Math.max(...rects.map((rect) => rect.right)),
76
+ bottom: Math.max(...rects.map((rect) => rect.bottom)),
77
+ };
78
+ }
79
+
80
+ const near = (apart: number): boolean => Math.abs(apart) >= SEEN && Math.abs(apart) <= NEAR;
81
+
82
+ /** What an item is, as its tag and first class say, whatever beat it shows at or id it has. */
83
+ const kindOf = (box: string): string => box.replace(/#[^.[]+|\[data-step="[^"]*"\]/g, "");
84
+
85
+ /** An item compared: one element, or the row or the stack several form beside another item. */
86
+ type Group = {
87
+ members: number[];
88
+ rect: Box;
89
+ line: FirstLine | undefined;
90
+ /** Whether every member paints its box or is a picture, so its edges show. */
91
+ framed: boolean;
92
+ kind: string | undefined;
93
+ };
94
+
95
+ /**
96
+ * The items on a slide that nearly line up and do not. Items are compared within one parent, and
97
+ * only when they sit side by side, compared by top, middle, and bottom, or stacked, compared by
98
+ * left, center, and right. Several items in a row under one item, or stacked beside it, are
99
+ * compared with it as the row or the stack they form, by the member at that edge. An item lines
100
+ * up when it shares one of those edges, or, side by side, a baseline: a heading over a wider list,
101
+ * an icon centered beside a line of text, and a number on its unit's baseline all pass. Where
102
+ * nothing is shared and the nearest edge is two pixels to eight away, it reads as a mistake; so
103
+ * does a start, top or left, that nearly meets where the end meets, between items that hold text.
104
+ * Items of one kind whose box the audience sees, as a row of cards, are held to more: one that
105
+ * shares its top and nearly its bottom with the others is a little shorter, and ones that start
106
+ * on one line should start their text on one line too.
107
+ *
108
+ * Inline boxes, which sit on a line of text, decoration, what the beat has not shown, what a
109
+ * transform turns, and the parts of a picture, such as an SVG's shapes, are not items. Of two
110
+ * items that miss each other, the one fewer others line up with is off the line, and of two alike
111
+ * the later; each is named once for each of its edges.
112
+ */
113
+ export function findMisalignments(elements: AlignedElement[]): Misalignment[] {
114
+ const inPicture = (index: number): boolean => {
115
+ for (let at = elements[index]?.parent ?? -1; at >= 0; at = elements[at]?.parent ?? -1) {
116
+ if (elements[at]?.picture) {
117
+ return true;
118
+ }
119
+ }
120
+ return false;
121
+ };
122
+ const items = elements.flatMap((element, index) =>
123
+ element.opacity >= 0.01 &&
124
+ !element.decorative &&
125
+ !element.inline &&
126
+ !element.tilted &&
127
+ element.rect.right > element.rect.left &&
128
+ element.rect.bottom > element.rect.top &&
129
+ !inPicture(index)
130
+ ? [index]
131
+ : [],
132
+ );
133
+ const lines = firstLines(elements);
134
+ const rectOf = (index: number): Box => elements[index]?.rect ?? union([]);
135
+
136
+ // The row or the stack `item` forms beside `other`: the items `other` relates to as it does to
137
+ // `item`, which sit across that from `item` or from one another.
138
+ const groupOf = (item: number, other: number, axis: Axis, siblings: number[]): Group => {
139
+ const across = axis === ROW ? COLUMN : ROW;
140
+ const members = [item];
141
+ for (let at = 0; at < members.length; at++) {
142
+ const member = members[at] ?? item;
143
+ for (const candidate of siblings) {
144
+ if (
145
+ !members.includes(candidate) &&
146
+ candidate !== other &&
147
+ relation(rectOf(candidate), rectOf(other)) === axis &&
148
+ relation(rectOf(candidate), rectOf(member)) === across
149
+ ) {
150
+ members.push(candidate);
151
+ }
152
+ }
153
+ }
154
+ members.sort((a, b) => a - b);
155
+ const kinds = new Set(members.map((member) => kindOf(elements[member]?.box ?? "")));
156
+ const memberLines = members.flatMap((member) => lines[member] ?? []);
157
+ return {
158
+ members,
159
+ rect: union(members.map(rectOf)),
160
+ line: memberLines.reduce<FirstLine | undefined>(
161
+ (first, line) => (!first || earlier(line.rect, first.rect) ? line : first),
162
+ undefined,
163
+ ),
164
+ framed: members.every(
165
+ (member) => elements[member]?.paints === true || elements[member]?.picture === true,
166
+ ),
167
+ kind: kinds.size === 1 ? [...kinds][0] : undefined,
168
+ };
169
+ };
170
+ // The member whose edge is the group's on that side.
171
+ const at = (group: Group, axis: Axis, side: Side): number =>
172
+ side === "center"
173
+ ? (group.members[0] ?? -1)
174
+ : group.members.reduce((best, member) => {
175
+ const value = along(rectOf(member), axis)[side];
176
+ const kept = along(rectOf(best), axis)[side];
177
+ return (side === "start" ? value < kept : value > kept) ? member : best;
178
+ });
179
+
180
+ type Miss = Misalignment & { key: string };
181
+ const misses: Miss[] = [];
182
+ // How many items each one lines up with, edge by edge.
183
+ const support = new Map<string, number>();
184
+ const lineUp = (index: number, key: string) => {
185
+ support.set(`${index}\0${key}`, (support.get(`${index}\0${key}`) ?? 0) + 1);
186
+ };
187
+ const compare = (
188
+ [a, b]: [number, number],
189
+ edge: AlignedEdge,
190
+ of: Miss["of"],
191
+ apart: number,
192
+ ): void => {
193
+ const key = `${of}\0${edge}`;
194
+ if (Math.abs(apart) < SAME) {
195
+ lineUp(a, key);
196
+ lineUp(b, key);
197
+ } else if (near(apart)) {
198
+ misses.push({ element: b, other: a, edge, of, by: Math.round(Math.abs(apart)), key });
199
+ }
200
+ };
201
+
202
+ const siblings = Map.groupBy(items, (index) => elements[index]?.parent ?? -1);
203
+ for (const group of siblings.values()) {
204
+ for (const [index, a] of group.entries()) {
205
+ for (const b of group.slice(index + 1)) {
206
+ const axis = relation(rectOf(a), rectOf(b));
207
+ if (!axis) {
208
+ continue;
209
+ }
210
+ const one = groupOf(a, b, axis, group);
211
+ const two = groupOf(b, a, axis, group);
212
+ // A row or a stack is compared once, as its first member.
213
+ if (one.members[0] !== a || two.members[0] !== b) {
214
+ continue;
215
+ }
216
+ const from = along(one.rect, axis);
217
+ const to = along(two.rect, axis);
218
+ const apart = (side: Side) => to[side] - from[side];
219
+ const shared = (side: Side) => Math.abs(apart(side)) < SAME;
220
+ const pair = (side: Side): [number, number] => [at(one, axis, side), at(two, axis, side)];
221
+ for (const side of SIDES.filter(shared)) {
222
+ compare(pair(side), axis[side], "box", 0);
223
+ }
224
+ const texts = one.line !== undefined && two.line !== undefined;
225
+ const cards =
226
+ one.framed && two.framed && texts && one.kind !== undefined && one.kind === two.kind;
227
+ // Text set on one baseline lines up, whatever boxes that do not show do. A baseline is
228
+ // worked out from the font's ascent, which layout may round where a canvas does not, so
229
+ // two that look like one count as one.
230
+ const baseline =
231
+ axis === ROW &&
232
+ !(one.framed && two.framed) &&
233
+ one.line?.baseline !== undefined &&
234
+ two.line?.baseline !== undefined &&
235
+ Math.abs(one.line.baseline - two.line.baseline) < SEEN;
236
+ if (baseline) {
237
+ // Nothing more to compare.
238
+ } else if (shared("center")) {
239
+ // Centered on one line, whatever their sizes.
240
+ } else if (shared("start") || shared("end")) {
241
+ // Side by side, text starts at the top, so a top a few pixels off reads where the
242
+ // bottoms meet; and a row of cards is one height, so one that shares its top and nearly
243
+ // its bottom is a little shorter. Stacked, items take the width their words give them,
244
+ // as tags set flush right do, and only an edge none of them shares can be off.
245
+ if (axis === ROW && shared("end") && texts) {
246
+ compare(pair("start"), axis.start, "box", apart("start"));
247
+ } else if (axis === ROW && shared("start") && cards) {
248
+ compare(pair("end"), axis.end, "box", apart("end"));
249
+ }
250
+ } else {
251
+ const nearest = SIDES.reduce((best, side) =>
252
+ Math.abs(apart(side)) < Math.abs(apart(best)) ? side : best,
253
+ );
254
+ compare(pair(nearest), axis[nearest], "box", apart(nearest));
255
+ }
256
+ // Text in cards off the line moved with them, and is named by their edge. Text centered
257
+ // in its card lines up by its middle, whatever its length.
258
+ if (cards && shared("start") && one.line && two.line) {
259
+ const [from, to] = [along(one.line.rect, axis), along(two.line.rect, axis)];
260
+ const centered = Math.abs(to.center - from.center) < SAME;
261
+ if (axis === COLUMN || one.line.fontSize === two.line.fontSize) {
262
+ compare([a, b], axis.start, "text", centered ? 0 : to.start - from.start);
263
+ }
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ const named = new Set<string>();
270
+ return misses.flatMap(({ key, ...miss }) => {
271
+ const ofOther = support.get(`${miss.other}\0${key}`) ?? 0;
272
+ const ofElement = support.get(`${miss.element}\0${key}`) ?? 0;
273
+ const found =
274
+ ofElement > ofOther ? { ...miss, element: miss.other, other: miss.element } : miss;
275
+ const id = `${found.element}\0${key}`;
276
+ if (named.has(id)) {
277
+ return [];
278
+ }
279
+ named.add(id);
280
+ return [found];
281
+ });
282
+ }
283
+
284
+ /** The first line of text in an element: where it is drawn, its baseline, and its size. */
285
+ type FirstLine = { rect: Box; baseline?: number; fontSize: number };
286
+
287
+ /** Whether `a` comes before `b` in reading order: higher, or as high and further left. */
288
+ function earlier(a: Box, b: Box): boolean {
289
+ return a.top < b.top - SAME || (Math.abs(a.top - b.top) < SAME && a.left < b.left);
290
+ }
291
+
292
+ /**
293
+ * Each element's first line of text, its own or a descendant's, in reading order. Decoration and
294
+ * what the beat has not shown have none.
295
+ */
296
+ function firstLines(elements: AlignedElement[]): Array<FirstLine | undefined> {
297
+ const first: Array<FirstLine | undefined> = elements.map(() => undefined);
298
+ // A parent comes before its children, so walking back reaches every child before its parent.
299
+ for (let index = elements.length - 1; index >= 0; index--) {
300
+ const element = elements[index];
301
+ if (!element || element.decorative || element.opacity < 0.01) {
302
+ continue;
303
+ }
304
+ const [own] = element.textRects;
305
+ const kept = first[index];
306
+ if (own && (!kept || earlier(own, kept.rect))) {
307
+ first[index] = {
308
+ rect: own,
309
+ fontSize: element.fontSize,
310
+ ...(element.baseline !== undefined ? { baseline: element.baseline } : {}),
311
+ };
312
+ }
313
+ const mine = first[index];
314
+ const parent = first[element.parent];
315
+ if (mine && element.parent >= 0 && (!parent || earlier(mine.rect, parent.rect))) {
316
+ first[element.parent] = mine;
317
+ }
318
+ }
319
+ return first;
320
+ }
@@ -3,6 +3,8 @@
3
3
  // edit, and the Markdown Copy writes. No DOM and no file system here, so all of it is decided by
4
4
  // these functions alone.
5
5
 
6
+ import { isPlainWord } from "./escape.ts";
7
+
6
8
  /** A box on the slide, in logical pixels, from its top left. */
7
9
  export type Box = { x: number; y: number; width: number; height: number };
8
10
 
@@ -115,8 +117,10 @@ export function formatNotes(
115
117
  groups.set(key, [...(groups.get(key) ?? []), row]);
116
118
  }
117
119
 
120
+ // The deck's folder and the notes file are the project's text, which a clone brings along.
121
+ const folder = shown(deck, "<deck>");
118
122
  const lines = [
119
- `## Notes on decks/${deck}`,
123
+ `## Notes on decks/${folder}`,
120
124
  "",
121
125
  "Boxes and points are in the slide's own pixels, from its top left.",
122
126
  ];
@@ -129,11 +133,11 @@ export function formatNotes(
129
133
  const title = slides[index]?.title;
130
134
  lines.push(
131
135
  "",
132
- `### Slide ${index + 1} of ${slides.length}, ${first.slug}${title ? ` (${JSON.stringify(title)})` : ""}, ${beatLabel(first.step)}`,
136
+ `### Slide ${index + 1} of ${slides.length}, ${shown(first.slug, "<slug>")}${title ? ` (${JSON.stringify(title)})` : ""}, ${beatLabel(first.step)}`,
133
137
  );
134
138
  for (const row of group) {
135
139
  const where = (target: TargetRow): string =>
136
- `${describe(target)} at decks/${deck}/slides/${row.slug}.html:${target.line}:${target.column} (${measure(target)})${target.found ? "" : ", as written before an edit; the line may have moved"}`;
140
+ `${describe(target)} at decks/${folder}/slides/${shown(row.slug, "<slug>")}.html:${target.line}:${target.column} (${measure(target)})${target.found ? "" : ", as written before an edit; the line may have moved"}`;
137
141
  lines.push("");
138
142
  if (row.targets.length === 1 && row.targets[0]) {
139
143
  lines.push(`${row.number}. ${where(row.targets[0])}`);
@@ -159,7 +163,15 @@ function beatLabel(step: string): string {
159
163
  if (step === "0") {
160
164
  return "as it arrives";
161
165
  }
162
- return /^\d+$/.test(step) ? `at beat ${step}` : `at beat \`${step}\``;
166
+ if (/^\d+$/.test(step)) {
167
+ return `at beat ${step}`;
168
+ }
169
+ return isPlainWord(step) ? `at beat \`${step}\`` : "at beat <step>";
170
+ }
171
+
172
+ /** `word` as it is when it is plain, else `placeholder`: see `isPlainWord`. */
173
+ function shown(word: string, placeholder: string): string {
174
+ return isPlainWord(word) ? word : placeholder;
163
175
  }
164
176
 
165
177
  function describe(target: TargetRow): string {
@@ -9,6 +9,7 @@ import {
9
9
  } from "./annotation-rows.ts";
10
10
  import { deckPaths } from "./deck-paths.ts";
11
11
  import { DekError } from "./error.ts";
12
+ import { shellName } from "./escape.ts";
12
13
  import { deckSlides, type WrittenElement, writtenElements } from "./html.ts";
13
14
  import { type ProjectDeck, readDeckFile } from "./resolve.ts";
14
15
  import { readSourceIfExists, writeInside } from "./safe-fs.ts";
@@ -306,11 +307,28 @@ function toRow(
306
307
  text: note.text,
307
308
  targets,
308
309
  changed,
309
- shot: `dekc shot ${deck.name} ${note.slug} --step ${note.step}`,
310
+ shot: shotCommand(deck.name, note.slug, note.step),
310
311
  createdAt: note.createdAt,
311
312
  };
312
313
  }
313
314
 
315
+ /**
316
+ * The shot of a note's slide at its beat, as a command to paste. Each name is the file's, so one
317
+ * that no word holds alike in every shell is a placeholder, and one that starts with a dash goes
318
+ * after `--`, where it reads as no flag.
319
+ */
320
+ function shotCommand(deck: string, slug: string, step: string): string {
321
+ const [name, slide, beat] = [
322
+ shellName(deck) ?? "<deck>",
323
+ shellName(slug) ?? "<slug>",
324
+ shellName(step) ?? "<step>",
325
+ ];
326
+ const flag = step.startsWith("-") ? `--step=${beat}` : `--step ${beat}`;
327
+ return deck.startsWith("-") || slug.startsWith("-")
328
+ ? `dekc shot ${flag} -- ${name} ${slide}`
329
+ : `dekc shot ${name} ${slide} ${flag}`;
330
+ }
331
+
314
332
  /** A hash of each of the slide's own files there is, by its extension. */
315
333
  function slideFileHashes(deck: ProjectDeck, slug: string): Record<string, string> {
316
334
  const paths = deckPaths(deck.dir);
@@ -1,9 +1,19 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { dirname } from "node:path";
2
2
  import { z } from "zod";
3
+ import { DekError } from "./error.ts";
4
+ import { shellQuote } from "./escape.ts";
3
5
  import { isPackLicense, PACK_LICENSES, type PackLicense } from "./licenses.ts";
4
6
  import { parsePublicUrl } from "./ogp.ts";
5
- import { isPinFor, isPlainRefName, packFile, parseRefSource } from "./ref-name.ts";
7
+ import {
8
+ isPinFor,
9
+ isPlainRefName,
10
+ packFile,
11
+ parseRefSource,
12
+ REF_DECK_NAME_RULE,
13
+ } from "./ref-name.ts";
14
+ import { readSourceIfExists } from "./safe-fs.ts";
6
15
  import { DeckSettings } from "./schema.ts";
16
+ import { scanTomlKeys } from "./toml-keys.ts";
7
17
  import { parseTomlWith } from "./zod.ts";
8
18
 
9
19
  export const Voice = z.object({
@@ -32,20 +42,21 @@ export const DekToml = z.object({
32
42
  ...DeckSettings.partial().shape,
33
43
  voice: Voice.optional(),
34
44
  pack: Pack.optional(),
45
+ // Each key is checked before its value, so a key that names no ref is never repeated.
35
46
  refs: z
36
- .record(z.string(), z.string())
47
+ .record(z.string(), z.unknown())
37
48
  .superRefine((refs, ctx) => {
38
49
  for (const [name, rev] of Object.entries(refs)) {
39
50
  if (!isPlainRefName(name)) {
40
51
  ctx.addIssue({
41
52
  code: "custom",
42
- path: [name],
43
- message: "a ref is named owner/repo/deck, or file/<deck> for a pack",
53
+ path: [],
54
+ message: `${REFUSED_REF_KEY}; ${REFUSED_REF_HINT}`,
44
55
  });
45
56
  continue;
46
57
  }
47
58
  const source = parseRefSource(name);
48
- if (isPinFor(source, rev)) {
59
+ if (typeof rev === "string" && isPinFor(source, rev)) {
49
60
  continue;
50
61
  }
51
62
  ctx.addIssue({
@@ -53,14 +64,49 @@ export const DekToml = z.object({
53
64
  path: [name],
54
65
  message:
55
66
  source.kind === "file"
56
- ? `pin a pack's ref to the 64-character sha256 of its files; run \`dekc ref ${packFile(source.deck)}\` to pin one`
57
- : `pin a ref to a 40-character commit sha; run \`dekc ref ${name}\` to pin one`,
67
+ ? `pin a pack's ref to the 64-character sha256 of its files; run \`dekc ref ${shellQuote(packFile(source.deck))}\` to pin one`
68
+ : `pin a ref to a 40-character commit sha; run \`dekc ref ${shellQuote(name)}\` to pin one`,
58
69
  });
59
70
  }
60
71
  })
72
+ .transform((refs) => refs as Record<string, string>)
61
73
  .optional(),
62
74
  });
63
75
 
76
+ const REFUSED_REF_KEY = "a key in [refs] names no ref";
77
+
78
+ const REFUSED_REF_HINT = `delete that line, and for a pack its packs/<deck>.tgz and refs/file/<deck>, then pin the deck again under a name a ref can carry: a ref is named owner/repo/deck, or file/<deck> for a pack, and ${REF_DECK_NAME_RULE}`;
79
+
80
+ /**
81
+ * The first key in [refs] that names no ref, by its line when the file says it plainly. Such a
82
+ * key may be someone else's text, from a project cloned or a pack added before dek checked it, so
83
+ * the error points at it and never repeats it.
84
+ */
85
+ function refusedRefKey(source: string): { line?: number } | undefined {
86
+ let parsed: unknown;
87
+ try {
88
+ parsed = Bun.TOML.parse(source);
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ const refs = (parsed as { refs?: unknown } | null)?.refs;
93
+ const name =
94
+ refs !== null && typeof refs === "object"
95
+ ? Object.keys(refs).find((key) => !isPlainRefName(key))
96
+ : undefined;
97
+ if (name === undefined) {
98
+ return undefined;
99
+ }
100
+ const written = scanTomlKeys(source).filter(
101
+ (entry) => entry.path[0] === "refs" && entry.path.length === 2,
102
+ );
103
+ // A key written with escapes reads differently on its line; any key there that names no ref is it.
104
+ const entry =
105
+ written.find((key) => key.path[1] === name) ??
106
+ written.find((key) => !isPlainRefName(key.path[1] ?? ""));
107
+ return entry === undefined ? {} : { line: entry.line };
108
+ }
109
+
64
110
  type VoiceDefaults = {
65
111
  engine: string;
66
112
  speaker: string;
@@ -83,6 +129,10 @@ export type DekConfig = {
83
129
  };
84
130
 
85
131
  export function parseDekToml(source: string, path?: string): DekConfig {
132
+ const refused = refusedRefKey(source);
133
+ if (refused) {
134
+ throw new DekError(REFUSED_REF_KEY, { path, ...refused, hint: REFUSED_REF_HINT });
135
+ }
86
136
  const data = parseTomlWith(DekToml, source, {
87
137
  label: "dek.toml",
88
138
  anchor: "dek-toml",
@@ -108,9 +158,8 @@ export function parseDekToml(source: string, path?: string): DekConfig {
108
158
  };
109
159
  }
110
160
 
161
+ /** The project's dek.toml, which sits at its root; a link out of the project is an error. */
111
162
  export function loadConfig(configPath: string): DekConfig {
112
- if (!existsSync(configPath)) {
113
- return {};
114
- }
115
- return parseDekToml(readFileSync(configPath, "utf8"), configPath);
163
+ const source = readSourceIfExists(configPath, dirname(configPath));
164
+ return source === undefined ? {} : parseDekToml(source, configPath);
116
165
  }
@@ -17,7 +17,9 @@ export type Scope = "project" | "deck" | "slide";
17
17
  * (DEK044), an empty heading (DEK024), which a slide script may fill, and a step bound by
18
18
  * position where the beat has an id (DEK025), which is right until a beat is inserted, and a
19
19
  * style several slides repeat (DEK026), which works but belongs in the theme. Text under
20
- * aria-hidden (DEK029) is right on a sample the talk shows as unreadable.
20
+ * aria-hidden (DEK029) is right on a sample the talk shows as unreadable. A line break that
21
+ * reads wrong (DEK035) is fixed by a choice of words or of where to break, which is the author's,
22
+ * and an edge a few pixels off (DEK036) can be meant, as in a picture of something out of line.
21
23
  */
22
24
  export const RULES = {
23
25
  DEK001: { severity: "error", scopes: ["slide"] },
@@ -54,6 +56,8 @@ export const RULES = {
54
56
  DEK032: { severity: "error", scopes: ["slide"] },
55
57
  DEK033: { severity: "error", scopes: ["slide"] },
56
58
  DEK034: { severity: "error", scopes: ["slide"] },
59
+ DEK035: { severity: "warning", scopes: ["slide"] },
60
+ DEK036: { severity: "warning", scopes: ["slide"] },
57
61
  DEK040: { severity: "warning", scopes: ["slide"] },
58
62
  DEK041: { severity: "warning", scopes: ["deck"] },
59
63
  DEK042: { severity: "warning", scopes: ["slide"] },
@@ -14,3 +14,46 @@ export function escapeAttr(text: string): string {
14
14
  export function escapeRegExp(text: string): string {
15
15
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16
16
  }
17
+
18
+ /**
19
+ * A word no shell reads anything in: letters, marks, and digits of any script, and `@%+=:,./_-`,
20
+ * but not first an `=`, which zsh reads as the path of the program named after it.
21
+ */
22
+ const PLAIN_WORD = /^[\p{L}\p{M}\p{N}@%+:,./_-][\p{L}\p{M}\p{N}@%+=:,./_-]*$/u;
23
+
24
+ /** Whether `word` is plain: dek prints it as it is, in a command or in prose. */
25
+ export function isPlainWord(word: string): boolean {
26
+ return PLAIN_WORD.test(word);
27
+ }
28
+
29
+ /** A backtick, as a shell word that holds none: `printf` writes it from its octal code. */
30
+ const BACKTICK_WORD = `"$(printf '\\140')"`;
31
+
32
+ /**
33
+ * `word` as one shell word, as dek writes it in a command to paste: as it is when it is plain,
34
+ * else single-quoted. A command is printed inside a code span, which a backtick would close, so a
35
+ * backtick is written as `printf` of its code, between quoted runs. The quoting is a POSIX shell's,
36
+ * as sh, bash, and zsh read it: fish reads a backslash in single quotes apart, and PowerShell and
37
+ * cmd quote otherwise. A name from a file goes through `shellName` instead.
38
+ */
39
+ export function shellQuote(word: string): string {
40
+ if (isPlainWord(word)) {
41
+ return word;
42
+ }
43
+ const quoted = word
44
+ .split("`")
45
+ .map((run) => (run === "" ? "" : `'${run.replaceAll("'", "'\\''")}'`))
46
+ .join(BACKTICK_WORD);
47
+ return quoted === "" ? "''" : quoted;
48
+ }
49
+
50
+ /**
51
+ * A name from a file, such as a deck's folder or a slide's file, as a word in a command dek
52
+ * suggests, or nothing when it has none: fish reads a quote or a backslash in it apart from a
53
+ * POSIX shell, and a control character would start a line of its own. The command then shows a
54
+ * placeholder, such as `<deck>`, and says where the name is.
55
+ */
56
+ export function shellName(name: string): string | undefined {
57
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: a control in a name is the problem.
58
+ return /['\\\x00-\x1f\x7f-\x9f]/.test(name) ? undefined : shellQuote(name);
59
+ }