@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
package/src/core/pptx.ts CHANGED
@@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { type DistOptions, distFile } from "./path.ts";
5
5
  import { type PlaywrightRunner, requirePlaywright } from "./playwright.ts";
6
- import { pptxPackage } from "./pptx-package.ts";
6
+ import { mapPictures, type PptxLayer, pptxPackage } from "./pptx-package.ts";
7
+ import { deckTheme } from "./pptx-theme.ts";
7
8
  import { presenterSlides } from "./presenter.ts";
8
9
  import { asResolvedDeck, type ResolvedDeck } from "./resolve.ts";
9
10
  import { writeInside } from "./safe-fs.ts";
@@ -11,7 +12,16 @@ import { logicalSize } from "./size.ts";
11
12
  import { lastStop } from "./step.ts";
12
13
  import { loadSlideSources, renderSlideHtml } from "./still-page.ts";
13
14
 
14
- export type PptxResult = { outPath: string };
15
+ export type PptxResult = {
16
+ outPath: string;
17
+ /**
18
+ * What was laid over the pictures: how many shapes, lines included, and pictures, and the
19
+ * slugs of the slides that kept everything in their picture.
20
+ */
21
+ lifted: PptxLifted;
22
+ };
23
+
24
+ export type PptxLifted = { shapes: number; pictures: number; untouched: string[] };
15
25
 
16
26
  export type PptxOptions = DistOptions & {
17
27
  /** For anyone who has the file: the notes leave out the stage directions and comments. */
@@ -20,9 +30,10 @@ export type PptxOptions = DistOptions & {
20
30
  };
21
31
 
22
32
  /**
23
- * The deck as a PowerPoint file: each slide at its last beat, as the PDF prints it, drawn as a
24
- * picture with its text laid over it in text boxes a recipient can edit, and its script in the
25
- * notes. A slide script that cannot run stops it, as it stops the PDF.
33
+ * The deck as a PowerPoint file: each slide at its last beat, as the PDF prints it, its plain
34
+ * boxes, pictures, and text as PowerPoint's own over a picture of the rest, in the deck's theme
35
+ * (ADRs 20 to 22), and its script in the notes. A slide script that cannot run stops it, as it
36
+ * stops the PDF.
26
37
  */
27
38
  export async function pptxDeck(dir: string, options?: PptxOptions): Promise<PptxResult>;
28
39
  export async function pptxDeck(source: ResolvedDeck, options?: PptxOptions): Promise<PptxResult>;
@@ -56,20 +67,56 @@ export async function pptxDeck(
56
67
  options.runner,
57
68
  );
58
69
  const scripts = presenterSlides(deck, options.public ? "audience" : "speaker");
70
+ // The deck's tokens are PowerPoint's theme, as most slides have them.
71
+ const theme = deckTheme(answer.slides.flatMap((slide) => (slide.theme ? [slide.theme] : [])));
59
72
  const bytes = pptxPackage({
60
73
  title: deck.deck.title,
61
74
  lang: deck.deck.lang,
62
75
  size,
76
+ theme,
63
77
  slides: answer.slides.map((slide, index) => ({
64
- picture: readFileSync(join(shots, `${index + 1}.png`)),
78
+ ...(slide.picture === false
79
+ ? {}
80
+ : { picture: readFileSync(join(shots, `${index + 1}.png`)) }),
81
+ ...(slide.background ? { background: slide.background } : {}),
65
82
  description: slide.description || (sections[index]?.title ?? slide.slug),
66
- texts: slide.boxes,
83
+ layers: mapPictures(slide.layers, ({ png, svg, ...picture }) => ({
84
+ ...picture,
85
+ png: readFileSync(png),
86
+ ...(svg === undefined ? {} : { svg }),
87
+ })),
67
88
  notes: scripts[index]?.script ?? "",
68
89
  })),
69
90
  });
70
91
  writeInside(outPath, bytes, project.root);
92
+ const lifted: PptxLifted = { shapes: 0, pictures: 0, untouched: [] };
93
+ for (const slide of answer.slides) {
94
+ const counts = countLifted(slide.layers);
95
+ lifted.shapes += counts.shapes;
96
+ lifted.pictures += counts.pictures;
97
+ if (counts.shapes + counts.pictures === 0) {
98
+ lifted.untouched.push(slide.slug);
99
+ }
100
+ }
101
+ return { outPath, lifted };
71
102
  } finally {
72
103
  rmSync(shots, { recursive: true, force: true });
73
104
  }
74
- return { outPath };
105
+ }
106
+
107
+ function countLifted<I>(layers: PptxLayer<I>[]): { shapes: number; pictures: number } {
108
+ let shapes = 0;
109
+ let pictures = 0;
110
+ for (const layer of layers) {
111
+ if (layer.kind === "shape") {
112
+ const inner = countLifted(layer.layers);
113
+ shapes += 1 + inner.shapes;
114
+ pictures += inner.pictures;
115
+ } else if (layer.kind === "rule") {
116
+ shapes += 1;
117
+ } else if (layer.kind === "picture") {
118
+ pictures += 1;
119
+ }
120
+ }
121
+ return { shapes, pictures };
75
122
  }
package/src/core/prose.ts CHANGED
@@ -14,3 +14,26 @@ function joined(items: string[], conjunction: string): string {
14
14
  }
15
15
  return `${items.slice(0, -1).join(", ")}, ${conjunction} ${items.at(-1)}`;
16
16
  }
17
+
18
+ /**
19
+ * Names from someone else's files as a hint lists them: those `shows` passes, then how many others
20
+ * there are, in the words of `others`. An other could hold anything, and an agent takes a hint as
21
+ * dek's own words.
22
+ */
23
+ export function listShown(
24
+ names: string[],
25
+ shows: (name: string) => boolean,
26
+ others: (count: number) => string,
27
+ ): string {
28
+ const shown = names.filter(shows);
29
+ const left = names.length - shown.length;
30
+ if (left === 0) {
31
+ return shown.join(", ");
32
+ }
33
+ return shown.length === 0 ? others(left) : `${shown.join(", ")}, and ${others(left)}`;
34
+ }
35
+
36
+ /** How many names a list leaves out because they are no plain word: see `listShown`. */
37
+ export function unprinted(count: number): string {
38
+ return `${count} whose ${count === 1 ? "name" : "names"} dek does not print`;
39
+ }
@@ -14,17 +14,34 @@ type DomNode = {
14
14
  * `<host>::<pseudo>`. The page exposes no box for a pseudo-element, and Chromium, which lays it
15
15
  * out, does: through the DevTools protocol. One that is not laid out has no box and is left out.
16
16
  */
17
- export async function pseudoTextBoxes(page: Page): Promise<Map<string, Box>> {
17
+ export function pseudoTextBoxes(page: Page): Promise<Map<string, Box>> {
18
+ return pseudoElementBoxes(page, PSEUDO_TEXT_HOST, "content", ["before", "after"]);
19
+ }
20
+
21
+ /**
22
+ * The `content` or `border` box, in the viewport, of each pseudo-element of the `types` given
23
+ * of every element marked with `attr`, keyed `<the attribute's value>::<pseudo>`.
24
+ */
25
+ export async function pseudoElementBoxes(
26
+ page: Page,
27
+ attr: string,
28
+ model: "content" | "border",
29
+ types: ReadonlyArray<"before" | "after" | "marker">,
30
+ ): Promise<Map<string, Box>> {
18
31
  const cdp = await page.context().newCDPSession(page);
19
32
  try {
20
33
  const { root } = (await cdp.send("DOM.getDocument", { depth: -1 })) as { root: DomNode };
21
34
  const boxes = new Map<string, Box>();
22
35
  for (const node of walk(root)) {
23
- const host = attribute(node, PSEUDO_TEXT_HOST);
36
+ const host = attribute(node, attr);
24
37
  for (const pseudo of host === undefined ? [] : (node.pseudoElements ?? [])) {
25
- const box = await contentBox(cdp, pseudo.nodeId);
26
- if (box && (pseudo.pseudoType === "before" || pseudo.pseudoType === "after")) {
27
- boxes.set(`${host}::${pseudo.pseudoType}`, box);
38
+ const type = types.find((each) => each === pseudo.pseudoType);
39
+ if (type === undefined) {
40
+ continue;
41
+ }
42
+ const box = await boxOf(cdp, pseudo.nodeId, model);
43
+ if (box) {
44
+ boxes.set(`${host}::${type}`, box);
28
45
  }
29
46
  }
30
47
  }
@@ -48,13 +65,18 @@ function attribute(node: DomNode, name: string): string | undefined {
48
65
  return at >= 0 && at % 2 === 0 ? attributes[at + 1] : undefined;
49
66
  }
50
67
 
51
- async function contentBox(cdp: CDPSession, nodeId: number): Promise<Box | undefined> {
68
+ async function boxOf(
69
+ cdp: CDPSession,
70
+ nodeId: number,
71
+ model: "content" | "border",
72
+ ): Promise<Box | undefined> {
52
73
  try {
53
- const { model } = (await cdp.send("DOM.getBoxModel", { nodeId })) as {
54
- model: { content: number[] };
74
+ const { model: boxes } = (await cdp.send("DOM.getBoxModel", { nodeId })) as {
75
+ model: { content: number[]; border: number[] };
55
76
  };
56
- const xs = model.content.filter((_, index) => index % 2 === 0);
57
- const ys = model.content.filter((_, index) => index % 2 === 1);
77
+ const quad = boxes[model];
78
+ const xs = quad.filter((_, index) => index % 2 === 0);
79
+ const ys = quad.filter((_, index) => index % 2 === 1);
58
80
  return {
59
81
  left: Math.min(...xs),
60
82
  top: Math.min(...ys),
@@ -35,12 +35,24 @@ const PACK_OWNER = "file";
35
35
 
36
36
  const OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
37
37
  const REPO_RE = /^[A-Za-z0-9._-]+$/;
38
- const DECK_RE = /^[^/\\\s@]+$/;
38
+ /**
39
+ * A ref's deck name: letters, marks, and digits of any script, dots, dashes, and underscores, not
40
+ * first a dash. It is chosen by whoever made the pack or the repository, and dek prints it in the
41
+ * commands it suggests and in AGENTS.md, inside a code span: no shell, code span, or command line
42
+ * reads anything in it.
43
+ */
44
+ const DECK_RE = /^[\p{L}\p{M}\p{N}._][\p{L}\p{M}\p{N}._-]{0,63}$/u;
45
+
46
+ /** Bytes a ref's deck name may take: its pack, `<deck>.tgz`, stays within a file name's 255. */
47
+ const DECK_MAX_BYTES = 200;
39
48
  const SHA_RE = /^[0-9a-f]{40}$/;
40
49
  const DIGEST_RE = /^[0-9a-f]{64}$/;
41
50
 
42
- const REF_HINT =
43
- "name a ref as owner/repo/deck, e.g. hajimism/dek/why-dek or hajimism/dek/why-dek@v1, or a pack's as file/<deck>";
51
+ /** What a ref's deck name may hold, as a hint says it. */
52
+ export const REF_DECK_NAME_RULE =
53
+ "a ref names its deck with up to 64 letters, digits, dots, dashes, and underscores, not first a dash, each accented letter one character (NFC)";
54
+
55
+ const REF_HINT = `name a ref as owner/repo/deck, e.g. hajimism/dek/why-dek or hajimism/dek/why-dek@v1, or a pack's as file/<deck>; ${REF_DECK_NAME_RULE}`;
44
56
 
45
57
  /** Where a pack's ref keeps its pack, relative to the project root: committed, never ignored. */
46
58
  export function packFile(deck: string): string {
@@ -78,9 +90,19 @@ export function isPinFor(source: RefSource, pin: string): boolean {
78
90
  return source.kind === "file" ? DIGEST_RE.test(pin) : SHA_RE.test(pin);
79
91
  }
80
92
 
81
- /** True for a deck name a ref can carry: no slash, space, or @, and not . or .. */
93
+ /**
94
+ * True for a deck name a ref can carry: see DECK_RE; not . or ..; and composed (NFC), since a
95
+ * file system that ignores the form would give two spellings of one name one folder.
96
+ */
82
97
  export function isRefDeckName(deck: string | undefined): deck is string {
83
- return deck !== undefined && DECK_RE.test(deck) && deck !== "." && deck !== "..";
98
+ return (
99
+ deck !== undefined &&
100
+ DECK_RE.test(deck) &&
101
+ deck === deck.normalize("NFC") &&
102
+ Buffer.byteLength(deck) <= DECK_MAX_BYTES &&
103
+ deck !== "." &&
104
+ deck !== ".."
105
+ );
84
106
  }
85
107
 
86
108
  export function parseRefSource(input: string): RefSource {
@@ -133,22 +155,39 @@ export function parseRefSource(input: string): RefSource {
133
155
  */
134
156
  function parseGithubUrl(input: string): GithubRefSource {
135
157
  const url = new URL(input);
136
- const [owner = "", repo = "", kind, rev, ...rest] = url.pathname.split("/").filter(Boolean);
137
- const decks = rest.indexOf("decks");
138
- const deck = decks === -1 ? undefined : rest[decks + 1];
158
+ const [owner = "", repo = "", kind, rev, ...encoded] = url.pathname.split("/").filter(Boolean);
159
+ // The folders after the rev as the repository names them; GitHub's link encodes any but ASCII.
160
+ const rest = decodeSegments(encoded);
161
+ const decks = rest?.indexOf("decks") ?? -1;
162
+ const deck = decks === -1 ? undefined : rest?.[decks + 1];
139
163
  if (
140
164
  url.hostname !== "github.com" ||
141
165
  (kind !== "tree" && kind !== "blob") ||
142
166
  !rev ||
167
+ rest === undefined ||
143
168
  deck === undefined
144
169
  ) {
145
170
  throw new DekError(`"${input}" is not a link to a deck on GitHub`, {
146
171
  hint: "link to the deck's folder, e.g. https://github.com/hajimism/dek/tree/main/sample/decks/why-dek, or name it as owner/repo/deck",
147
172
  });
148
173
  }
174
+ if (!isRefDeckName(deck)) {
175
+ throw new DekError(`"${input}" is not a link to a deck a ref can name`, {
176
+ hint: REF_DECK_NAME_RULE,
177
+ });
178
+ }
149
179
  const source = parseRefSource(`${owner}/${repo.replace(/\.git$/, "")}/${deck}@${rev}`);
150
180
  if (source.kind !== "github") {
151
181
  throw new DekError(`"${input}" is not a link to a deck on GitHub`, { hint: REF_HINT });
152
182
  }
153
183
  return { ...source, path: rest.slice(0, decks + 2).join("/") };
154
184
  }
185
+
186
+ /** Each segment of a link's path decoded, or nothing when one is not valid percent-encoding. */
187
+ function decodeSegments(segments: string[]): string[] | undefined {
188
+ try {
189
+ return segments.map((segment) => decodeURIComponent(segment));
190
+ } catch {
191
+ return undefined;
192
+ }
193
+ }
package/src/core/ref.ts CHANGED
@@ -1,12 +1,21 @@
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,
@@ -24,6 +33,49 @@ function isLink(path: string): boolean {
24
33
  }
25
34
  }
26
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
+
27
79
  /** Written into each snapshot; its presence is what makes a directory a ref. */
28
80
  export const REF_MARKER = ".ref.json";
29
81
 
@@ -44,8 +96,10 @@ export function refDir(root: string, name: string): string {
44
96
 
45
97
  /** The marker of a snapshot, or nothing when it is missing or not one dek wrote. */
46
98
  export function readRefMeta(dir: string): RefMeta | undefined {
47
- const path = join(dir, REF_MARKER);
48
- 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) {
49
103
  return undefined;
50
104
  }
51
105
  try {
@@ -56,6 +110,87 @@ export function readRefMeta(dir: string): RefMeta | undefined {
56
110
  }
57
111
  }
58
112
 
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
+
59
194
  export const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
60
195
 
61
196
  /** The snapshot's license file, relative to the snapshot, when the source had one. */
@@ -81,7 +216,11 @@ export function refState(project: { root: string; configPath: string }, name: st
81
216
  name,
82
217
  ...(pinned !== undefined ? { pinned } : {}),
83
218
  dir,
84
- 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,
85
224
  };
86
225
  }
87
226
 
@@ -185,7 +324,16 @@ function repoDecks(files: RepoFiles): Array<{ name: string; prefix: string }> {
185
324
  });
186
325
  }
187
326
 
188
- /** The name of the one deck a pack holds; a pack of none, or of more, is refused. */
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. */
189
337
  export function deckOfPack(files: RepoFiles): string {
190
338
  const names = [...new Set(repoDecks(files).map((deck) => deck.name))].sort();
191
339
  const [name, ...others] = names;
@@ -195,10 +343,15 @@ export function deckOfPack(files: RepoFiles): string {
195
343
  });
196
344
  }
197
345
  if (others.length > 0) {
198
- throw new DekError(`the pack holds more than one deck: ${names.join(", ")}`, {
346
+ throw new DekError(`the pack holds more than one deck: ${listDecks(names)}`, {
199
347
  hint: `a pack is one deck; ${PACK_HINT}`,
200
348
  });
201
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
+ }
202
355
  return name;
203
356
  }
204
357
 
@@ -218,14 +371,19 @@ export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
218
371
  throw new DekError(`${repo} has no deck "${source.deck}"`, {
219
372
  hint:
220
373
  names.length > 0
221
- ? `decks in ${repo}: ${names.join(", ")}`
374
+ ? `decks in ${repo}: ${listDecks(names)}`
222
375
  : `${repo} holds no dek project (decks/<name>/script.md beside a dek.toml)`,
223
376
  });
224
377
  }
225
378
  if (others.length > 0) {
226
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
+ );
227
385
  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(", ")}`,
386
+ hint: `pass the GitHub link to the one you mean; they are at ${listed}`,
229
387
  });
230
388
  }
231
389
  return match.prefix;
@@ -319,13 +477,11 @@ export async function writeSnapshot(
319
477
  pin: string,
320
478
  snapshot: Snapshot,
321
479
  ): 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
480
  const refsDir = join(root, "refs");
324
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);
325
484
  outputDir(dirname(dir), root);
326
- if (isLink(dir)) {
327
- await rm(dir);
328
- }
329
485
  const suffix = `${process.pid}-${Date.now().toString(36)}`;
330
486
  const temp = join(refsDir, `.tmp-${suffix}`);
331
487
  const old = join(refsDir, `.old-${suffix}`);
@@ -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