@hajimism/dek 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.ja.md +2 -2
  3. package/README.md +2 -2
  4. package/package.json +5 -3
  5. package/skills/dek/SKILL.md +10 -3
  6. package/skills/dek-design/SKILL.md +42 -0
  7. package/src/cli/address.ts +95 -18
  8. package/src/cli/build.ts +2 -2
  9. package/src/cli/check.ts +2 -1
  10. package/src/cli/commands.ts +57 -19
  11. package/src/cli/contract.ts +58 -4
  12. package/src/cli/files.ts +2 -5
  13. package/src/cli/flags.ts +1 -1
  14. package/src/cli/format.ts +5 -4
  15. package/src/cli/goto.ts +44 -11
  16. package/src/cli/init.ts +10 -5
  17. package/src/cli/lint.ts +36 -16
  18. package/src/cli/ls.ts +5 -1
  19. package/src/cli/main.ts +51 -14
  20. package/src/cli/new.ts +28 -8
  21. package/src/cli/pptx.ts +9 -4
  22. package/src/cli/ref.ts +92 -9
  23. package/src/cli/result.ts +47 -6
  24. package/src/cli/scope.ts +57 -29
  25. package/src/cli/shot.ts +78 -6
  26. package/src/cli/show.ts +15 -2
  27. package/src/cli/text.ts +88 -11
  28. package/src/cli/theme.ts +58 -10
  29. package/src/cli/tty.ts +16 -2
  30. package/src/cli/voice.ts +15 -9
  31. package/src/core/alignment.ts +320 -0
  32. package/src/core/annotation-rows.ts +16 -4
  33. package/src/core/annotations.ts +19 -1
  34. package/src/core/assets.ts +18 -10
  35. package/src/core/attribute-value.ts +188 -0
  36. package/src/core/build.ts +3 -3
  37. package/src/core/config.ts +61 -12
  38. package/src/core/css-scan.ts +173 -39
  39. package/src/core/css-transform.ts +57 -11
  40. package/src/core/css.ts +19 -13
  41. package/src/core/diagnostic.ts +5 -1
  42. package/src/core/escape.ts +43 -0
  43. package/src/core/foreign-text.ts +138 -0
  44. package/src/core/github.ts +1 -0
  45. package/src/core/html-entities.ts +318 -0
  46. package/src/core/html-scan.ts +49 -27
  47. package/src/core/html.ts +21 -15
  48. package/src/core/line-break.ts +275 -0
  49. package/src/core/lint/asset-refs.ts +50 -8
  50. package/src/core/lint/files.ts +30 -5
  51. package/src/core/lint/keys.ts +7 -3
  52. package/src/core/lint/slide-html.ts +24 -19
  53. package/src/core/lint/slide-script.ts +7 -5
  54. package/src/core/mv.ts +57 -27
  55. package/src/core/order.ts +14 -0
  56. package/src/core/pack.ts +2 -2
  57. package/src/core/path.ts +8 -2
  58. package/src/core/playwright-module.d.ts +13 -1
  59. package/src/core/playwright-motion.ts +1 -0
  60. package/src/core/playwright-sheet.ts +1 -0
  61. package/src/core/playwright-visual.ts +207 -30
  62. package/src/core/playwright.ts +279 -44
  63. package/src/core/pptx-package.ts +461 -33
  64. package/src/core/pptx-paint.ts +1168 -0
  65. package/src/core/pptx-plan.ts +879 -0
  66. package/src/core/pptx-text.ts +204 -47
  67. package/src/core/pptx-theme.ts +222 -0
  68. package/src/core/pptx.ts +56 -8
  69. package/src/core/prose.ts +23 -0
  70. package/src/core/pseudo-text.ts +32 -10
  71. package/src/core/ref-name.ts +52 -8
  72. package/src/core/ref.ts +308 -54
  73. package/src/core/resolve.ts +23 -11
  74. package/src/core/rumdl.ts +51 -25
  75. package/src/core/safe-fs.ts +19 -2
  76. package/src/core/sandbox.ts +217 -0
  77. package/src/core/shot/morph.ts +1 -1
  78. package/src/core/shot/motion.ts +2 -3
  79. package/src/core/shot/still.ts +2 -2
  80. package/src/core/shot-cache.ts +26 -17
  81. package/src/core/slide-eval-worker.ts +1 -0
  82. package/src/core/slide-measure.ts +51 -1
  83. package/src/core/slide-script-eval.ts +12 -8
  84. package/src/core/slide-script.ts +114 -20
  85. package/src/core/spawn.ts +131 -32
  86. package/src/core/sync.ts +5 -14
  87. package/src/core/tar.ts +315 -0
  88. package/src/core/text-contrast.ts +2 -0
  89. package/src/core/toml-keys.ts +116 -51
  90. package/src/core/url-attributes.ts +27 -9
  91. package/src/core/visual-cache.ts +2 -0
  92. package/src/core/visual.ts +182 -24
  93. package/src/core/voice.ts +5 -6
  94. package/src/core/zod.ts +9 -1
  95. package/src/server/auth.ts +21 -0
  96. package/src/server/dev.ts +26 -7
  97. package/src/server/lock.ts +50 -7
  98. package/src/server/routes.ts +7 -2
  99. package/src/server/serial.ts +8 -2
  100. package/src/theme/default.css +4 -0
  101. package/src/video/bake.ts +6 -2
  102. package/src/video/mux.ts +27 -3
  103. package/src/video/recorder.ts +3 -2
  104. package/src/video/worker.ts +1 -0
  105. package/src/voice/engine.ts +81 -18
package/src/cli/ls.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { Diagnostic } from "../core/diagnostic.ts";
2
+ import type { HiddenText } from "../core/foreign-text.ts";
2
3
  import { lintDeck, lintProject } from "../core/lint.ts";
3
4
  import { listSlides, type Project, type ProjectDeck } from "../core/resolve.ts";
4
5
  import { slideVideoSeconds, timelineSeconds } from "../core/timeline.ts";
5
6
  import { sectionTiming } from "../core/timing.ts";
6
7
  import { tryLoadCachedTimeline } from "../core/voice.ts";
7
8
  import { type SkippedCheck, skippedChecks } from "./result.ts";
8
- import type { DecksTarget, RefInfo } from "./scope.ts";
9
+ import { type DecksTarget, hiddenField, hiddenInFiles, type RefInfo } from "./scope.ts";
9
10
 
10
11
  export type LsListResult = {
11
12
  kind: "list";
@@ -46,6 +47,8 @@ export type LsDeckResult = {
46
47
  skipped?: SkippedCheck[];
47
48
  /** Set when the deck is a ref. */
48
49
  ref?: RefInfo;
50
+ /** A ref's lines of script.md that hold characters a person cannot see. */
51
+ hidden?: HiddenText[];
49
52
  };
50
53
 
51
54
  /** The one deck in scope, a ref included, or else every deck of the project. */
@@ -58,6 +61,7 @@ export function lsCommand(target: DecksTarget): LsListResult | LsDeckResult {
58
61
  { check: "lint", reason: "a ref is read-only; its problems are not yours to fix" },
59
62
  ]),
60
63
  ref,
64
+ ...hiddenField(ref, hiddenInFiles(deck.dir, [deck.scriptPath])),
61
65
  };
62
66
  }
63
67
  if (deck) {
package/src/cli/main.ts CHANGED
@@ -1,8 +1,13 @@
1
+ import { parseCss } from "../core/css.ts";
2
+ import { deckPaths } from "../core/deck-paths.ts";
1
3
  import { DekError } from "../core/error.ts";
4
+ import { shellQuote } from "../core/escape.ts";
2
5
  import { either } from "../core/prose.ts";
3
6
  import { isRefName } from "../core/ref-name.ts";
4
- import { resolveProject } from "../core/resolve.ts";
5
- import { addressHint } from "./address.ts";
7
+ import { type ProjectDeck, readDeckFile, resolveProject } from "../core/resolve.ts";
8
+ import { takeUnsandboxed } from "../core/sandbox.ts";
9
+ import { themeFacts } from "../core/theme-facts.ts";
10
+ import { addressHint, deckWord, type HintDeck, withPlaceholderNote } from "./address.ts";
6
11
  import {
7
12
  type AnySpec,
8
13
  type CliResult,
@@ -13,7 +18,6 @@ import {
13
18
  subcommandAt,
14
19
  subcommandsOf,
15
20
  } from "./commands.ts";
16
- import { shellQuote } from "./files.ts";
17
21
  import { type CommandLine, parseCommandLine, wantsJson } from "./flags.ts";
18
22
  import { writeFailure, writeSuccess } from "./result.ts";
19
23
  import { DeckRequiredError, namesDeck, peelDeckArg, resolveTarget } from "./scope.ts";
@@ -158,7 +162,7 @@ async function run(cwd: string, line: CommandLine): Promise<void> {
158
162
  }
159
163
  const scope = spec.scope;
160
164
  const target = scope
161
- ? retypedWithDeck(line, cwd, () =>
165
+ ? retypedWithDeck(line, cwd, call, () =>
162
166
  resolveTarget(cwd, scope, {
163
167
  deck: call.deck,
164
168
  refs: spec.refs === true,
@@ -170,10 +174,13 @@ async function run(cwd: string, line: CommandLine): Promise<void> {
170
174
  const ctx = { cwd, flags: line.values, args: call.args, target };
171
175
  const data = await form.run?.(ctx);
172
176
  if (spec.kind === "result") {
173
- writeSuccess({ command: call.name, data } as CliResult, {
177
+ // Said on stderr as Chromium drew without its sandbox, and kept here for --json.
178
+ const drawn = { ...(data as object), ...takeUnsandboxed() };
179
+ writeSuccess({ command: call.name, data: drawn } as CliResult, {
174
180
  json: line.values.json === true,
175
181
  format: line.values.format,
176
182
  cwd,
183
+ flags: line.values,
177
184
  ...(target?.deck && {
178
185
  deck: { name: target.ref?.name ?? target.deck.name, dir: target.deck.dir },
179
186
  }),
@@ -186,9 +193,10 @@ const NAMED_DECKS = 3;
186
193
 
187
194
  /**
188
195
  * `resolve`, with a missing deck hinted as the line that was typed, the deck named where the
189
- * command takes it: one command per deck of the project, so the hint runs as written.
196
+ * command takes it: one command per deck of the project that has what the line names, so the
197
+ * hint runs as written.
190
198
  */
191
- function retypedWithDeck<T>(line: CommandLine, cwd: string, resolve: () => T): T {
199
+ function retypedWithDeck<T>(line: CommandLine, cwd: string, call: Call, resolve: () => T): T {
192
200
  try {
193
201
  return resolve();
194
202
  } catch (error) {
@@ -196,16 +204,43 @@ function retypedWithDeck<T>(line: CommandLine, cwd: string, resolve: () => T): T
196
204
  throw error;
197
205
  }
198
206
  const typed = `\`dekc ${line.typed.map(shellQuote).join(" ")}\``;
199
- const [first] = error.decks;
200
- const hint = !first
201
- ? "run `dekc new <name>` to make a deck"
202
- : 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 });
207
+ const decks = error.decks.filter((deck) => runsIn(call, deck));
208
+ const [first] = decks;
209
+ // Each command is retyped for one deck; where to find a name left as <deck> is said once.
210
+ const retyped = (deck: HintDeck) => addressHint(typed, deck, cwd, { note: false });
211
+ const hint =
212
+ error.decks.length === 0
213
+ ? "run `dekc new <name>` to make a deck"
214
+ : !first
215
+ ? "run `dekc ls`"
216
+ : decks.length > NAMED_DECKS
217
+ ? `run ${retyped(first)}, or name another deck in place of ${deckWord(first.name)}; \`dekc ls\` lists them`
218
+ : `run ${either(decks.map(retyped))}`;
219
+ throw new DekError(error.message, { hint: withPlaceholderNote(hint), cause: error });
206
220
  }
207
221
  }
208
222
 
223
+ /**
224
+ * Whether `call` finds in `deck` what it names: a section, the one `dekc mv` renames or the name
225
+ * script.md already gives it, and a layout of the deck's theme.
226
+ */
227
+ function runsIn(call: Call, deck: ProjectDeck): boolean {
228
+ const { slug, old, layout } = call.args;
229
+ const has = (name: string | undefined) =>
230
+ deck.deck.sections.some((section) => section.slug === name);
231
+ return (
232
+ (slug === undefined || has(slug)) &&
233
+ (old === undefined || has(old) || has(call.args.new)) &&
234
+ (layout === undefined || themeLayouts(deck).includes(layout))
235
+ );
236
+ }
237
+
238
+ /** The layouts a deck's theme defines; none when it has no theme.css. */
239
+ function themeLayouts(deck: ProjectDeck): string[] {
240
+ const css = readDeckFile(deck.dir, deckPaths(deck.dir).theme) ?? "";
241
+ return themeFacts(parseCss(css)).layouts.map((entry) => entry.name);
242
+ }
243
+
209
244
  /** A session prints no result, so --json has nothing to shape, and a reader would wait forever. */
210
245
  function sessionJsonError(name: string): DekError {
211
246
  return new DekError(`${name} keeps running until stopped and prints no JSON`, {
@@ -218,6 +253,8 @@ function sessionJsonError(name: string): DekError {
218
253
  * prints once, here: as JSON when the line asks for it, even a line too broken to parse.
219
254
  */
220
255
  export async function main(argv: string[], cwd: string): Promise<void> {
256
+ // What a run before this one in the process drew is its own, even one that failed.
257
+ takeUnsandboxed();
221
258
  let json = wantsJson(argv);
222
259
  try {
223
260
  const line = parseCommandLine(argv);
package/src/cli/new.ts CHANGED
@@ -1,13 +1,22 @@
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
+ import { type HiddenText, hiddenText } from "../core/foreign-text.ts";
6
7
  import { DECK_NAME_HINT, isDeckName } from "../core/path.ts";
7
8
  import { isRefName } from "../core/ref-name.ts";
9
+ import { readDeckFile } from "../core/resolve.ts";
10
+ import { readSourceIfExists } from "../core/safe-fs.ts";
8
11
  import { syncDeck } from "../core/sync.ts";
9
12
  import { applyPlan, deckPlan, nextSteps } from "./files.ts";
10
- import { type RefInfo, requireDeckFromCwd, requireProject, resolveTarget } from "./scope.ts";
13
+ import {
14
+ hiddenField,
15
+ type RefInfo,
16
+ requireDeckFromCwd,
17
+ requireProject,
18
+ resolveTarget,
19
+ } from "./scope.ts";
11
20
 
12
21
  export type NewResult = {
13
22
  name: string;
@@ -19,6 +28,11 @@ export type NewResult = {
19
28
  next: string[];
20
29
  /** Set when the theme came from a ref with no license. */
21
30
  warnings: string[];
31
+ /**
32
+ * A ref's theme's lines, now the new deck's theme.css, that hold characters a person cannot
33
+ * see: the copy is the deck's own, so they are its author's to remove.
34
+ */
35
+ hidden?: HiddenText[];
22
36
  };
23
37
 
24
38
  export function newCommand(options: { cwd: string; name: string; themeFrom?: string }): NewResult {
@@ -44,12 +58,17 @@ export function newCommand(options: { cwd: string; name: string; themeFrom?: str
44
58
  ? resolveTarget(options.cwd, "deck", { refs: true, deck: options.themeFrom })
45
59
  : undefined;
46
60
  const ref = fromRef?.ref;
47
- const themeSource = fromRef
48
- ? deckPaths(fromRef.deck.dir).theme
61
+ const fromDeck = fromRef
62
+ ? fromRef.deck
49
63
  : options.themeFrom
50
- ? deckPaths(requireDeckFromCwd(options.cwd, options.themeFrom).deck.dir).theme
51
- : join(project.root, "theme.css");
52
- if (!existsSync(themeSource)) {
64
+ ? requireDeckFromCwd(options.cwd, options.themeFrom).deck
65
+ : undefined;
66
+ const themeSource = fromDeck ? deckPaths(fromDeck.dir).theme : join(project.root, "theme.css");
67
+ // What is copied is published with the new deck: a link out of its project is refused.
68
+ const source = fromDeck
69
+ ? readDeckFile(fromDeck.dir, themeSource)
70
+ : readSourceIfExists(themeSource, project.root);
71
+ if (source === undefined) {
53
72
  throw new DekError("theme.css not found", {
54
73
  path: themeSource,
55
74
  hint: ref
@@ -60,7 +79,7 @@ export function newCommand(options: { cwd: string; name: string; themeFrom?: str
60
79
  });
61
80
  }
62
81
 
63
- const theme = `${ref ? provenance(ref) : ""}${readFileSync(themeSource, "utf8")}`;
82
+ const theme = `${ref ? provenance(ref) : ""}${source}`;
64
83
  const { created } = applyPlan(
65
84
  deckPlan(project.root, name, theme, loadConfig(project.configPath)),
66
85
  );
@@ -80,6 +99,7 @@ export function newCommand(options: { cwd: string; name: string; themeFrom?: str
80
99
  `${ref.name} has no license; ask its author before you present or share a deck built on its theme`,
81
100
  ]
82
101
  : [],
102
+ ...hiddenField(ref, ref ? hiddenText(theme, deckPaths(dir).theme) : []),
83
103
  };
84
104
  }
85
105
 
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,9 @@
1
- import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
1
+ // biome-ignore lint/style/noRestrictedImports: rmdirSync, which takes packs/ and refs/ folders only while empty; removeInside would take what is in them.
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, statSync } from "node:fs";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
4
  import { loadConfig } from "../core/config.ts";
4
5
  import { DekError } from "../core/error.ts";
6
+ import { isPlainWord, shellQuote } from "../core/escape.ts";
5
7
  import { downloadTarball, resolveRev } from "../core/github.ts";
6
8
  import {
7
9
  deckOfPack,
@@ -12,15 +14,17 @@ import {
12
14
  pickSnapshot,
13
15
  readPack,
14
16
  readTarball,
17
+ refDir,
15
18
  refLicense,
16
19
  refState,
17
20
  refTitle,
21
+ refuseLinkUnderPacks,
22
+ refuseLinkUnderRefs,
18
23
  type Snapshot,
19
24
  snapshotDigest,
20
25
  writeSnapshot,
21
26
  } from "../core/ref.ts";
22
- import type { FileRefSource, GithubRefSource } from "../core/ref-name.ts";
23
- import { readTextIfExists } from "../core/resolve.ts";
27
+ import type { FileRefSource, GithubRefSource, RefSource } from "../core/ref-name.ts";
24
28
  import { readSourceIfExists, removeInside, requireInside, writeInside } from "../core/safe-fs.ts";
25
29
  import { writeAgentsMd } from "../core/sync.ts";
26
30
  import { setTableKey } from "../core/toml-keys.ts";
@@ -58,10 +62,12 @@ export type RefCliResult = RefAddResult | RefListResult | RefRmResult;
58
62
  export async function restoreRef(cwd: string, arg: string): Promise<void> {
59
63
  const source = parseRefSource(arg);
60
64
  const project = requireProject(cwd);
61
- const { pinned, fetched } = refState(project, source.name);
65
+ const { pinned, dir, fetched } = refState(project, source.name);
62
66
  if (pinned === undefined || fetched) {
63
67
  return;
64
68
  }
69
+ // Said before a download that the write would refuse anyway.
70
+ refuseLinkUnderRefs(project.root, dir);
65
71
  if (source.kind === "file") {
66
72
  await unpackSnapshot(project.root, source, pinned);
67
73
  return;
@@ -88,7 +94,7 @@ async function unpackSnapshot(root: string, source: FileRefSource, pin: string):
88
94
  if ((await snapshotDigest(snapshot)) !== pin) {
89
95
  throw new DekError(`${rel} is not the pack ${source.name} is pinned to`, {
90
96
  path,
91
- hint: `run \`dekc ref ${rel}\` to pin the pack that is there, or put back the one dek.toml pins`,
97
+ hint: `run \`dekc ref ${shellQuote(rel)}\` to pin the pack that is there, or put back the one dek.toml pins`,
92
98
  });
93
99
  }
94
100
  await writeSnapshot(root, source.name, pin, snapshot);
@@ -128,7 +134,10 @@ export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
128
134
  });
129
135
  }
130
136
  const project = requireProject(cwd);
137
+ refuseSharedFolders(project, source);
131
138
  const { pinned: from, dir, fetched } = refState(project, source.name);
139
+ // Said before a download that the write would refuse anyway.
140
+ refuseLinkUnderRefs(project.root, dir);
132
141
  const rev =
133
142
  source.rev !== undefined && isPinnedRev(source.rev)
134
143
  ? source.rev
@@ -141,7 +150,7 @@ export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
141
150
  changed = true;
142
151
  }
143
152
  if (from !== rev) {
144
- const toml = readTextIfExists(project.configPath) ?? "";
153
+ const toml = readSourceIfExists(project.configPath, project.root) ?? "";
145
154
  writeInside(project.configPath, setTableKey(toml, "refs", source.name, rev), project.root);
146
155
  changed = true;
147
156
  }
@@ -166,6 +175,72 @@ export async function addRef(cwd: string, arg: string): Promise<RefAddResult> {
166
175
  };
167
176
  }
168
177
 
178
+ /**
179
+ * Refuses a pin whose folders another pin, or a folder already on disk, takes. refs/ and packs/
180
+ * are named by the ref, and macOS and Windows read a name without its case, so `file/Talk` and
181
+ * `file/talk` would share one snapshot and one pack, and adding the second would replace the
182
+ * first's.
183
+ */
184
+ function refuseSharedFolders(
185
+ project: { root: string; configPath: string },
186
+ source: RefSource,
187
+ ): void {
188
+ const { name } = source;
189
+ const other = Object.keys(loadConfig(project.configPath).refs ?? {}).find(
190
+ (pinned) => pinned !== name && folded(pinned) === folded(name),
191
+ );
192
+ if (other !== undefined) {
193
+ throw new DekError(`${name} would share its folders with ${other}, which dek.toml pins`, {
194
+ path: project.configPath,
195
+ 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`,
196
+ });
197
+ }
198
+ const paths = [relative(project.root, refDir(project.root, name))];
199
+ if (source.kind === "file") {
200
+ paths.push(packFile(source.deck));
201
+ }
202
+ const left = paths
203
+ .map((path) => spelledOtherwise(project.root, path))
204
+ .find((path) => path !== undefined);
205
+ if (left !== undefined) {
206
+ const shown = isPlainWord(left) ? left : "another folder";
207
+ throw new DekError(
208
+ `${name} would share a folder with ${shown}, which is already in the project`,
209
+ {
210
+ path: join(project.root, left),
211
+ hint: "no ref in dek.toml pins it, so a ref removed by hand left it; delete it and add the ref again",
212
+ },
213
+ );
214
+ }
215
+ }
216
+
217
+ /** A name as a file system blind to case and form reads it: APFS folds ς as σ, and fi as fi. */
218
+ function folded(name: string): string {
219
+ return name.normalize("NFKC").toUpperCase().toLowerCase();
220
+ }
221
+
222
+ /**
223
+ * The path, under `root`, of a folder or file a file system blind to case and form would read as
224
+ * `path` though it is spelled otherwise, found segment by segment.
225
+ */
226
+ function spelledOtherwise(root: string, path: string): string | undefined {
227
+ let dir = root;
228
+ const walked: string[] = [];
229
+ for (const segment of path.split(sep)) {
230
+ const entries = existsSync(dir) && statSync(dir).isDirectory() ? readdirSync(dir) : [];
231
+ const other = entries.find((entry) => entry !== segment && folded(entry) === folded(segment));
232
+ if (other !== undefined) {
233
+ return join(...walked, other);
234
+ }
235
+ if (!entries.includes(segment)) {
236
+ return undefined;
237
+ }
238
+ walked.push(segment);
239
+ dir = join(dir, segment);
240
+ }
241
+ return undefined;
242
+ }
243
+
169
244
  /** An argument that ends in .tgz or .tar.gz is a pack's path, never a ref name. */
170
245
  function isPackPath(arg: string): boolean {
171
246
  return /\.(tgz|tar\.gz)$/i.test(arg);
@@ -187,9 +262,12 @@ async function addPack(cwd: string, arg: string): Promise<RefAddResult> {
187
262
  }
188
263
  const read = await readPackFile(given, undefined);
189
264
  const { source, bytes } = read;
265
+ refuseSharedFolders(project, source);
190
266
  const rev = await snapshotDigest(read);
191
267
  const { pinned: from, dir, fetched } = refState(project, source.name);
192
268
  const stored = join(project.root, packFile(source.deck));
269
+ // Before the first write: the copy below is written by name.
270
+ refuseLinkUnderPacks(project.root, source.deck);
193
271
 
194
272
  let changed = false;
195
273
  if (!(fetched && from === rev)) {
@@ -201,7 +279,7 @@ async function addPack(cwd: string, arg: string): Promise<RefAddResult> {
201
279
  changed = true;
202
280
  }
203
281
  if (from !== rev) {
204
- const toml = readTextIfExists(project.configPath) ?? "";
282
+ const toml = readSourceIfExists(project.configPath, project.root) ?? "";
205
283
  writeInside(project.configPath, setTableKey(toml, "refs", source.name, rev), project.root);
206
284
  changed = true;
207
285
  }
@@ -264,8 +342,13 @@ export function removeRef(cwd: string, arg: string): RefRmResult {
264
342
  hint: "run `dekc ref` to list refs",
265
343
  });
266
344
  }
345
+ // Before anything changes: the deletes below must not reach what a link there points to.
346
+ refuseLinkUnderRefs(project.root, dir);
347
+ if (source.kind === "file") {
348
+ refuseLinkUnderPacks(project.root, source.deck);
349
+ }
267
350
  if (pinned !== undefined) {
268
- const toml = readFileSync(project.configPath, "utf8");
351
+ const toml = readSourceIfExists(project.configPath, project.root) ?? "";
269
352
  writeInside(
270
353
  project.configPath,
271
354
  setTableKey(toml, "refs", source.name, undefined),
package/src/cli/result.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { Diagnostic, SkippedCheck } from "../core/diagnostic.ts";
2
2
  import { DekError } from "../core/error.ts";
3
+ import { revealHidden } from "../core/foreign-text.ts";
3
4
  import { PLAYWRIGHT_INSTALL, type PlaywrightRunner, playwrightReady } from "../core/playwright.ts";
4
5
  import { addressHint, deckAround, type HintDeck } from "./address.ts";
5
6
  import { type CliResult, outputOf } from "./commands.ts";
7
+ import type { FlagValues } from "./flags.ts";
6
8
  import { displayPath, formatError, formatErrorText } from "./format.ts";
7
- import { shouldColor, terminalSafe } from "./tty.ts";
9
+ import { plainText, shouldColor, terminalSafe } from "./tty.ts";
8
10
 
9
11
  export type { CliResult, SkippedCheck };
10
12
 
@@ -13,15 +15,18 @@ export function skippedChecks(skipped: SkippedCheck[]): { skipped?: SkippedCheck
13
15
  return skipped.length > 0 ? { skipped } : {};
14
16
  }
15
17
 
18
+ /** What `--visual` measures in a browser, as a hint or a reason names it. */
19
+ export const RENDERED_RULES = "overflow, contrast, line breaks, and alignment";
20
+
16
21
  /**
17
- * The overflow and contrast rules, when a run leaves them out: why, and `rerun`, the command that
22
+ * The rules a browser measures, when a run leaves them out: why, and `rerun`, the command that
18
23
  * measures them. Without Playwright, installing it comes first, whatever the reason was.
19
24
  */
20
25
  export function visualSkipped(
21
26
  rerun: string,
22
27
  options: { reason: string; runner?: PlaywrightRunner },
23
28
  ): SkippedCheck {
24
- const measure = `run \`${rerun}\` to measure overflow and contrast`;
29
+ const measure = `run \`${rerun}\` to measure ${RENDERED_RULES}`;
25
30
  return playwrightReady(options.runner)
26
31
  ? { check: "visual", reason: options.reason, hint: measure }
27
32
  : {
@@ -38,6 +43,8 @@ export type WriteSuccessOptions = {
38
43
  cwd?: string;
39
44
  /** The one deck the command worked on, which hints that name no path are about. */
40
45
  deck?: HintDeck;
46
+ /** The flags it ran with, which a failure's rerun repeats where they change what is checked. */
47
+ flags?: FlagValues;
41
48
  };
42
49
 
43
50
  /** Where a run's hints are read: the directory it ran in, and the deck it worked on, if one. */
@@ -83,7 +90,7 @@ export function writeSuccess(original: CliResult, options: WriteSuccessOptions):
83
90
  const { cwd } = options;
84
91
  const addressed = cwd === undefined ? original : addressResult(original, { cwd, ...options });
85
92
  const result = cwd === undefined ? addressed : displayPaths(addressed, cwd);
86
- const failed = output.failure?.(result.data);
93
+ const failed = output.failure?.(result.data, options.flags ?? {});
87
94
  const failure =
88
95
  failed?.hint === undefined || cwd === undefined
89
96
  ? failed
@@ -95,9 +102,13 @@ export function writeSuccess(original: CliResult, options: WriteSuccessOptions):
95
102
  const data = output.json ? output.json(result.data) : (result.data as object);
96
103
  process.stdout.write(`${JSON.stringify({ ...envelope, ...data })}\n`);
97
104
  } else {
98
- const text = formatText(result, shouldColor(process.stdout));
105
+ const plain = {
106
+ ...result,
107
+ data: plainStrings(result.data, isAboutRef(result.data)),
108
+ } as CliResult;
109
+ const text = formatText(plain, shouldColor(process.stdout));
99
110
  process.stdout.write(`${terminalSafe(text)}\n`);
100
- const notes = output.notes?.(result.data, shouldColor(process.stderr));
111
+ const notes = output.notes?.(plain.data, shouldColor(process.stderr));
101
112
  if (notes) {
102
113
  process.stderr.write(`${terminalSafe(notes)}\n`);
103
114
  }
@@ -123,6 +134,36 @@ export function displayPaths(result: CliResult, cwd: string): CliResult {
123
134
  return { ...result, data: paths(result.data, (path) => displayPath(path, cwd)) } as CliResult;
124
135
  }
125
136
 
137
+ /** A result that read a ref, as `ls`, `show`, and `theme` mark theirs. */
138
+ function isAboutRef(data: unknown): boolean {
139
+ return typeof data === "object" && data !== null && "ref" in data && data.ref !== undefined;
140
+ }
141
+
142
+ /**
143
+ * Every string in a result as plainText, so the text a deck or a ref wrote reaches the terminal
144
+ * with no escape of its own; the text formatters paint dek's colors after. In a result about a
145
+ * ref, each character a person cannot see shows as `⟨U+XXXX⟩` too.
146
+ */
147
+ function plainStrings<T>(value: T, reveal: boolean): T {
148
+ if (typeof value === "string") {
149
+ const plain = plainText(value);
150
+ return (reveal ? revealHidden(plain) : plain) as T;
151
+ }
152
+ if (Array.isArray(value)) {
153
+ return value.map((entry) => plainStrings(entry, reveal)) as T;
154
+ }
155
+ if (
156
+ value !== null &&
157
+ typeof value === "object" &&
158
+ Object.getPrototypeOf(value) === Object.prototype
159
+ ) {
160
+ return Object.fromEntries(
161
+ Object.entries(value).map(([key, entry]) => [key, plainStrings(entry, reveal)]),
162
+ ) as T;
163
+ }
164
+ return value;
165
+ }
166
+
126
167
  /** A result as text, without what did not run; see writeSuccess. */
127
168
  export function formatText(result: CliResult, color = false): string {
128
169
  return outputOf(result.command).text(result.data, color);