@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
package/src/core/ref.ts CHANGED
@@ -1,16 +1,26 @@
1
- import { existsSync, lstatSync, readFileSync } from "node:fs";
1
+ import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
2
2
  import { mkdir, rename, rm, writeFile } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { loadConfig } from "./config.ts";
6
6
  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";
7
+ import { shellQuote } from "./escape.ts";
8
+ import { listShown } from "./prose.ts";
9
+ import {
10
+ isPlainRefName,
11
+ isRefDeckName,
12
+ packFile,
13
+ parseRefSource,
14
+ REF_DECK_NAME_RULE,
15
+ type RefSource,
16
+ } from "./ref-name.ts";
17
+ import { listSlides, type Project, resolveProject } from "./resolve.ts";
18
+ import { followInside, outputDir } from "./safe-fs.ts";
10
19
 
11
20
  export {
12
21
  isPinnedRev,
13
22
  isRefName,
23
+ packFile,
14
24
  parseRefSource,
15
25
  type RefSource,
16
26
  } from "./ref-name.ts";
@@ -23,6 +33,49 @@ function isLink(path: string): boolean {
23
33
  }
24
34
  }
25
35
 
36
+ /** The first link on the way from `<root>/<top>` down to `path`, both included, if any. */
37
+ function linkUnder(root: string, top: "refs" | "packs", path: string): string | undefined {
38
+ let at = join(root, top);
39
+ for (const segment of ["", ...relative(at, path).split(sep)]) {
40
+ at = join(at, segment);
41
+ if (isLink(at)) {
42
+ return at;
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ /**
49
+ * Refuses a link on the way to the snapshot at `dir`. refs/ is dek's own folder, which a cloned
50
+ * project can still commit: a link there, even one that stays in the project, would aim a
51
+ * snapshot's swap and its delete at what it points to, such as a deck under decks/.
52
+ */
53
+ export function refuseLinkUnderRefs(root: string, dir: string): void {
54
+ const link = linkUnder(root, "refs", dir);
55
+ if (link !== undefined) {
56
+ const rel = relative(root, link);
57
+ throw new DekError(`${rel} is a symlink; dek follows no link under refs/`, {
58
+ path: link,
59
+ hint: `remove ${rel}: refs/ holds only the snapshots \`dekc ref\` writes, and the next read of a pinned ref fetches it again`,
60
+ });
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Refuses a link at packs/, or at the copy of `deck`'s pack kept there. dek writes and removes
66
+ * that copy by name, so through a link it would overwrite, or delete, what the link points to.
67
+ */
68
+ export function refuseLinkUnderPacks(root: string, deck: string): void {
69
+ const link = linkUnder(root, "packs", join(root, packFile(deck)));
70
+ if (link !== undefined) {
71
+ const rel = relative(root, link);
72
+ throw new DekError(`${rel} is a symlink; dek follows no link under packs/`, {
73
+ path: link,
74
+ hint: `remove ${rel}, then run \`dekc ref <file>.tgz\` with the pack you were sent: packs/ holds the copies it keeps, as plain files`,
75
+ });
76
+ }
77
+ }
78
+
26
79
  /** Written into each snapshot; its presence is what makes a directory a ref. */
27
80
  export const REF_MARKER = ".ref.json";
28
81
 
@@ -35,14 +88,18 @@ export const RefMeta = z.object({
35
88
  export type RefMeta = z.infer<typeof RefMeta>;
36
89
 
37
90
  export function refDir(root: string, name: string): string {
38
- const { owner, repo, deck } = parseRefSource(name);
39
- return join(root, "refs", owner, repo, deck);
91
+ const source = parseRefSource(name);
92
+ return source.kind === "file"
93
+ ? join(root, "refs", "file", source.deck)
94
+ : join(root, "refs", source.owner, source.repo, source.deck);
40
95
  }
41
96
 
42
97
  /** The marker of a snapshot, or nothing when it is missing or not one dek wrote. */
43
98
  export function readRefMeta(dir: string): RefMeta | undefined {
44
- const path = join(dir, REF_MARKER);
45
- if (!existsSync(path)) {
99
+ const marker = join(dir, REF_MARKER);
100
+ // dek writes the marker as a plain file; a link out of the snapshot is none of its.
101
+ const path = existsSync(marker) ? followInside(marker, dir) : undefined;
102
+ if (path === undefined) {
46
103
  return undefined;
47
104
  }
48
105
  try {
@@ -53,7 +110,88 @@ export function readRefMeta(dir: string): RefMeta | undefined {
53
110
  }
54
111
  }
55
112
 
56
- const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
113
+ /**
114
+ * The snapshot `dir` is in, and the ref it holds: the outermost folder from `dir` up that is one
115
+ * dek wrote. A ref may carry a dek.toml, and a marker, of its own in its deck's assets; neither
116
+ * hides the snapshot around them or names it. A marker anywhere else makes nothing a ref.
117
+ */
118
+ function refAround(dir: string): { dir: string; name: string } | undefined {
119
+ let found: { dir: string; name: string } | undefined;
120
+ for (let at = resolve(dir); ; at = dirname(at)) {
121
+ const name = snapshotName(at);
122
+ if (name !== undefined) {
123
+ found = { dir: at, name };
124
+ }
125
+ if (dirname(at) === at) {
126
+ return found;
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * The ref `dir` holds, when it is a snapshot as dek writes one: a dek.toml beside a marker that
133
+ * names a ref, at that ref's own place under a project's refs/.
134
+ */
135
+ function snapshotName(dir: string): string | undefined {
136
+ if (!isThere(join(dir, "dek.toml"))) {
137
+ return undefined;
138
+ }
139
+ const name = readRefMeta(dir)?.name;
140
+ if (name === undefined || !isPlainRefName(name)) {
141
+ return undefined;
142
+ }
143
+ // The same folder, not the same spelling: a disk that ignores case, or a link at refs/, gives
144
+ // the path to a snapshot another spelling than its name.
145
+ const place = refDir("", name);
146
+ const project = resolve(dir, ...place.split(sep).map(() => ".."));
147
+ return isSameFolder(join(project, place), dir) ? name : undefined;
148
+ }
149
+
150
+ function isSameFolder(a: string, b: string): boolean {
151
+ try {
152
+ const [one, other] = [statSync(a), statSync(b)];
153
+ return one.dev === other.dev && one.ino === other.ino;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ function isThere(path: string): boolean {
160
+ try {
161
+ lstatSync(path);
162
+ return true;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ /** Refuses `dir` when it is inside a ref: no command, the dev server's included, runs or writes there. */
169
+ export function refuseInsideRef(dir: string): void {
170
+ const ref = refAround(dir);
171
+ if (ref !== undefined) {
172
+ throw new DekError("this directory is inside a ref; refs are read-only", {
173
+ path: ref.dir,
174
+ hint: `run dek from your own project instead: \`dekc show ${ref.name} <slug>\``,
175
+ });
176
+ }
177
+ }
178
+
179
+ /** The project around `cwd`, which is the user's own: never one inside a ref. */
180
+ export function requireProject(cwd: string): Project {
181
+ const project = resolveProject(cwd);
182
+ refuseInsideRef(project.root);
183
+ return project;
184
+ }
185
+
186
+ /** What a command that writes says when it is handed a ref by name. */
187
+ export function refReadOnlyError(name: string): DekError {
188
+ const ref = shellQuote(name);
189
+ return new DekError(`"${name}" is a ref; refs are read-only`, {
190
+ 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`,
191
+ });
192
+ }
193
+
194
+ export const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
57
195
 
58
196
  /** The snapshot's license file, relative to the snapshot, when the source had one. */
59
197
  export function refLicense(dir: string): string | null {
@@ -78,7 +216,11 @@ export function refState(project: { root: string; configPath: string }, name: st
78
216
  name,
79
217
  ...(pinned !== undefined ? { pinned } : {}),
80
218
  dir,
81
- fetched: pinned !== undefined && readRefMeta(dir)?.rev === pinned,
219
+ // Never through a link under refs/: one is fetched again, which refuses the link.
220
+ fetched:
221
+ pinned !== undefined &&
222
+ linkUnder(project.root, "refs", dir) === undefined &&
223
+ readRefMeta(dir)?.rev === pinned,
82
224
  };
83
225
  }
84
226
 
@@ -86,7 +228,12 @@ export function refState(project: { root: string; configPath: string }, name: st
86
228
  export type RepoFiles = Map<string, Blob>;
87
229
 
88
230
  /** Beyond this, a snapshot is refused: a deck to read is a few hundred kilobytes. */
89
- const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024;
231
+ export const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024;
232
+
233
+ /** A pack's tar, unzipped, may hold its files and their headers, not much more. */
234
+ const MAX_PACK_TAR_BYTES = 2 * MAX_SNAPSHOT_BYTES;
235
+
236
+ const PACK_HINT = "ask for a pack its author made with `dekc pack <deck>`";
90
237
 
91
238
  /**
92
239
  * Reads a GitHub tarball. Bun leaves out links; a path that is absolute, has
@@ -108,6 +255,64 @@ export async function readTarball(bytes: Uint8Array): Promise<RepoFiles> {
108
255
  return files;
109
256
  }
110
257
 
258
+ /**
259
+ * Reads a pack from someone else. It is gunzipped through a count that stops at `maxTarBytes`
260
+ * before Bun takes any of it apart, so a small file that inflates without end is refused rather
261
+ * than held in memory; the tar is then read as a GitHub tarball is.
262
+ */
263
+ export async function readPack(
264
+ bytes: Uint8Array,
265
+ limits: { maxBytes?: number; maxTarBytes?: number } = {},
266
+ ): Promise<RepoFiles> {
267
+ const maxBytes = limits.maxBytes ?? MAX_SNAPSHOT_BYTES;
268
+ const maxTarBytes = limits.maxTarBytes ?? MAX_PACK_TAR_BYTES;
269
+ if (bytes.byteLength > maxBytes) {
270
+ throw new DekError(`the pack is larger than ${formatMb(maxBytes)}`, { hint: PACK_HINT });
271
+ }
272
+ const chunks: Uint8Array[] = [];
273
+ let size = 0;
274
+ try {
275
+ const reader = new Blob([new Uint8Array(bytes)])
276
+ .stream()
277
+ .pipeThrough(new DecompressionStream("gzip"))
278
+ .getReader();
279
+ for (;;) {
280
+ const { done, value } = await reader.read();
281
+ if (done) {
282
+ break;
283
+ }
284
+ size += value.byteLength;
285
+ if (size > maxTarBytes) {
286
+ await reader.cancel();
287
+ throw new DekError(`the pack unpacks to more than ${formatMb(maxTarBytes)}`, {
288
+ hint: PACK_HINT,
289
+ });
290
+ }
291
+ chunks.push(value);
292
+ }
293
+ } catch (error) {
294
+ if (error instanceof DekError) {
295
+ throw error;
296
+ }
297
+ throw new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT });
298
+ }
299
+ const tar = new Uint8Array(size);
300
+ let offset = 0;
301
+ for (const chunk of chunks) {
302
+ tar.set(chunk, offset);
303
+ offset += chunk.byteLength;
304
+ }
305
+ try {
306
+ return await readTarball(tar);
307
+ } catch {
308
+ throw new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT });
309
+ }
310
+ }
311
+
312
+ function formatMb(bytes: number): string {
313
+ return bytes >= 1024 * 1024 ? `${bytes / 1024 / 1024} MB` : `${bytes} bytes`;
314
+ }
315
+
111
316
  const DECK_SCRIPT_RE = /^(?:(.*)\/)?decks\/([^/]+)\/script\.md$/;
112
317
 
113
318
  /** The decks in a repository: each `decks/<name>/script.md` beside its project's dek.toml. */
@@ -119,14 +324,46 @@ function repoDecks(files: RepoFiles): Array<{ name: string; prefix: string }> {
119
324
  });
120
325
  }
121
326
 
327
+ /** Deck names as a hint lists them: only those a ref can carry. */
328
+ function listDecks(names: string[]): string {
329
+ return listShown(
330
+ names,
331
+ isRefDeckName,
332
+ (count) => `${count} whose ${count === 1 ? "name" : "names"} a ref cannot carry`,
333
+ );
334
+ }
335
+
336
+ /** The name of the one deck a pack holds; a pack of none, of more, or of one no ref can name is refused. */
337
+ export function deckOfPack(files: RepoFiles): string {
338
+ const names = [...new Set(repoDecks(files).map((deck) => deck.name))].sort();
339
+ const [name, ...others] = names;
340
+ if (name === undefined) {
341
+ throw new DekError("the pack holds no deck", {
342
+ hint: `a pack is one deck beside a dek.toml; ${PACK_HINT}`,
343
+ });
344
+ }
345
+ if (others.length > 0) {
346
+ throw new DekError(`the pack holds more than one deck: ${listDecks(names)}`, {
347
+ hint: `a pack is one deck; ${PACK_HINT}`,
348
+ });
349
+ }
350
+ if (!isRefDeckName(name)) {
351
+ throw new DekError("the pack's deck has a name a ref cannot carry", {
352
+ hint: `${REF_DECK_NAME_RULE}; ask its author to rename the deck's folder and pack it again`,
353
+ });
354
+ }
355
+ return name;
356
+ }
357
+
122
358
  /** The directory of the project that holds the deck, as a prefix: "" or "sample/". */
123
359
  export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
124
- const repo = `${source.owner}/${source.repo}`;
360
+ const repo = source.kind === "file" ? "the pack" : `${source.owner}/${source.repo}`;
361
+ const path = source.kind === "github" ? source.path : undefined;
125
362
  const decks = repoDecks(files);
126
363
  const matches = decks.filter(
127
364
  (deck) =>
128
365
  deck.name === source.deck &&
129
- (source.path === undefined || `${deck.prefix}decks/${deck.name}` === source.path),
366
+ (path === undefined || `${deck.prefix}decks/${deck.name}` === path),
130
367
  );
131
368
  const [match, ...others] = matches;
132
369
  if (!match) {
@@ -134,21 +371,26 @@ export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
134
371
  throw new DekError(`${repo} has no deck "${source.deck}"`, {
135
372
  hint:
136
373
  names.length > 0
137
- ? `decks in ${repo}: ${names.join(", ")}`
374
+ ? `decks in ${repo}: ${listDecks(names)}`
138
375
  : `${repo} holds no dek project (decks/<name>/script.md beside a dek.toml)`,
139
376
  });
140
377
  }
141
378
  if (others.length > 0) {
142
379
  const paths = matches.map((deck) => `${deck.prefix}decks/${deck.name}`).sort();
380
+ const listed = listShown(
381
+ paths,
382
+ (path) => path.split("/").every(isRefDeckName),
383
+ (count) => `${count} whose ${count === 1 ? "path" : "paths"} a hint cannot show`,
384
+ );
143
385
  throw new DekError(`${repo} has more than one deck named "${source.deck}"`, {
144
- hint: `pass the GitHub link to the one you mean; they are at ${paths.join(", ")}`,
386
+ hint: `pass the GitHub link to the one you mean; they are at ${listed}`,
145
387
  });
146
388
  }
147
389
  return match.prefix;
148
390
  }
149
391
 
150
392
  /** Inside the deck, only what reading it needs: not dist/, .cache/, or voice/. */
151
- function keepsInDeck(path: string): boolean {
393
+ export function keepsInDeck(path: string): boolean {
152
394
  return (
153
395
  path === "script.md" ||
154
396
  path === "theme.css" ||
@@ -157,17 +399,19 @@ function keepsInDeck(path: string): boolean {
157
399
  );
158
400
  }
159
401
 
402
+ /** The files a snapshot keeps, by their paths in it, and where the deck was in its source. */
403
+ export type Snapshot = {
404
+ files: Map<string, Blob>;
405
+ license: string | undefined;
406
+ /** The deck's directory in the source repository or pack. */
407
+ path: string;
408
+ };
409
+
160
410
  /**
161
- * Writes the snapshot of one deck at one commit under refs/, laid out as the
162
- * project it came from so it resolves like any dek project. It is written
163
- * beside the old one and swapped in, so a failure leaves the old one whole.
411
+ * What a snapshot keeps of a repository or a pack: its project's dek.toml, the deck's files that
412
+ * reading needs, and its license, laid out as the project it came from.
164
413
  */
165
- export async function installSnapshot(
166
- root: string,
167
- source: RefSource,
168
- sha: string,
169
- files: RepoFiles,
170
- ): Promise<{ dir: string; license: string | null; path: string }> {
414
+ export function pickSnapshot(files: RepoFiles, source: RefSource): Snapshot {
171
415
  const prefix = findDeckInRepo(files, source);
172
416
  const deckPrefix = `${prefix}decks/${source.deck}/`;
173
417
  const license =
@@ -190,20 +434,60 @@ export async function installSnapshot(
190
434
  hint: "a deck to read as a model should be far smaller; check its assets",
191
435
  });
192
436
  }
437
+ return { files: picked, license, path: `${prefix}decks/${source.deck}` };
438
+ }
439
+
440
+ /**
441
+ * What a pack's ref is pinned to: the sha256 of every file the snapshot keeps, in byte order of
442
+ * their paths, each as its path, a NUL, its size in decimal digits, a NUL, and its bytes. It
443
+ * follows the files, not the tarball, whose headers carry the time it was made.
444
+ */
445
+ export async function snapshotDigest(snapshot: Snapshot): Promise<string> {
446
+ const hasher = new Bun.CryptoHasher("sha256");
447
+ const encoder = new TextEncoder();
448
+ const paths = [...snapshot.files.keys()].sort((a, b) =>
449
+ Buffer.compare(encoder.encode(a), encoder.encode(b)),
450
+ );
451
+ for (const path of paths) {
452
+ const blob = snapshot.files.get(path) ?? new Blob([]);
453
+ hasher.update(`${path}\0${blob.size}\0`);
454
+ hasher.update(new Uint8Array(await blob.arrayBuffer()));
455
+ }
456
+ return hasher.digest("hex");
457
+ }
458
+
459
+ /** Writes the snapshot of one deck at one commit under refs/; see writeSnapshot. */
460
+ export async function installSnapshot(
461
+ root: string,
462
+ source: RefSource,
463
+ sha: string,
464
+ files: RepoFiles,
465
+ ): Promise<{ dir: string; license: string | null; path: string }> {
466
+ return writeSnapshot(root, source.name, sha, pickSnapshot(files, source));
467
+ }
193
468
 
194
- // refs/ is dek's; a link on the way would aim the swap below, and its delete, elsewhere.
469
+ /**
470
+ * Writes a snapshot under refs/, laid out as the project it came from so it resolves like any
471
+ * dek project, and marks it with what it is pinned to. It is written beside the old one and
472
+ * swapped in, so a failure leaves the old one whole.
473
+ */
474
+ export async function writeSnapshot(
475
+ root: string,
476
+ name: string,
477
+ pin: string,
478
+ snapshot: Snapshot,
479
+ ): Promise<{ dir: string; license: string | null; path: string }> {
195
480
  const refsDir = join(root, "refs");
196
- const dir = refDir(root, source.name);
481
+ const dir = refDir(root, name);
482
+ // A link on the way would aim the swap below, and its delete, at what it points to.
483
+ refuseLinkUnderRefs(root, dir);
197
484
  outputDir(dirname(dir), root);
198
- if (isLink(dir)) {
199
- await rm(dir);
200
- }
201
485
  const suffix = `${process.pid}-${Date.now().toString(36)}`;
202
486
  const temp = join(refsDir, `.tmp-${suffix}`);
203
487
  const old = join(refsDir, `.old-${suffix}`);
204
- const meta: RefMeta = { name: source.name, rev: sha, path: `${prefix}decks/${source.deck}` };
488
+ const meta: RefMeta = { name, rev: pin, path: snapshot.path };
205
489
  try {
206
- for (const [path, blob] of picked) {
490
+ for (const [path, blob] of snapshot.files) {
207
491
  await mkdir(dirname(join(temp, path)), { recursive: true });
208
492
  await writeFile(join(temp, path), new Uint8Array(await blob.arrayBuffer()));
209
493
  }
@@ -224,7 +508,7 @@ export async function installSnapshot(
224
508
  await rm(old, { recursive: true, force: true });
225
509
  }
226
510
  }
227
- return { dir, license: license ?? null, path: meta.path };
511
+ return { dir, license: snapshot.license ?? null, path: meta.path };
228
512
  }
229
513
 
230
514
  /** The snapshot deck's title and slide count, or nothing when it does not parse. */
@@ -1,10 +1,10 @@
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
6
  import { parseScript } from "./parse.ts";
7
- import { deckProjectRoot } from "./path.ts";
7
+ import { deckProjectRoot, isInside } from "./path.ts";
8
8
  import { readSourceIfExists, requireInside } from "./safe-fs.ts";
9
9
  import type { Deck, Section } from "./schema.ts";
10
10
  import { suggest } from "./suggest.ts";
@@ -14,6 +14,11 @@ export type ProjectDeck = {
14
14
  dir: string;
15
15
  scriptPath: string;
16
16
  deck: Deck;
17
+ /**
18
+ * Set on a ref's deck: the folder that stands for its `.cache`, in the project that reads it,
19
+ * and that project's root. A snapshot is never written once `dekc ref` wrote it.
20
+ */
21
+ cache?: { dir: string; root: string };
17
22
  };
18
23
 
19
24
  export type Project = {
@@ -28,11 +33,6 @@ export type ResolvedDeck = {
28
33
  deck: ProjectDeck;
29
34
  };
30
35
 
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
36
  /**
37
37
  * A file of the deck's, or nothing when there is none. It may be a symlink to elsewhere in the
38
38
  * project, but not out of it: what a deck reads, a build publishes.
@@ -150,14 +150,25 @@ function loadDecks(root: string): {
150
150
  if (!existsSync(decksDir)) {
151
151
  return { decks: [], failed: [] };
152
152
  }
153
- if (!statSync(decksDir).isDirectory()) {
153
+ // A link out of the project would list, serve, and watch the folders of wherever it leads. It
154
+ // may lead elsewhere in the project, but not into a hidden folder; a folder has no kind to keep.
155
+ const realRoot = realpathSync(root);
156
+ const realDecks = realpathSync(decksDir);
157
+ const rel = relative(realRoot, realDecks);
158
+ if (!isInside(realDecks, realRoot) || rel.split(sep).some((part) => part.startsWith("."))) {
159
+ throw new DekError("decks leads outside the project", {
160
+ path: decksDir,
161
+ hint: "dek follows decks/ only to a folder inside the project and outside hidden folders; move the decks into the project instead",
162
+ });
163
+ }
164
+ if (!statSync(realDecks).isDirectory()) {
154
165
  throw new DekError("decks is not a directory", { path: decksDir });
155
166
  }
156
167
 
157
168
  const decks: ProjectDeck[] = [];
158
169
  const failed: Project["failed"] = [];
159
170
 
160
- for (const entry of readdirSync(decksDir, { withFileTypes: true })
171
+ for (const entry of readdirSync(realDecks, { withFileTypes: true })
161
172
  .filter((item) => item.isDirectory())
162
173
  .sort((a, b) => a.name.localeCompare(b.name))) {
163
174
  const loaded = readDeck(root, entry.name);
@@ -62,7 +62,7 @@ export async function shotMorph(
62
62
  const step = stepKey(fromSection.beats, from.beatIndex);
63
63
 
64
64
  const html = videoDocument(resolved, options.playerScript);
65
- const entry = morphEntry(deck.dir, options.from, options.to, at, html);
65
+ const entry = morphEntry(deck, options.from, options.to, at, html);
66
66
  if (!entry.cached) {
67
67
  await requirePlaywright(
68
68
  {
@@ -2,7 +2,6 @@ import { readFileSync } from "node:fs";
2
2
  import { isAbsolute, join, relative } from "node:path";
3
3
  import pkg from "../../../package.json";
4
4
  import { DekError } from "../error.ts";
5
- import { deckProjectRoot } from "../path.ts";
6
5
  import { type PlaywrightRunner, requirePlaywright } from "../playwright.ts";
7
6
  import { deckStops, retreat } from "../position.ts";
8
7
  import { asResolvedDeck, type ResolvedDeck, requireSection } from "../resolve.ts";
@@ -103,7 +102,7 @@ export async function shotMotion(
103
102
  const key = `${JSON.stringify([pkg.version, SHEET_LAYOUT_VERSION, MOTION_FRACTIONS, plan.from, beats, title])}\0${html}`;
104
103
  // Each step keeps its own entry, so runs of different steps neither evict nor race each other.
105
104
  const capture = options.step === undefined ? [section.slug] : [section.slug, String(first)];
106
- const bundle = bundleEntry(deck.dir, "motion", capture, key);
105
+ const bundle = bundleEntry(deck, "motion", capture, key);
107
106
  const manifest = join(bundle.dir, "motion.json");
108
107
  const cached = readMotionManifest(manifest, bundle.dir);
109
108
  if (cached) {
@@ -112,7 +111,7 @@ export async function shotMotion(
112
111
  }
113
112
  // Chromium writes the frames and sheets by name, so no link may stand where one goes.
114
113
  dropLinks(bundle.dir);
115
- dropLinks(outputDir(join(bundle.dir, "frames"), deckProjectRoot(deck.dir)));
114
+ dropLinks(outputDir(join(bundle.dir, "frames"), bundle.root));
116
115
 
117
116
  const response = await requirePlaywright(
118
117
  {
@@ -84,7 +84,7 @@ export async function shotSheet(
84
84
  }));
85
85
  // The tiles' paths hash what they show, so the key changes whenever a tile does.
86
86
  const bundle = bundleEntry(
87
- deck.dir,
87
+ deck,
88
88
  "sheets",
89
89
  [],
90
90
  JSON.stringify([SHEET_LAYOUT_VERSION, deck.deck.title, cells]),
@@ -136,7 +136,7 @@ export function still(
136
136
  step: string = stepKey(section.beats, beatIndex),
137
137
  ): Still {
138
138
  const html = renderSlideHtml(sources, section.slug, beatIndex);
139
- const entry = stillEntry(deck.dir, section, beatIndex, html);
139
+ const entry = stillEntry(deck, section, beatIndex, html);
140
140
  return { html, slug: section.slug, step, screenshotPath: entry.path, entry };
141
141
  }
142
142