@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/cli/init.ts CHANGED
@@ -3,10 +3,12 @@ import { basename, join, resolve } from "node:path";
3
3
  import { parseCss, publishedTokens } from "../core/css.ts";
4
4
  import { deckPaths } from "../core/deck-paths.ts";
5
5
  import { DekError } from "../core/error.ts";
6
+ import { shellQuote } from "../core/escape.ts";
6
7
  import { REQUIRED_TOKENS } from "../core/lint/tokens.ts";
7
8
  import { walkUp } from "../core/optional.ts";
8
9
  import { DECK_NAME_HINT, isDeckName } from "../core/path.ts";
9
- import { readTextIfExists } from "../core/resolve.ts";
10
+ import { refuseInsideRef } from "../core/ref.ts";
11
+ import { readSourceIfExists } from "../core/safe-fs.ts";
10
12
  import {
11
13
  defaultTsconfig,
12
14
  dekFilePaths,
@@ -25,7 +27,6 @@ import {
25
27
  nextSteps,
26
28
  type PlannedPath,
27
29
  playwrightStep,
28
- shellQuote,
29
30
  } from "./files.ts";
30
31
 
31
32
  export type InitResult = {
@@ -84,7 +85,7 @@ export function initCommand(options: { cwd: string; dir?: string; deck?: string
84
85
  /** The tokens every deck needs that the project's theme.css does not publish on `.slide`. */
85
86
  function missingThemeTokens(root: string): string[] {
86
87
  const published = new Set(
87
- publishedTokens(parseCss(readTextIfExists(join(root, "theme.css")) ?? "")).map(
88
+ publishedTokens(parseCss(readSourceIfExists(join(root, "theme.css"), root) ?? "")).map(
88
89
  (token) => token.name,
89
90
  ),
90
91
  );
@@ -100,8 +101,10 @@ function syncDeckFiles(deckDir: string, created: string[]): FileChanges {
100
101
  /** Everything init writes but `.dek/` and AGENTS.md, in the order a reader meets it. */
101
102
  function projectPlan(root: string, deck: string | undefined): PlannedPath[] {
102
103
  const themePath = join(root, "theme.css");
103
- // A first deck is drawn in the theme that will be there: the one kept, or dek's.
104
- const theme = readTextIfExists(themePath) ?? defaultTheme();
104
+ // A first deck is drawn in the theme that will be there: the one kept, or dek's. A folder that
105
+ // is not there yet holds no theme.
106
+ const theme =
107
+ (existsSync(root) ? readSourceIfExists(themePath, root) : undefined) ?? defaultTheme();
105
108
  return [
106
109
  { path: join(root, "dek.toml"), contents: defaultToml() },
107
110
  { path: themePath, contents: defaultTheme() },
@@ -123,6 +126,8 @@ function checkTarget(root: string): void {
123
126
  if (existsSync(root) && !statSync(root).isDirectory()) {
124
127
  throw new DekError("not a directory", { path: root, hint: "pass a directory to init" });
125
128
  }
129
+ // A ref is someone else's project, read where it is: init would write its files into it.
130
+ refuseInsideRef(root);
126
131
  const outer = walkUp(root, (dir) => {
127
132
  const config = join(dir, "dek.toml");
128
133
  return existsSync(config) && statSync(config).isFile() ? dir : undefined;
package/src/cli/lint.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { relative } from "node:path";
3
3
  import type { Diagnostic } from "../core/diagnostic.ts";
4
+ import { shellQuote } from "../core/escape.ts";
4
5
  import { lintDeck, lintProject, unreadableScriptDiagnostics } from "../core/lint.ts";
5
6
  import { playwrightMissingError } from "../core/playwright.ts";
6
7
  import { resolveRumdlBin, runRumdl } from "../core/rumdl.ts";
7
8
  import { mergeSarif, type SarifLog } from "../core/sarif.ts";
8
9
  import { syncDeck } from "../core/sync.ts";
9
10
  import { lintVisualDeck } from "../core/visual.ts";
10
- import { type SkippedCheck, skippedChecks, visualSkipped } from "./result.ts";
11
+ import { RENDERED_RULES, type SkippedCheck, skippedChecks, visualSkipped } from "./result.ts";
11
12
  import type { DecksTarget } from "./scope.ts";
12
13
 
13
14
  export type LintCliResult = {
@@ -60,7 +61,7 @@ export async function lintCommand(
60
61
  ? []
61
62
  : [
62
63
  visualSkipped("dekc lint --visual", {
63
- reason: "overflow and contrast are measured only with --visual",
64
+ reason: `${RENDERED_RULES} are measured only with --visual`,
64
65
  }),
65
66
  ]),
66
67
  ]),
@@ -79,6 +80,6 @@ function rumdlSkipped(scriptPath: string, cwd: string): SkippedCheck {
79
80
  return {
80
81
  check: "rumdl",
81
82
  reason: "rumdl ran but gave no result",
82
- hint: `run \`${bin} check ${relative(cwd, scriptPath) || scriptPath}\` to see why`,
83
+ hint: `run \`${shellQuote(bin)} check ${shellQuote(relative(cwd, scriptPath) || scriptPath)}\` to see why`,
83
84
  };
84
85
  }
package/src/cli/main.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { DekError } from "../core/error.ts";
2
+ import { shellQuote } from "../core/escape.ts";
2
3
  import { either } from "../core/prose.ts";
3
4
  import { isRefName } from "../core/ref-name.ts";
4
5
  import { resolveProject } from "../core/resolve.ts";
5
- import { addressHint } from "./address.ts";
6
+ import { addressHint, deckWord, type HintDeck, withPlaceholderNote } from "./address.ts";
6
7
  import {
7
8
  type AnySpec,
8
9
  type CliResult,
@@ -13,7 +14,6 @@ import {
13
14
  subcommandAt,
14
15
  subcommandsOf,
15
16
  } from "./commands.ts";
16
- import { shellQuote } from "./files.ts";
17
17
  import { type CommandLine, parseCommandLine, wantsJson } from "./flags.ts";
18
18
  import { writeFailure, writeSuccess } from "./result.ts";
19
19
  import { DeckRequiredError, namesDeck, peelDeckArg, resolveTarget } from "./scope.ts";
@@ -197,12 +197,14 @@ function retypedWithDeck<T>(line: CommandLine, cwd: string, resolve: () => T): T
197
197
  }
198
198
  const typed = `\`dekc ${line.typed.map(shellQuote).join(" ")}\``;
199
199
  const [first] = error.decks;
200
+ // Each command is retyped for one deck; where to find a name left as <deck> is said once.
201
+ const retyped = (deck: HintDeck) => addressHint(typed, deck, cwd, { note: false });
200
202
  const hint = !first
201
203
  ? "run `dekc new <name>` to make a deck"
202
204
  : error.decks.length > NAMED_DECKS
203
- ? `run ${addressHint(typed, first, cwd)}, or name another deck in place of ${first.name}; \`dekc ls\` lists them`
204
- : `run ${either(error.decks.map((deck) => addressHint(typed, deck, cwd)))}`;
205
- throw new DekError(error.message, { hint, cause: error });
205
+ ? `run ${retyped(first)}, or name another deck in place of ${deckWord(first.name)}; \`dekc ls\` lists them`
206
+ : `run ${either(error.decks.map(retyped))}`;
207
+ throw new DekError(error.message, { hint: withPlaceholderNote(hint), cause: error });
206
208
  }
207
209
  }
208
210
 
package/src/cli/new.ts CHANGED
@@ -1,12 +1,15 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadConfig } from "../core/config.ts";
4
4
  import { deckPaths } from "../core/deck-paths.ts";
5
5
  import { DekError } from "../core/error.ts";
6
6
  import { DECK_NAME_HINT, isDeckName } from "../core/path.ts";
7
+ import { isRefName } from "../core/ref-name.ts";
8
+ import { readDeckFile } from "../core/resolve.ts";
9
+ import { readSourceIfExists } from "../core/safe-fs.ts";
7
10
  import { syncDeck } from "../core/sync.ts";
8
11
  import { applyPlan, deckPlan, nextSteps } from "./files.ts";
9
- import { requireDeckFromCwd, requireProject } from "./scope.ts";
12
+ import { type RefInfo, requireDeckFromCwd, requireProject, resolveTarget } from "./scope.ts";
10
13
 
11
14
  export type NewResult = {
12
15
  name: string;
@@ -16,6 +19,8 @@ export type NewResult = {
16
19
  updated: string[];
17
20
  /** The commands to run next, from the directory new ran in. */
18
21
  next: string[];
22
+ /** Set when the theme came from a ref with no license. */
23
+ warnings: string[];
19
24
  };
20
25
 
21
26
  export function newCommand(options: { cwd: string; name: string; themeFrom?: string }): NewResult {
@@ -35,19 +40,34 @@ export function newCommand(options: { cwd: string; name: string; themeFrom?: str
35
40
  });
36
41
  }
37
42
 
38
- const themeSource = options.themeFrom
39
- ? deckPaths(requireDeckFromCwd(options.cwd, options.themeFrom).deck.dir).theme
40
- : join(project.root, "theme.css");
41
- if (!existsSync(themeSource)) {
43
+ // A ref is read where it is, never written: only the copy below is the new deck's.
44
+ const fromRef =
45
+ options.themeFrom !== undefined && isRefName(options.themeFrom)
46
+ ? resolveTarget(options.cwd, "deck", { refs: true, deck: options.themeFrom })
47
+ : undefined;
48
+ const ref = fromRef?.ref;
49
+ const fromDeck = fromRef
50
+ ? fromRef.deck
51
+ : options.themeFrom
52
+ ? requireDeckFromCwd(options.cwd, options.themeFrom).deck
53
+ : undefined;
54
+ const themeSource = fromDeck ? deckPaths(fromDeck.dir).theme : join(project.root, "theme.css");
55
+ // What is copied is published with the new deck: a link out of its project is refused.
56
+ const source = fromDeck
57
+ ? readDeckFile(fromDeck.dir, themeSource)
58
+ : readSourceIfExists(themeSource, project.root);
59
+ if (source === undefined) {
42
60
  throw new DekError("theme.css not found", {
43
61
  path: themeSource,
44
- hint: options.themeFrom
45
- ? "run `dekc ls` and pick a deck that has theme.css"
46
- : "add theme.css at the project root",
62
+ hint: ref
63
+ ? "run `dekc ref` to list the refs, and pick one whose deck has theme.css"
64
+ : options.themeFrom
65
+ ? "run `dekc ls` and pick a deck that has theme.css"
66
+ : "add theme.css at the project root",
47
67
  });
48
68
  }
49
69
 
50
- const theme = readFileSync(themeSource, "utf8");
70
+ const theme = `${ref ? provenance(ref) : ""}${source}`;
51
71
  const { created } = applyPlan(
52
72
  deckPlan(project.root, name, theme, loadConfig(project.configPath)),
53
73
  );
@@ -61,5 +81,16 @@ export function newCommand(options: { cwd: string; name: string; themeFrom?: str
61
81
  created,
62
82
  updated: synced.dekFiles.updated,
63
83
  next: nextSteps(options.cwd, project.root, dir),
84
+ warnings:
85
+ ref && ref.license === null
86
+ ? [
87
+ `${ref.name} has no license; ask its author before you present or share a deck built on its theme`,
88
+ ]
89
+ : [],
64
90
  };
65
91
  }
92
+
93
+ /** The first line of a theme copied from a ref: where it came from, for its reader and its credit. */
94
+ function provenance(ref: RefInfo): string {
95
+ return `/* Theme from the ref ${ref.name} at ${ref.rev.slice(0, 7)}. */\n`;
96
+ }
@@ -0,0 +1,21 @@
1
+ import { writePack } from "../core/pack.ts";
2
+ import type { DecksTarget } from "./scope.ts";
3
+
4
+ /** One file per deck, a single deck included, as `dekc pdf` answers, and what to fix first. */
5
+ export type PackCliResult = { outs: string[]; warnings: string[] };
6
+
7
+ export async function packCommand(
8
+ { project, decks }: DecksTarget,
9
+ options: { rootDist?: boolean } = {},
10
+ ): Promise<PackCliResult> {
11
+ const outs: string[] = [];
12
+ const warnings = new Set<string>();
13
+ for (const entry of decks) {
14
+ const done = await writePack(project, entry, { rootDist: options.rootDist });
15
+ outs.push(done.outPath);
16
+ for (const warning of done.warnings) {
17
+ warnings.add(warning);
18
+ }
19
+ }
20
+ return { outs, warnings: [...warnings] };
21
+ }
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,20 +1,30 @@
1
- import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync } from "node:fs";
2
- import { dirname, join } 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 {
8
+ deckOfPack,
7
9
  installSnapshot,
8
10
  isPinnedRev,
11
+ packFile,
9
12
  parseRefSource,
10
- type RefSource,
13
+ pickSnapshot,
14
+ readPack,
11
15
  readTarball,
16
+ refDir,
12
17
  refLicense,
13
18
  refState,
14
19
  refTitle,
20
+ refuseLinkUnderPacks,
21
+ refuseLinkUnderRefs,
22
+ type Snapshot,
23
+ snapshotDigest,
24
+ writeSnapshot,
15
25
  } from "../core/ref.ts";
16
- import { readTextIfExists } from "../core/resolve.ts";
17
- import { readSourceIfExists, removeInside, writeInside } from "../core/safe-fs.ts";
26
+ import type { FileRefSource, GithubRefSource, RefSource } from "../core/ref-name.ts";
27
+ import { readSourceIfExists, removeInside, requireInside, writeInside } from "../core/safe-fs.ts";
18
28
  import { writeAgentsMd } from "../core/sync.ts";
19
29
  import { setTableKey } from "../core/toml-keys.ts";
20
30
  import { requireProject } from "./scope.ts";
@@ -29,6 +39,8 @@ export type RefAddResult = {
29
39
  changed: boolean;
30
40
  dir: string;
31
41
  license: string | null;
42
+ /** A pack's ref: its committed copy, which a clone unpacks the ref from. */
43
+ pack?: string;
32
44
  warnings: string[];
33
45
  };
34
46
 
@@ -49,23 +61,82 @@ export type RefCliResult = RefAddResult | RefListResult | RefRmResult;
49
61
  export async function restoreRef(cwd: string, arg: string): Promise<void> {
50
62
  const source = parseRefSource(arg);
51
63
  const project = requireProject(cwd);
52
- const { pinned, fetched } = refState(project, source.name);
64
+ const { pinned, dir, fetched } = refState(project, source.name);
53
65
  if (pinned === undefined || fetched) {
54
66
  return;
55
67
  }
68
+ // Said before a download that the write would refuse anyway.
69
+ refuseLinkUnderRefs(project.root, dir);
70
+ if (source.kind === "file") {
71
+ await unpackSnapshot(project.root, source, pinned);
72
+ return;
73
+ }
56
74
  await fetchSnapshot(project.root, source, pinned);
57
75
  }
58
76
 
59
- async function fetchSnapshot(root: string, source: RefSource, sha: string) {
77
+ async function fetchSnapshot(root: string, source: GithubRefSource, sha: string) {
60
78
  const files = await readTarball(await downloadTarball(source.owner, source.repo, sha));
61
79
  return installSnapshot(root, source, sha, files);
62
80
  }
63
81
 
82
+ /** A pack's snapshot, unpacked again from its committed copy, which must be the pinned one. */
83
+ async function unpackSnapshot(root: string, source: FileRefSource, pin: string): Promise<void> {
84
+ const rel = packFile(source.deck);
85
+ const path = join(root, rel);
86
+ if (!existsSync(path)) {
87
+ throw new DekError(`${rel} is missing`, {
88
+ path,
89
+ hint: `a pack's ref is unpacked from its copy in packs/, so commit packs/ with dek.toml; run \`dekc ref <file>.tgz\` with the pack you were sent to add it again`,
90
+ });
91
+ }
92
+ const snapshot = await readPackFile(path, source, root);
93
+ if ((await snapshotDigest(snapshot)) !== pin) {
94
+ throw new DekError(`${rel} is not the pack ${source.name} is pinned to`, {
95
+ path,
96
+ hint: `run \`dekc ref ${shellQuote(rel)}\` to pin the pack that is there, or put back the one dek.toml pins`,
97
+ });
98
+ }
99
+ await writeSnapshot(root, source.name, pin, snapshot);
100
+ }
101
+
102
+ /** What a snapshot keeps of the pack at `path`; an error says which file it was about. */
103
+ async function readPackFile(
104
+ path: string,
105
+ source: FileRefSource | undefined,
106
+ root?: string,
107
+ ): Promise<Snapshot & { source: FileRefSource; bytes: Uint8Array }> {
108
+ try {
109
+ const bytes = readFileSync(root === undefined ? path : requireInside(path, root));
110
+ const files = await readPack(bytes);
111
+ const found = source ?? parseRefSource(`file/${deckOfPack(files)}`);
112
+ if (found.kind !== "file") {
113
+ throw new DekError("the pack's deck cannot be named as a ref");
114
+ }
115
+ return { ...pickSnapshot(files, found), source: found, bytes };
116
+ } catch (error) {
117
+ if (error instanceof DekError && error.path === undefined) {
118
+ throw new DekError(error.message, { path, ...(error.hint ? { hint: error.hint } : {}) });
119
+ }
120
+ throw error;
121
+ }
122
+ }
123
+
64
124
  /** `dekc ref <source>`: pin and fetch, or move the pin to the latest or to the rev after `@`. */
65
125
  export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
126
+ if (isPackPath(arg)) {
127
+ return addPack(cwd, arg);
128
+ }
66
129
  const source = parseRefSource(arg);
130
+ if (source.kind === "file") {
131
+ throw new DekError(`"${arg}" names a pack's ref`, {
132
+ hint: "add a pack by its file, `dekc ref <file>.tgz`; `dekc ref` lists the refs already added",
133
+ });
134
+ }
67
135
  const project = requireProject(cwd);
136
+ refuseSharedFolders(project, source);
68
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);
69
140
  const rev =
70
141
  source.rev !== undefined && isPinnedRev(source.rev)
71
142
  ? source.rev
@@ -78,7 +149,7 @@ export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
78
149
  changed = true;
79
150
  }
80
151
  if (from !== rev) {
81
- const toml = readTextIfExists(project.configPath) ?? "";
152
+ const toml = readSourceIfExists(project.configPath, project.root) ?? "";
82
153
  writeInside(project.configPath, setTableKey(toml, "refs", source.name, rev), project.root);
83
154
  changed = true;
84
155
  }
@@ -103,6 +174,153 @@ export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
103
174
  };
104
175
  }
105
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
+
243
+ /** An argument that ends in .tgz or .tar.gz is a pack's path, never a ref name. */
244
+ function isPackPath(arg: string): boolean {
245
+ return /\.(tgz|tar\.gz)$/i.test(arg);
246
+ }
247
+
248
+ /**
249
+ * `dekc ref <file>.tgz`: pin the pack's deck as `file/<deck>` to the digest of its files, keep
250
+ * the pack as packs/<deck>.tgz for git, and unpack the snapshot. Everything is read and checked
251
+ * before the first write.
252
+ */
253
+ async function addPack(cwd: string, arg: string): Promise<RefAddResult> {
254
+ const project = requireProject(cwd);
255
+ const given = resolve(cwd, arg);
256
+ if (!existsSync(given)) {
257
+ throw new DekError(`no such file: ${arg}`, {
258
+ path: given,
259
+ hint: "pass the path of the .tgz `dekc pack` wrote",
260
+ });
261
+ }
262
+ const read = await readPackFile(given, undefined);
263
+ const { source, bytes } = read;
264
+ refuseSharedFolders(project, source);
265
+ const rev = await snapshotDigest(read);
266
+ const { pinned: from, dir, fetched } = refState(project, source.name);
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);
270
+
271
+ let changed = false;
272
+ if (!(fetched && from === rev)) {
273
+ await writeSnapshot(project.root, source.name, rev, read);
274
+ changed = true;
275
+ }
276
+ if (!(await isPackOf(stored, source, rev, project.root))) {
277
+ writeInside(stored, bytes, project.root);
278
+ changed = true;
279
+ }
280
+ if (from !== rev) {
281
+ const toml = readSourceIfExists(project.configPath, project.root) ?? "";
282
+ writeInside(project.configPath, setTableKey(toml, "refs", source.name, rev), project.root);
283
+ changed = true;
284
+ }
285
+ ignoreRefs(project.root);
286
+ writeAgentsMd(project.root);
287
+
288
+ const license = refLicense(dir);
289
+ return {
290
+ action: "add",
291
+ name: source.name,
292
+ rev,
293
+ ...(from !== undefined && from !== rev ? { from } : {}),
294
+ changed,
295
+ dir,
296
+ license,
297
+ pack: stored,
298
+ warnings:
299
+ license === null
300
+ ? [
301
+ "the pack has no license; read it as a model, but ask its author before reusing its text or assets",
302
+ ]
303
+ : [],
304
+ };
305
+ }
306
+
307
+ /** True when the pack at `path` holds the files `rev` pins. */
308
+ async function isPackOf(
309
+ path: string,
310
+ source: FileRefSource,
311
+ rev: string,
312
+ root: string,
313
+ ): Promise<boolean> {
314
+ if (!existsSync(path)) {
315
+ return false;
316
+ }
317
+ try {
318
+ return (await snapshotDigest(await readPackFile(path, source, root))) === rev;
319
+ } catch {
320
+ return false;
321
+ }
322
+ }
323
+
106
324
  export function listRefs(cwd: string): RefListResult {
107
325
  const project = requireProject(cwd);
108
326
  const refs = Object.keys(loadConfig(project.configPath).refs ?? {}).map((name) => {
@@ -123,8 +341,13 @@ export function removeRef(cwd: string, arg: string): RefRmResult {
123
341
  hint: "run `dekc ref` to list refs",
124
342
  });
125
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
+ }
126
349
  if (pinned !== undefined) {
127
- const toml = readFileSync(project.configPath, "utf8");
350
+ const toml = readSourceIfExists(project.configPath, project.root) ?? "";
128
351
  writeInside(
129
352
  project.configPath,
130
353
  setTableKey(toml, "refs", source.name, undefined),
@@ -133,6 +356,17 @@ export function removeRef(cwd: string, arg: string): RefRmResult {
133
356
  }
134
357
  removeInside(dir, project.root, { recursive: true });
135
358
  removeEmptyParents(dirname(dir), project.root);
359
+ if (source.kind === "file") {
360
+ const packs = join(project.root, "packs");
361
+ removeInside(join(project.root, packFile(source.deck)), project.root);
362
+ if (
363
+ existsSync(packs) &&
364
+ !lstatSync(packs).isSymbolicLink() &&
365
+ readdirSync(packs).length === 0
366
+ ) {
367
+ rmdirSync(packs);
368
+ }
369
+ }
136
370
  writeAgentsMd(project.root);
137
371
  return { action: "rm", name: source.name };
138
372
  }
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
  : {