@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
package/src/core/ref.ts CHANGED
@@ -1,12 +1,23 @@
1
- import { existsSync, lstatSync, readFileSync } from "node:fs";
1
+ import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
2
+ // biome-ignore lint/style/noRestrictedImports: mkdir, writeFile, rename, and rm, which write a snapshot into a fresh folder in refs/, which outputDir checked, and swap it in whole.
2
3
  import { mkdir, rename, rm, writeFile } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
4
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
5
  import { z } from "zod";
5
6
  import { loadConfig } from "./config.ts";
6
7
  import { DekError } from "./error.ts";
7
- import { parseRefSource, type RefSource } from "./ref-name.ts";
8
- import { listSlides, resolveProject } from "./resolve.ts";
9
- import { outputDir } from "./safe-fs.ts";
8
+ import { shellQuote } from "./escape.ts";
9
+ import { foreignList } from "./foreign-text.ts";
10
+ import {
11
+ isPlainRefName,
12
+ isRefDeckName,
13
+ packFile,
14
+ parseRefSource,
15
+ REF_DECK_NAME_RULE,
16
+ type RefSource,
17
+ } from "./ref-name.ts";
18
+ import { listSlides, type Project, resolveProject } from "./resolve.ts";
19
+ import { followInside, outputDir } from "./safe-fs.ts";
20
+ import { readTar } from "./tar.ts";
10
21
 
11
22
  export {
12
23
  isPinnedRev,
@@ -24,6 +35,49 @@ function isLink(path: string): boolean {
24
35
  }
25
36
  }
26
37
 
38
+ /** The first link on the way from `<root>/<top>` down to `path`, both included, if any. */
39
+ function linkUnder(root: string, top: "refs" | "packs", path: string): string | undefined {
40
+ let at = join(root, top);
41
+ for (const segment of ["", ...relative(at, path).split(sep)]) {
42
+ at = join(at, segment);
43
+ if (isLink(at)) {
44
+ return at;
45
+ }
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ /**
51
+ * Refuses a link on the way to the snapshot at `dir`. refs/ is dek's own folder, which a cloned
52
+ * project can still commit: a link there, even one that stays in the project, would aim a
53
+ * snapshot's swap and its delete at what it points to, such as a deck under decks/.
54
+ */
55
+ export function refuseLinkUnderRefs(root: string, dir: string): void {
56
+ const link = linkUnder(root, "refs", dir);
57
+ if (link !== undefined) {
58
+ const rel = relative(root, link);
59
+ throw new DekError(`${rel} is a symlink; dek follows no link under refs/`, {
60
+ path: link,
61
+ hint: `remove ${rel}: refs/ holds only the snapshots \`dekc ref\` writes, and the next read of a pinned ref fetches it again`,
62
+ });
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Refuses a link at packs/, or at the copy of `deck`'s pack kept there. dek writes and removes
68
+ * that copy by name, so through a link it would overwrite, or delete, what the link points to.
69
+ */
70
+ export function refuseLinkUnderPacks(root: string, deck: string): void {
71
+ const link = linkUnder(root, "packs", join(root, packFile(deck)));
72
+ if (link !== undefined) {
73
+ const rel = relative(root, link);
74
+ throw new DekError(`${rel} is a symlink; dek follows no link under packs/`, {
75
+ path: link,
76
+ hint: `remove ${rel}, then run \`dekc ref <file>.tgz\` with the pack you were sent: packs/ holds the copies it keeps, as plain files`,
77
+ });
78
+ }
79
+ }
80
+
27
81
  /** Written into each snapshot; its presence is what makes a directory a ref. */
28
82
  export const REF_MARKER = ".ref.json";
29
83
 
@@ -44,8 +98,10 @@ export function refDir(root: string, name: string): string {
44
98
 
45
99
  /** The marker of a snapshot, or nothing when it is missing or not one dek wrote. */
46
100
  export function readRefMeta(dir: string): RefMeta | undefined {
47
- const path = join(dir, REF_MARKER);
48
- if (!existsSync(path)) {
101
+ const marker = join(dir, REF_MARKER);
102
+ // dek writes the marker as a plain file; a link out of the snapshot is none of its.
103
+ const path = existsSync(marker) ? followInside(marker, dir) : undefined;
104
+ if (path === undefined) {
49
105
  return undefined;
50
106
  }
51
107
  try {
@@ -56,6 +112,87 @@ export function readRefMeta(dir: string): RefMeta | undefined {
56
112
  }
57
113
  }
58
114
 
115
+ /**
116
+ * The snapshot `dir` is in, and the ref it holds: the outermost folder from `dir` up that is one
117
+ * dek wrote. A ref may carry a dek.toml, and a marker, of its own in its deck's assets; neither
118
+ * hides the snapshot around them or names it. A marker anywhere else makes nothing a ref.
119
+ */
120
+ function refAround(dir: string): { dir: string; name: string } | undefined {
121
+ let found: { dir: string; name: string } | undefined;
122
+ for (let at = resolve(dir); ; at = dirname(at)) {
123
+ const name = snapshotName(at);
124
+ if (name !== undefined) {
125
+ found = { dir: at, name };
126
+ }
127
+ if (dirname(at) === at) {
128
+ return found;
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * The ref `dir` holds, when it is a snapshot as dek writes one: a dek.toml beside a marker that
135
+ * names a ref, at that ref's own place under a project's refs/.
136
+ */
137
+ export function snapshotName(dir: string): string | undefined {
138
+ if (!isThere(join(dir, "dek.toml"))) {
139
+ return undefined;
140
+ }
141
+ const name = readRefMeta(dir)?.name;
142
+ if (name === undefined || !isPlainRefName(name)) {
143
+ return undefined;
144
+ }
145
+ // The same folder, not the same spelling: a disk that ignores case, or a link at refs/, gives
146
+ // the path to a snapshot another spelling than its name.
147
+ const place = refDir("", name);
148
+ const project = resolve(dir, ...place.split(sep).map(() => ".."));
149
+ return isSameFolder(join(project, place), dir) ? name : undefined;
150
+ }
151
+
152
+ function isSameFolder(a: string, b: string): boolean {
153
+ try {
154
+ const [one, other] = [statSync(a), statSync(b)];
155
+ return one.dev === other.dev && one.ino === other.ino;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ function isThere(path: string): boolean {
162
+ try {
163
+ lstatSync(path);
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /** Refuses `dir` when it is inside a ref: no command, the dev server's included, runs or writes there. */
171
+ export function refuseInsideRef(dir: string): void {
172
+ const ref = refAround(dir);
173
+ if (ref !== undefined) {
174
+ throw new DekError("this directory is inside a ref; refs are read-only", {
175
+ path: ref.dir,
176
+ hint: `run dek from your own project instead: \`dekc show ${ref.name} <slug>\``,
177
+ });
178
+ }
179
+ }
180
+
181
+ /** The project around `cwd`, which is the user's own: never one inside a ref. */
182
+ export function requireProject(cwd: string): Project {
183
+ const project = resolveProject(cwd);
184
+ refuseInsideRef(project.root);
185
+ return project;
186
+ }
187
+
188
+ /** What a command that writes says when it is handed a ref by name. */
189
+ export function refReadOnlyError(name: string): DekError {
190
+ const ref = shellQuote(name);
191
+ return new DekError(`"${name}" is a ref; refs are read-only`, {
192
+ hint: `start a deck on its theme with \`dekc new <name> --theme-from ${ref}\`, or read it with \`dekc show ${ref} <slug>\` and write what you need in your own deck's theme`,
193
+ });
194
+ }
195
+
59
196
  export const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
60
197
 
61
198
  /** The snapshot's license file, relative to the snapshot, when the source had one. */
@@ -81,7 +218,11 @@ export function refState(project: { root: string; configPath: string }, name: st
81
218
  name,
82
219
  ...(pinned !== undefined ? { pinned } : {}),
83
220
  dir,
84
- fetched: pinned !== undefined && readRefMeta(dir)?.rev === pinned,
221
+ // Never through a link under refs/: one is fetched again, which refuses the link.
222
+ fetched:
223
+ pinned !== undefined &&
224
+ linkUnder(project.root, "refs", dir) === undefined &&
225
+ readRefMeta(dir)?.rev === pinned,
85
226
  };
86
227
  }
87
228
 
@@ -94,42 +235,84 @@ export const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024;
94
235
  /** A pack's tar, unzipped, may hold its files and their headers, not much more. */
95
236
  const MAX_PACK_TAR_BYTES = 2 * MAX_SNAPSHOT_BYTES;
96
237
 
97
- const PACK_HINT = "ask for a pack its author made with `dekc pack <deck>`";
98
-
99
238
  /**
100
- * Reads a GitHub tarball. Bun leaves out links; a path that is absolute, has
101
- * an empty segment, or climbs with `..` is dropped here, so nothing written
102
- * later can land outside the snapshot.
239
+ * A repository's tar, unzipped. A repository of text inflates some 3.5 times, so about five
240
+ * times the 200 MB dek downloads of it: 1 GB.
103
241
  */
104
- export async function readTarball(bytes: Uint8Array): Promise<RepoFiles> {
105
- const files: RepoFiles = new Map();
106
- for (const [path, blob] of await new Bun.Archive(bytes).files()) {
107
- const [, ...segments] = path.split("/");
108
- const safe =
109
- segments.length > 0 &&
110
- !path.includes("\\") &&
111
- segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
112
- if (safe && !path.startsWith("/")) {
113
- files.set(segments.join("/"), blob);
114
- }
115
- }
116
- return files;
242
+ const MAX_TARBALL_TAR_BYTES = 1024 * 1024 * 1024;
243
+
244
+ /** A deck is a few hundred files; a pack of more is not one `dekc pack` made. */
245
+ const MAX_PACK_ENTRIES = 10_000;
246
+
247
+ /** A repository's files, folders, and the headers before them, as many as a large one holds. */
248
+ const MAX_TARBALL_ENTRIES = 200_000;
249
+
250
+ const PACK_HINT = "ask for a pack its author made with `dekc pack <deck>`";
251
+
252
+ /** Reads a GitHub tarball, as `readGzippedTar` reads any. */
253
+ export async function readTarball(
254
+ bytes: Uint8Array,
255
+ limits: { maxTarBytes?: number; maxEntries?: number } = {},
256
+ ): Promise<RepoFiles> {
257
+ const maxTarBytes = limits.maxTarBytes ?? MAX_TARBALL_TAR_BYTES;
258
+ const maxEntries = limits.maxEntries ?? MAX_TARBALL_ENTRIES;
259
+ return readGzippedTar(
260
+ bytes,
261
+ { maxTarBytes, maxEntries },
262
+ {
263
+ tooLarge: () =>
264
+ new DekError(`the repository unpacks to more than ${formatMb(maxTarBytes)}`, {
265
+ hint: "dekc ref downloads and unpacks the whole repository, so keep decks in a smaller repository, or ask the deck's author for a pack made with `dekc pack <deck>` and add it with `dekc ref <file>.tgz`",
266
+ }),
267
+ notTar: () =>
268
+ new DekError("GitHub's tarball of the repository is not a gzipped tar", {
269
+ hint: "run the command again",
270
+ }),
271
+ holds: (what) =>
272
+ new DekError(`the repository's tarball holds ${what}, which dek does not read`, {
273
+ hint: "the repository holds something dek cannot read; ask the deck's author for a pack made with `dekc pack <deck>` and pin the deck from it with `dekc ref <file>.tgz`",
274
+ }),
275
+ },
276
+ );
117
277
  }
118
278
 
119
- /**
120
- * Reads a pack from someone else. It is gunzipped through a count that stops at `maxTarBytes`
121
- * before Bun takes any of it apart, so a small file that inflates without end is refused rather
122
- * than held in memory; the tar is then read as a GitHub tarball is.
123
- */
279
+ /** Reads a pack from someone else, no larger than `maxBytes`, as `readGzippedTar` reads any. */
124
280
  export async function readPack(
125
281
  bytes: Uint8Array,
126
- limits: { maxBytes?: number; maxTarBytes?: number } = {},
282
+ limits: { maxBytes?: number; maxTarBytes?: number; maxEntries?: number } = {},
127
283
  ): Promise<RepoFiles> {
128
284
  const maxBytes = limits.maxBytes ?? MAX_SNAPSHOT_BYTES;
129
285
  const maxTarBytes = limits.maxTarBytes ?? MAX_PACK_TAR_BYTES;
286
+ const maxEntries = limits.maxEntries ?? MAX_PACK_ENTRIES;
130
287
  if (bytes.byteLength > maxBytes) {
131
288
  throw new DekError(`the pack is larger than ${formatMb(maxBytes)}`, { hint: PACK_HINT });
132
289
  }
290
+ return readGzippedTar(
291
+ bytes,
292
+ { maxTarBytes, maxEntries },
293
+ {
294
+ tooLarge: () =>
295
+ new DekError(`the pack unpacks to more than ${formatMb(maxTarBytes)}`, { hint: PACK_HINT }),
296
+ notTar: () =>
297
+ new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT }),
298
+ holds: (what) =>
299
+ new DekError(`the pack holds ${what}, which dek does not read`, { hint: PACK_HINT }),
300
+ },
301
+ );
302
+ }
303
+
304
+ /**
305
+ * The files of a gzipped tar from elsewhere. It is gunzipped through a count that stops at
306
+ * `maxTarBytes`, so a small file that inflates without end is refused rather than held in
307
+ * memory, and the tar is then read by dek's own reader (`readTar`), whose files are the bytes
308
+ * the tar stores and no more. The top folder is dropped, and so is a path that is absolute, has
309
+ * an empty segment, or climbs with `..`, so nothing written later can land outside the snapshot.
310
+ */
311
+ async function readGzippedTar(
312
+ bytes: Uint8Array,
313
+ { maxTarBytes, maxEntries }: { maxTarBytes: number; maxEntries: number },
314
+ errors: { tooLarge: () => DekError; notTar: () => DekError; holds: (what: string) => DekError },
315
+ ): Promise<RepoFiles> {
133
316
  const chunks: Uint8Array[] = [];
134
317
  let size = 0;
135
318
  try {
@@ -145,9 +328,7 @@ export async function readPack(
145
328
  size += value.byteLength;
146
329
  if (size > maxTarBytes) {
147
330
  await reader.cancel();
148
- throw new DekError(`the pack unpacks to more than ${formatMb(maxTarBytes)}`, {
149
- hint: PACK_HINT,
150
- });
331
+ throw errors.tooLarge();
151
332
  }
152
333
  chunks.push(value);
153
334
  }
@@ -155,7 +336,7 @@ export async function readPack(
155
336
  if (error instanceof DekError) {
156
337
  throw error;
157
338
  }
158
- throw new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT });
339
+ throw errors.notTar();
159
340
  }
160
341
  const tar = new Uint8Array(size);
161
342
  let offset = 0;
@@ -163,15 +344,36 @@ export async function readPack(
163
344
  tar.set(chunk, offset);
164
345
  offset += chunk.byteLength;
165
346
  }
166
- try {
167
- return await readTarball(tar);
168
- } catch {
169
- throw new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT });
347
+ // The tar holds them all now; let them go before its files are made.
348
+ chunks.length = 0;
349
+ const entries = readTar(tar, { maxBytes: maxTarBytes, maxEntries });
350
+ if (!(entries instanceof Map)) {
351
+ throw entries.kind === "too large"
352
+ ? errors.tooLarge()
353
+ : entries.kind === "not a tar"
354
+ ? errors.notTar()
355
+ : errors.holds(entries.what);
170
356
  }
357
+ const files: RepoFiles = new Map();
358
+ for (const [path, blob] of entries) {
359
+ const [, ...segments] = path.split("/");
360
+ const safe =
361
+ segments.length > 0 &&
362
+ !path.includes("\\") &&
363
+ segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
364
+ if (safe && !path.startsWith("/")) {
365
+ files.set(segments.join("/"), blob);
366
+ }
367
+ }
368
+ return files;
171
369
  }
172
370
 
173
371
  function formatMb(bytes: number): string {
174
- return bytes >= 1024 * 1024 ? `${bytes / 1024 / 1024} MB` : `${bytes} bytes`;
372
+ const mb = 1024 * 1024;
373
+ if (bytes >= 1024 * mb && bytes % (1024 * mb) === 0) {
374
+ return `${bytes / 1024 / mb} GB`;
375
+ }
376
+ return bytes >= mb ? `${bytes / mb} MB` : `${bytes} bytes`;
175
377
  }
176
378
 
177
379
  const DECK_SCRIPT_RE = /^(?:(.*)\/)?decks\/([^/]+)\/script\.md$/;
@@ -185,7 +387,37 @@ function repoDecks(files: RepoFiles): Array<{ name: string; prefix: string }> {
185
387
  });
186
388
  }
187
389
 
188
- /** The name of the one deck a pack holds; a pack of none, or of more, is refused. */
390
+ /**
391
+ * Names from someone else's repository or pack as a message lists them: those `shows` passes,
392
+ * as foreignList shows them, then how many others there are, in the words of `others`.
393
+ */
394
+ function listCarried(
395
+ names: string[],
396
+ shows: (name: string) => boolean,
397
+ others: (count: number) => string,
398
+ ): string {
399
+ const shown = names.filter(shows);
400
+ const left = names.length - shown.length;
401
+ if (left === 0) {
402
+ return foreignList(shown);
403
+ }
404
+ return shown.length === 0 ? others(left) : `${foreignList(shown)}, and ${others(left)}`;
405
+ }
406
+
407
+ /** Deck names as a message lists them: only those a ref can carry. */
408
+ function listDecks(names: string[]): string {
409
+ return listCarried(
410
+ names,
411
+ isRefDeckName,
412
+ (count) => `${count} whose ${count === 1 ? "name" : "names"} a ref cannot carry`,
413
+ );
414
+ }
415
+
416
+ /**
417
+ * The name of the one deck a pack holds; a pack of none, of more, or of one no ref can name is
418
+ * refused. The names are the pack author's, so the message lists them as listDecks does, and the
419
+ * hint is dek's.
420
+ */
189
421
  export function deckOfPack(files: RepoFiles): string {
190
422
  const names = [...new Set(repoDecks(files).map((deck) => deck.name))].sort();
191
423
  const [name, ...others] = names;
@@ -195,14 +427,23 @@ export function deckOfPack(files: RepoFiles): string {
195
427
  });
196
428
  }
197
429
  if (others.length > 0) {
198
- throw new DekError(`the pack holds more than one deck: ${names.join(", ")}`, {
430
+ throw new DekError(`the pack holds more than one deck: ${listDecks(names)}`, {
199
431
  hint: `a pack is one deck; ${PACK_HINT}`,
200
432
  });
201
433
  }
434
+ if (!isRefDeckName(name)) {
435
+ throw new DekError("the pack's deck has a name a ref cannot carry", {
436
+ hint: `${REF_DECK_NAME_RULE}; ask its author to rename the deck's folder and pack it again`,
437
+ });
438
+ }
202
439
  return name;
203
440
  }
204
441
 
205
- /** The directory of the project that holds the deck, as a prefix: "" or "sample/". */
442
+ /**
443
+ * The directory of the project that holds the deck, as a prefix: "" or "sample/". The folder
444
+ * names the source holds are its author's: an error lists them in its message, as foreignList
445
+ * does, and its hint is dek's words alone.
446
+ */
206
447
  export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
207
448
  const repo = source.kind === "file" ? "the pack" : `${source.owner}/${source.repo}`;
208
449
  const path = source.kind === "github" ? source.path : undefined;
@@ -215,17 +456,32 @@ export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
215
456
  const [match, ...others] = matches;
216
457
  if (!match) {
217
458
  const names = [...new Set(decks.map((deck) => deck.name))].sort();
218
- throw new DekError(`${repo} has no deck "${source.deck}"`, {
459
+ if (names.length === 0) {
460
+ throw new DekError(`${repo} has no deck "${source.deck}"`, {
461
+ hint: `${repo} holds no dek project (decks/<name>/script.md beside a dek.toml)`,
462
+ });
463
+ }
464
+ throw new DekError(`${repo} has no deck "${source.deck}", only ${listDecks(names)}`, {
219
465
  hint:
220
- names.length > 0
221
- ? `decks in ${repo}: ${names.join(", ")}`
222
- : `${repo} holds no dek project (decks/<name>/script.md beside a dek.toml)`,
466
+ source.kind === "file"
467
+ ? `add the pack you were sent with \`dekc ref <file>.tgz\`, which pins the deck it holds`
468
+ : names.some(isRefDeckName)
469
+ ? `pin one of them: \`dekc ref ${repo}/<deck>\``
470
+ : `${REF_DECK_NAME_RULE}; ask its author to rename the deck's folder`,
223
471
  });
224
472
  }
225
473
  if (others.length > 0) {
226
474
  const paths = matches.map((deck) => `${deck.prefix}decks/${deck.name}`).sort();
227
- throw new DekError(`${repo} has more than one deck named "${source.deck}"`, {
228
- hint: `pass the GitHub link to the one you mean; they are at ${paths.join(", ")}`,
475
+ const listed = listCarried(
476
+ paths,
477
+ (path) => path.split("/").every(isRefDeckName),
478
+ (count) => `${count} whose ${count === 1 ? "path" : "paths"} dek does not print`,
479
+ );
480
+ throw new DekError(`${repo} has more than one deck named "${source.deck}", at ${listed}`, {
481
+ hint:
482
+ source.kind === "file"
483
+ ? `a pack is one deck; ${PACK_HINT}`
484
+ : `pass the GitHub link to the folder of the one you mean, https://github.com/${repo}/tree/<rev>/<path>`,
229
485
  });
230
486
  }
231
487
  return match.prefix;
@@ -319,13 +575,11 @@ export async function writeSnapshot(
319
575
  pin: string,
320
576
  snapshot: Snapshot,
321
577
  ): Promise<{ dir: string; license: string | null; path: string }> {
322
- // refs/ is dek's; a link on the way would aim the swap below, and its delete, elsewhere.
323
578
  const refsDir = join(root, "refs");
324
579
  const dir = refDir(root, name);
580
+ // A link on the way would aim the swap below, and its delete, at what it points to.
581
+ refuseLinkUnderRefs(root, dir);
325
582
  outputDir(dirname(dir), root);
326
- if (isLink(dir)) {
327
- await rm(dir);
328
- }
329
583
  const suffix = `${process.pid}-${Date.now().toString(36)}`;
330
584
  const temp = join(refsDir, `.tmp-${suffix}`);
331
585
  const old = join(refsDir, `.old-${suffix}`);
@@ -1,10 +1,11 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { join, relative, resolve, sep } from "node:path";
3
3
  import { deckPaths } from "./deck-paths.ts";
4
4
  import { DekError } from "./error.ts";
5
5
  import { walkUp } from "./optional.ts";
6
+ import { compareCodeUnits } from "./order.ts";
6
7
  import { parseScript } from "./parse.ts";
7
- import { deckProjectRoot } from "./path.ts";
8
+ import { deckProjectRoot, isInside } from "./path.ts";
8
9
  import { readSourceIfExists, requireInside } from "./safe-fs.ts";
9
10
  import type { Deck, Section } from "./schema.ts";
10
11
  import { suggest } from "./suggest.ts";
@@ -14,6 +15,11 @@ export type ProjectDeck = {
14
15
  dir: string;
15
16
  scriptPath: string;
16
17
  deck: Deck;
18
+ /**
19
+ * Set on a ref's deck: the folder that stands for its `.cache`, in the project that reads it,
20
+ * and that project's root. A snapshot is never written once `dekc ref` wrote it.
21
+ */
22
+ cache?: { dir: string; root: string };
17
23
  };
18
24
 
19
25
  export type Project = {
@@ -28,11 +34,6 @@ export type ResolvedDeck = {
28
34
  deck: ProjectDeck;
29
35
  };
30
36
 
31
- /** The file's text, or nothing when there is no such file. */
32
- export function readTextIfExists(path: string): string | undefined {
33
- return existsSync(path) ? readFileSync(path, "utf8") : undefined;
34
- }
35
-
36
37
  /**
37
38
  * A file of the deck's, or nothing when there is none. It may be a symlink to elsewhere in the
38
39
  * project, but not out of it: what a deck reads, a build publishes.
@@ -115,7 +116,7 @@ export function listSlideFiles(
115
116
  readdirSync(dir)
116
117
  // A declaration file carries types for the editor, not a slide.
117
118
  .filter((name) => name.endsWith(ext) && !name.endsWith(".d.ts"))
118
- .sort((a, b) => a.localeCompare(b))
119
+ .sort(compareCodeUnits)
119
120
  .flatMap((name) => {
120
121
  const path = join(dir, name);
121
122
  if (!statSync(requireInside(path, root)).isFile()) {
@@ -150,16 +151,27 @@ function loadDecks(root: string): {
150
151
  if (!existsSync(decksDir)) {
151
152
  return { decks: [], failed: [] };
152
153
  }
153
- if (!statSync(decksDir).isDirectory()) {
154
+ // A link out of the project would list, serve, and watch the folders of wherever it leads. It
155
+ // may lead elsewhere in the project, but not into a hidden folder; a folder has no kind to keep.
156
+ const realRoot = realpathSync(root);
157
+ const realDecks = realpathSync(decksDir);
158
+ const rel = relative(realRoot, realDecks);
159
+ if (!isInside(realDecks, realRoot) || rel.split(sep).some((part) => part.startsWith("."))) {
160
+ throw new DekError("decks leads outside the project", {
161
+ path: decksDir,
162
+ hint: "dek follows decks/ only to a folder inside the project and outside hidden folders; move the decks into the project instead",
163
+ });
164
+ }
165
+ if (!statSync(realDecks).isDirectory()) {
154
166
  throw new DekError("decks is not a directory", { path: decksDir });
155
167
  }
156
168
 
157
169
  const decks: ProjectDeck[] = [];
158
170
  const failed: Project["failed"] = [];
159
171
 
160
- for (const entry of readdirSync(decksDir, { withFileTypes: true })
172
+ for (const entry of readdirSync(realDecks, { withFileTypes: true })
161
173
  .filter((item) => item.isDirectory())
162
- .sort((a, b) => a.name.localeCompare(b.name))) {
174
+ .sort((a, b) => compareCodeUnits(a.name, b.name))) {
163
175
  const loaded = readDeck(root, entry.name);
164
176
  if (loaded.ok) {
165
177
  decks.push(loaded.deck);