@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/cli/pptx.ts CHANGED
@@ -1,20 +1,25 @@
1
- import { pptxDeck } from "../core/pptx.ts";
1
+ import { type PptxLifted, pptxDeck } from "../core/pptx.ts";
2
2
  import type { DecksTarget } from "./scope.ts";
3
3
 
4
- /** One file per deck, a single deck included, as `dekc pdf` answers. */
5
- export type PptxCliResult = { outs: string[] };
4
+ /**
5
+ * One file per deck, a single deck included, as `dekc pdf` answers, and what each file laid
6
+ * over its pictures, for the text output alone (`--json` is `outs`).
7
+ */
8
+ export type PptxCliResult = { outs: string[]; lifted?: PptxLifted[] };
6
9
 
7
10
  export async function pptxCommand(
8
11
  { project, decks }: DecksTarget,
9
12
  options: { rootDist?: boolean; public?: boolean } = {},
10
13
  ): Promise<PptxCliResult> {
11
14
  const outs: string[] = [];
15
+ const lifted: PptxLifted[] = [];
12
16
  for (const entry of decks) {
13
17
  const done = await pptxDeck(
14
18
  { project, deck: entry },
15
19
  { rootDist: options.rootDist, public: options.public },
16
20
  );
17
21
  outs.push(done.outPath);
22
+ lifted.push(done.lifted);
18
23
  }
19
- return { outs };
24
+ return { outs, lifted };
20
25
  }
package/src/cli/ref.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, statSync } from "node:fs";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
3
  import { loadConfig } from "../core/config.ts";
4
4
  import { DekError } from "../core/error.ts";
5
+ import { isPlainWord, shellQuote } from "../core/escape.ts";
5
6
  import { downloadTarball, resolveRev } from "../core/github.ts";
6
7
  import {
7
8
  deckOfPack,
@@ -12,15 +13,17 @@ import {
12
13
  pickSnapshot,
13
14
  readPack,
14
15
  readTarball,
16
+ refDir,
15
17
  refLicense,
16
18
  refState,
17
19
  refTitle,
20
+ refuseLinkUnderPacks,
21
+ refuseLinkUnderRefs,
18
22
  type Snapshot,
19
23
  snapshotDigest,
20
24
  writeSnapshot,
21
25
  } from "../core/ref.ts";
22
- import type { FileRefSource, GithubRefSource } from "../core/ref-name.ts";
23
- import { readTextIfExists } from "../core/resolve.ts";
26
+ import type { FileRefSource, GithubRefSource, RefSource } from "../core/ref-name.ts";
24
27
  import { readSourceIfExists, removeInside, requireInside, writeInside } from "../core/safe-fs.ts";
25
28
  import { writeAgentsMd } from "../core/sync.ts";
26
29
  import { setTableKey } from "../core/toml-keys.ts";
@@ -58,10 +61,12 @@ export type RefCliResult = RefAddResult | RefListResult | RefRmResult;
58
61
  export async function restoreRef(cwd: string, arg: string): Promise<void> {
59
62
  const source = parseRefSource(arg);
60
63
  const project = requireProject(cwd);
61
- const { pinned, fetched } = refState(project, source.name);
64
+ const { pinned, dir, fetched } = refState(project, source.name);
62
65
  if (pinned === undefined || fetched) {
63
66
  return;
64
67
  }
68
+ // Said before a download that the write would refuse anyway.
69
+ refuseLinkUnderRefs(project.root, dir);
65
70
  if (source.kind === "file") {
66
71
  await unpackSnapshot(project.root, source, pinned);
67
72
  return;
@@ -88,7 +93,7 @@ async function unpackSnapshot(root: string, source: FileRefSource, pin: string):
88
93
  if ((await snapshotDigest(snapshot)) !== pin) {
89
94
  throw new DekError(`${rel} is not the pack ${source.name} is pinned to`, {
90
95
  path,
91
- hint: `run \`dekc ref ${rel}\` to pin the pack that is there, or put back the one dek.toml pins`,
96
+ hint: `run \`dekc ref ${shellQuote(rel)}\` to pin the pack that is there, or put back the one dek.toml pins`,
92
97
  });
93
98
  }
94
99
  await writeSnapshot(root, source.name, pin, snapshot);
@@ -128,7 +133,10 @@ export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
128
133
  });
129
134
  }
130
135
  const project = requireProject(cwd);
136
+ refuseSharedFolders(project, source);
131
137
  const { pinned: from, dir, fetched } = refState(project, source.name);
138
+ // Said before a download that the write would refuse anyway.
139
+ refuseLinkUnderRefs(project.root, dir);
132
140
  const rev =
133
141
  source.rev !== undefined && isPinnedRev(source.rev)
134
142
  ? source.rev
@@ -141,7 +149,7 @@ export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
141
149
  changed = true;
142
150
  }
143
151
  if (from !== rev) {
144
- const toml = readTextIfExists(project.configPath) ?? "";
152
+ const toml = readSourceIfExists(project.configPath, project.root) ?? "";
145
153
  writeInside(project.configPath, setTableKey(toml, "refs", source.name, rev), project.root);
146
154
  changed = true;
147
155
  }
@@ -166,6 +174,72 @@ export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
166
174
  };
167
175
  }
168
176
 
177
+ /**
178
+ * Refuses a pin whose folders another pin, or a folder already on disk, takes. refs/ and packs/
179
+ * are named by the ref, and macOS and Windows read a name without its case, so `file/Talk` and
180
+ * `file/talk` would share one snapshot and one pack, and adding the second would replace the
181
+ * first's.
182
+ */
183
+ function refuseSharedFolders(
184
+ project: { root: string; configPath: string },
185
+ source: RefSource,
186
+ ): void {
187
+ const { name } = source;
188
+ const other = Object.keys(loadConfig(project.configPath).refs ?? {}).find(
189
+ (pinned) => pinned !== name && folded(pinned) === folded(name),
190
+ );
191
+ if (other !== undefined) {
192
+ throw new DekError(`${name} would share its folders with ${other}, which dek.toml pins`, {
193
+ path: project.configPath,
194
+ hint: `a ref's folders are named without their case on macOS and Windows; to pin ${name} in its place, run \`dekc ref rm ${shellQuote(other)}\` first`,
195
+ });
196
+ }
197
+ const paths = [relative(project.root, refDir(project.root, name))];
198
+ if (source.kind === "file") {
199
+ paths.push(packFile(source.deck));
200
+ }
201
+ const left = paths
202
+ .map((path) => spelledOtherwise(project.root, path))
203
+ .find((path) => path !== undefined);
204
+ if (left !== undefined) {
205
+ const shown = isPlainWord(left) ? left : "another folder";
206
+ throw new DekError(
207
+ `${name} would share a folder with ${shown}, which is already in the project`,
208
+ {
209
+ path: join(project.root, left),
210
+ hint: "no ref in dek.toml pins it, so a ref removed by hand left it; delete it and add the ref again",
211
+ },
212
+ );
213
+ }
214
+ }
215
+
216
+ /** A name as a file system blind to case and form reads it: APFS folds ς as σ, and fi as fi. */
217
+ function folded(name: string): string {
218
+ return name.normalize("NFKC").toUpperCase().toLowerCase();
219
+ }
220
+
221
+ /**
222
+ * The path, under `root`, of a folder or file a file system blind to case and form would read as
223
+ * `path` though it is spelled otherwise, found segment by segment.
224
+ */
225
+ function spelledOtherwise(root: string, path: string): string | undefined {
226
+ let dir = root;
227
+ const walked: string[] = [];
228
+ for (const segment of path.split(sep)) {
229
+ const entries = existsSync(dir) && statSync(dir).isDirectory() ? readdirSync(dir) : [];
230
+ const other = entries.find((entry) => entry !== segment && folded(entry) === folded(segment));
231
+ if (other !== undefined) {
232
+ return join(...walked, other);
233
+ }
234
+ if (!entries.includes(segment)) {
235
+ return undefined;
236
+ }
237
+ walked.push(segment);
238
+ dir = join(dir, segment);
239
+ }
240
+ return undefined;
241
+ }
242
+
169
243
  /** An argument that ends in .tgz or .tar.gz is a pack's path, never a ref name. */
170
244
  function isPackPath(arg: string): boolean {
171
245
  return /\.(tgz|tar\.gz)$/i.test(arg);
@@ -187,9 +261,12 @@ async function addPack(cwd: string, arg: string): Promise<RefAddResult> {
187
261
  }
188
262
  const read = await readPackFile(given, undefined);
189
263
  const { source, bytes } = read;
264
+ refuseSharedFolders(project, source);
190
265
  const rev = await snapshotDigest(read);
191
266
  const { pinned: from, dir, fetched } = refState(project, source.name);
192
267
  const stored = join(project.root, packFile(source.deck));
268
+ // Before the first write: the copy below is written by name.
269
+ refuseLinkUnderPacks(project.root, source.deck);
193
270
 
194
271
  let changed = false;
195
272
  if (!(fetched && from === rev)) {
@@ -201,7 +278,7 @@ async function addPack(cwd: string, arg: string): Promise<RefAddResult> {
201
278
  changed = true;
202
279
  }
203
280
  if (from !== rev) {
204
- const toml = readTextIfExists(project.configPath) ?? "";
281
+ const toml = readSourceIfExists(project.configPath, project.root) ?? "";
205
282
  writeInside(project.configPath, setTableKey(toml, "refs", source.name, rev), project.root);
206
283
  changed = true;
207
284
  }
@@ -264,8 +341,13 @@ export function removeRef(cwd: string, arg: string): RefRmResult {
264
341
  hint: "run `dekc ref` to list refs",
265
342
  });
266
343
  }
344
+ // Before anything changes: the deletes below must not reach what a link there points to.
345
+ refuseLinkUnderRefs(project.root, dir);
346
+ if (source.kind === "file") {
347
+ refuseLinkUnderPacks(project.root, source.deck);
348
+ }
267
349
  if (pinned !== undefined) {
268
- const toml = readFileSync(project.configPath, "utf8");
350
+ const toml = readSourceIfExists(project.configPath, project.root) ?? "";
269
351
  writeInside(
270
352
  project.configPath,
271
353
  setTableKey(toml, "refs", source.name, undefined),
package/src/cli/result.ts CHANGED
@@ -13,15 +13,18 @@ export function skippedChecks(skipped: SkippedCheck[]): { skipped?: SkippedCheck
13
13
  return skipped.length > 0 ? { skipped } : {};
14
14
  }
15
15
 
16
+ /** What `--visual` measures in a browser, as a hint or a reason names it. */
17
+ export const RENDERED_RULES = "overflow, contrast, line breaks, and alignment";
18
+
16
19
  /**
17
- * The overflow and contrast rules, when a run leaves them out: why, and `rerun`, the command that
20
+ * The rules a browser measures, when a run leaves them out: why, and `rerun`, the command that
18
21
  * measures them. Without Playwright, installing it comes first, whatever the reason was.
19
22
  */
20
23
  export function visualSkipped(
21
24
  rerun: string,
22
25
  options: { reason: string; runner?: PlaywrightRunner },
23
26
  ): SkippedCheck {
24
- const measure = `run \`${rerun}\` to measure overflow and contrast`;
27
+ const measure = `run \`${rerun}\` to measure ${RENDERED_RULES}`;
25
28
  return playwrightReady(options.runner)
26
29
  ? { check: "visual", reason: options.reason, hint: measure }
27
30
  : {
package/src/cli/scope.ts CHANGED
@@ -1,14 +1,14 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { join, relative } from "node:path";
3
2
  import { DekError } from "../core/error.ts";
3
+ import { shellName, shellQuote } from "../core/escape.ts";
4
4
  import {
5
5
  isRefName,
6
6
  packFile,
7
7
  parseRefSource,
8
- REF_MARKER,
9
- readRefMeta,
10
8
  refLicense,
9
+ refReadOnlyError,
11
10
  refState,
11
+ requireProject,
12
12
  } from "../core/ref.ts";
13
13
  import {
14
14
  locateDeck,
@@ -19,7 +19,7 @@ import {
19
19
  } from "../core/resolve.ts";
20
20
  import { suggest } from "../core/suggest.ts";
21
21
 
22
- export { requireSection };
22
+ export { requireProject, requireSection };
23
23
 
24
24
  export type Scope = {
25
25
  project: Project;
@@ -52,18 +52,6 @@ export type DecksTarget = {
52
52
  ref?: RefInfo;
53
53
  };
54
54
 
55
- export function requireProject(cwd: string): Project {
56
- const project = resolveProject(cwd);
57
- if (existsSync(join(project.root, REF_MARKER))) {
58
- const name = readRefMeta(project.root)?.name ?? "<ref>";
59
- throw new DekError("this directory is inside a ref; refs are read-only", {
60
- path: project.root,
61
- hint: `run dek from your own project instead: \`dekc show ${name} <slug>\``,
62
- });
63
- }
64
- return project;
65
- }
66
-
67
55
  export function inferDeckName(project: Project, cwd: string): string | undefined {
68
56
  const located = locateDeck(project.root, cwd);
69
57
  if (!located) {
@@ -139,9 +127,7 @@ export function resolveScope(cwd: string, options: { deck?: string } = {}): Scop
139
127
  return { project };
140
128
  }
141
129
  if (isRefName(name)) {
142
- throw new DekError(`"${name}" is a ref; refs are read-only`, {
143
- hint: `start a deck on its theme with \`dekc new <name> --theme-from ${name}\`, or read it with \`dekc show ${name} <slug>\` and write what you need in your own deck's theme`,
144
- });
130
+ throw refReadOnlyError(name);
145
131
  }
146
132
  const deck = project.decks.find((entry) => entry.name === name);
147
133
  if (!deck) {
@@ -149,10 +135,11 @@ export function resolveScope(cwd: string, options: { deck?: string } = {}): Scop
149
135
  if (failed) {
150
136
  throw failed.error;
151
137
  }
152
- const guess = suggest(name, [
138
+ const near = suggest(name, [
153
139
  ...project.decks.map((entry) => entry.name),
154
140
  ...project.failed.map((entry) => entry.name),
155
141
  ]);
142
+ const guess = near === undefined ? undefined : shellName(near);
156
143
  throw new DekError(`deck "${name}" not found`, {
157
144
  path: join(project.root, "decks", name),
158
145
  hint: guess ? `did you mean \`${guess}\`? run \`dekc ls\` for every deck` : "run `dekc ls`",
@@ -277,18 +264,16 @@ function resolveRef(cwd: string, arg: string): ReadableDeck & { ref: RefInfo } {
277
264
  hint:
278
265
  source.kind === "file"
279
266
  ? "add it from the pack you were sent: `dekc ref <file>.tgz`"
280
- : `run \`dekc ref ${arg}\``,
267
+ : `run \`dekc ref ${shellQuote(arg)}\``,
281
268
  });
282
269
  }
283
270
  // What adds the ref again: a pack's ref from its file, any other from its name.
284
- const again =
285
- source.kind === "file"
286
- ? `dekc ref ${packFile(source.deck)}`
287
- : `dekc ref ${source.name}@${pinned}`;
271
+ const added = source.kind === "file" ? packFile(source.deck) : `${source.name}@${pinned}`;
272
+ const again = `dekc ref ${shellQuote(added)}`;
288
273
  if (source.kind === "github" && source.rev !== undefined && source.rev !== pinned) {
289
274
  throw new DekError(`"${arg}" is not the pinned version, ${pinned.slice(0, 7)}`, {
290
275
  path: project.configPath,
291
- hint: `run \`dekc ref ${arg}\` to pin that version, or drop @${source.rev}`,
276
+ hint: `run \`dekc ref ${shellQuote(arg)}\` to pin that version, or drop @${source.rev}`,
292
277
  });
293
278
  }
294
279
  if (!fetched) {
@@ -311,7 +296,11 @@ function resolveRef(cwd: string, arg: string): ReadableDeck & { ref: RefInfo } {
311
296
  }
312
297
  return {
313
298
  project: snapshot,
314
- deck: found,
299
+ // Read where it is, but cached in this project: nothing is written in a snapshot.
300
+ deck: {
301
+ ...found,
302
+ cache: { dir: join(project.root, ".cache", relative(project.root, dir)), root: project.root },
303
+ },
315
304
  ref: { name: source.name, rev: pinned, dir, license: refLicense(dir) },
316
305
  };
317
306
  }
package/src/cli/shot.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { DekError } from "../core/error.ts";
2
+ import { shellQuote } from "../core/escape.ts";
2
3
  import { parseMorphAt, shotMorph } from "../core/shot/morph.ts";
3
4
  import { type ShotMotion, shotMotion } from "../core/shot/motion.ts";
4
5
  import { type ShotFile, shotDeck, shotSheet } from "../core/shot/still.ts";
@@ -91,7 +92,7 @@ export function parseShotMode(options: ShotCommandOptions): ShotMode {
91
92
  if (slug) {
92
93
  refuse(
93
94
  "--sheet with a slug",
94
- `--sheet tiles every slide; for one slide's beats, run \`dekc shot ${slug} --motion\``,
95
+ `--sheet tiles every slide; for one slide's beats, run \`dekc shot ${shellQuote(slug)} --motion\``,
95
96
  );
96
97
  }
97
98
  return { kind: "sheet" };
package/src/cli/text.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // How each command's result reads as text; commands.ts says which command prints which.
2
2
  // The pieces the dev server's log shares, such as diagnostics and tables, are in format.ts.
3
3
  import type { Diagnostic, SkippedCheck } from "../core/diagnostic.ts";
4
+ import { shellQuote } from "../core/escape.ts";
5
+ import { unprinted } from "../core/prose.ts";
4
6
  import { packFile, parseRefSource } from "../core/ref-name.ts";
5
7
  import { formatClock } from "../core/timing.ts";
6
8
  import type { AnnotationsCliResult } from "./annotations.ts";
@@ -16,6 +18,7 @@ import type { MvResult } from "./mv.ts";
16
18
  import type { NewResult } from "./new.ts";
17
19
  import type { PackCliResult } from "./pack.ts";
18
20
  import type { PdfCliResult } from "./pdf.ts";
21
+ import type { PptxCliResult } from "./pptx.ts";
19
22
  import type { RefCliResult } from "./ref.ts";
20
23
  import type { ShotCliResult } from "./shot.ts";
21
24
  import type { ShowResult } from "./show.ts";
@@ -230,6 +233,27 @@ export function formatPdf(data: PdfCliResult): string {
230
233
  return data.outs.map((path) => `wrote ${path}`).join("\n");
231
234
  }
232
235
 
236
+ /**
237
+ * Each file written, and what it laid over its pictures, naming the slides that kept everything
238
+ * in the picture, which are the ones to rewrite toward shapes.
239
+ */
240
+ export function formatPptx(data: PptxCliResult): string {
241
+ return data.outs
242
+ .flatMap((path, index) => {
243
+ const lifted = data.lifted?.[index];
244
+ if (!lifted) {
245
+ return [`wrote ${path}`];
246
+ }
247
+ const none = lifted.untouched.length > 0 ? `; nothing on ${lifted.untouched.join(", ")}` : "";
248
+ const count = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
249
+ return [
250
+ `wrote ${path}`,
251
+ ` laid ${count(lifted.shapes, "shape")} and ${count(lifted.pictures, "picture")} over the picture${none}`,
252
+ ];
253
+ })
254
+ .join("\n");
255
+ }
256
+
233
257
  export function formatPack(data: PackCliResult): string {
234
258
  return [formatPdf(data), ...data.warnings.map((warning) => `warning: ${warning}`)].join("\n");
235
259
  }
@@ -346,6 +370,7 @@ export function formatTheme(data: ThemeResult): string {
346
370
  ...data.layouts.map(
347
371
  (layout) => ` ${layout.name}${layout.example === undefined ? " (no example)" : ""}`,
348
372
  ),
373
+ ...(data.unprintedLayouts ? [` and ${unprinted(data.unprintedLayouts)}`] : []),
349
374
  "",
350
375
  "CLASSES",
351
376
  ` ${data.classes.join(" ")}`,
@@ -360,7 +385,7 @@ export function formatTheme(data: ThemeResult): string {
360
385
  "TOKENS",
361
386
  ...data.tokens.map((token) => ` ${token.name.padEnd(width)} ${token.value}`),
362
387
  "",
363
- "Markup for a layout: dekc theme <layout>",
388
+ `Markup for a layout: dekc theme ${data.ref ? `${shellQuote(data.ref.name)} ` : ""}<layout>`,
364
389
  ].join("\n");
365
390
  }
366
391
 
@@ -437,7 +462,7 @@ export function formatRef(data: RefCliResult): string {
437
462
  head,
438
463
  ...data.warnings.map((warning) => `warning: ${warning}`),
439
464
  ...(data.pack ? [`commit ${data.pack}: a clone unpacks the ref from it`] : []),
440
- `read it with \`dekc ls ${data.name}\` and \`dekc show ${data.name} <slug>\``,
465
+ `read it with \`dekc ls ${shellQuote(data.name)}\` and \`dekc show ${shellQuote(data.name)} <slug>\``,
441
466
  ].join("\n");
442
467
  }
443
468
  case "list":
package/src/cli/theme.ts CHANGED
@@ -2,6 +2,8 @@ import { existsSync } from "node:fs";
2
2
  import { type CssToken, parseCss } from "../core/css.ts";
3
3
  import { deckPaths } from "../core/deck-paths.ts";
4
4
  import { DekError } from "../core/error.ts";
5
+ import { isPlainWord } from "../core/escape.ts";
6
+ import { listShown, unprinted } from "../core/prose.ts";
5
7
  import { readDeckFile } from "../core/resolve.ts";
6
8
  import { type ThemeLayout, themeFacts } from "../core/theme-facts.ts";
7
9
  import type { ReadableDeck, RefInfo } from "./scope.ts";
@@ -15,7 +17,10 @@ export type ThemeResult = {
15
17
  stateClasses: string[];
16
18
  /** The tokens every slide can `var()`. */
17
19
  tokens: CssToken[];
20
+ /** The layouts whose names are plain words. A name is the theme's text, maybe a pack's. */
18
21
  layouts: ThemeLayout[];
22
+ /** How many layouts the theme has whose names are no plain word, left out of `layouts`. */
23
+ unprintedLayouts?: number;
19
24
  /** Set when one layout was asked for; text output prints its example alone. */
20
25
  layout?: { name: string; example: string };
21
26
  /** Set when the deck is a ref. */
@@ -34,12 +39,15 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
34
39
  const { classes, stateClasses, layouts, tokens } = themeFacts(
35
40
  parseCss(readDeckFile(deck.dir, path) ?? ""),
36
41
  );
42
+ const printed = layouts.filter((layout) => isPlainWord(layout.name));
43
+ const left = layouts.length - printed.length;
37
44
  const result: ThemeResult = {
38
45
  path,
39
46
  classes: [...classes].sort(),
40
47
  stateClasses: [...stateClasses].sort(),
41
48
  tokens,
42
- layouts,
49
+ layouts: printed,
50
+ ...(left > 0 ? { unprintedLayouts: left } : {}),
43
51
  ...(ref ? { ref } : {}),
44
52
  };
45
53
  if (!name) {
@@ -49,7 +57,13 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
49
57
  if (!layout) {
50
58
  throw new DekError(`layout "${name}" is not defined in theme.css`, {
51
59
  path,
52
- hint: `use one of: ${layouts.map((entry) => entry.name).join(", ") || "(none)"}`,
60
+ hint: `use one of: ${
61
+ listShown(
62
+ layouts.map((entry) => entry.name),
63
+ isPlainWord,
64
+ unprinted,
65
+ ) || "(none)"
66
+ }`,
53
67
  });
54
68
  }
55
69
  if (layout.example === undefined) {
package/src/cli/voice.ts CHANGED
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { DekError } from "../core/error.ts";
5
5
  import { deckProjectRoot } from "../core/path.ts";
6
- import { isCachedFile, writeInside } from "../core/safe-fs.ts";
6
+ import { isCachedFile, requireInside, writeInside } from "../core/safe-fs.ts";
7
7
  import { loadVoiceDict, loadVoiceSettings, voiceCacheFile, writeVoiceDict } from "../core/voice.ts";
8
8
  import {
9
9
  engineBaseUrl,
@@ -91,8 +91,9 @@ export function pinVoice({ deck }: DeckTarget): VoiceCliResult {
91
91
  const pinDir = join(deck.dir, "voice", "pin");
92
92
  const pinTimeline = join(pinDir, "timeline.json");
93
93
  const pinAudio = join(pinDir, "master.wav");
94
- writeInside(pinTimeline, readFileSync(timelinePath), root);
95
- writeInside(pinAudio, readFileSync(audioPath), root);
94
+ // The folders on the way may be links too; what is pinned comes from inside the project.
95
+ writeInside(pinTimeline, readFileSync(requireInside(timelinePath, root)), root);
96
+ writeInside(pinAudio, readFileSync(requireInside(audioPath, root)), root);
96
97
  return { action: "pin", timelinePath: pinTimeline, audioPath: pinAudio };
97
98
  }
98
99