@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.
- package/CHANGELOG.md +136 -0
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/package.json +5 -3
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +95 -18
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +57 -19
- package/src/cli/contract.ts +58 -4
- package/src/cli/files.ts +2 -5
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +5 -4
- package/src/cli/goto.ts +44 -11
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +36 -16
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +51 -14
- package/src/cli/new.ts +28 -8
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +92 -9
- package/src/cli/result.ts +47 -6
- package/src/cli/scope.ts +57 -29
- package/src/cli/shot.ts +78 -6
- package/src/cli/show.ts +15 -2
- package/src/cli/text.ts +88 -11
- package/src/cli/theme.ts +58 -10
- package/src/cli/tty.ts +16 -2
- package/src/cli/voice.ts +15 -9
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- package/src/core/assets.ts +18 -10
- package/src/core/attribute-value.ts +188 -0
- package/src/core/build.ts +3 -3
- package/src/core/config.ts +61 -12
- package/src/core/css-scan.ts +173 -39
- package/src/core/css-transform.ts +57 -11
- package/src/core/css.ts +19 -13
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/foreign-text.ts +138 -0
- package/src/core/github.ts +1 -0
- package/src/core/html-entities.ts +318 -0
- package/src/core/html-scan.ts +49 -27
- package/src/core/html.ts +21 -15
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +50 -8
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +24 -19
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +57 -27
- package/src/core/order.ts +14 -0
- package/src/core/pack.ts +2 -2
- package/src/core/path.ts +8 -2
- package/src/core/playwright-module.d.ts +13 -1
- package/src/core/playwright-motion.ts +1 -0
- package/src/core/playwright-sheet.ts +1 -0
- package/src/core/playwright-visual.ts +207 -30
- package/src/core/playwright.ts +279 -44
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +56 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +52 -8
- package/src/core/ref.ts +308 -54
- package/src/core/resolve.ts +23 -11
- package/src/core/rumdl.ts +51 -25
- package/src/core/safe-fs.ts +19 -2
- package/src/core/sandbox.ts +217 -0
- package/src/core/shot/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +26 -17
- package/src/core/slide-eval-worker.ts +1 -0
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +12 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/spawn.ts +131 -32
- package/src/core/sync.ts +5 -14
- package/src/core/tar.ts +315 -0
- package/src/core/text-contrast.ts +2 -0
- package/src/core/toml-keys.ts +116 -51
- package/src/core/url-attributes.ts +27 -9
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +182 -24
- package/src/core/voice.ts +5 -6
- package/src/core/zod.ts +9 -1
- package/src/server/auth.ts +21 -0
- package/src/server/dev.ts +26 -7
- package/src/server/lock.ts +50 -7
- package/src/server/routes.ts +7 -2
- package/src/server/serial.ts +8 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
- package/src/video/mux.ts +27 -3
- package/src/video/recorder.ts +3 -2
- package/src/video/worker.ts +1 -0
- 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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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 {
|
|
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
|
|
48
|
-
?
|
|
61
|
+
const fromDeck = fromRef
|
|
62
|
+
? fromRef.deck
|
|
49
63
|
: options.themeFrom
|
|
50
|
-
?
|
|
51
|
-
:
|
|
52
|
-
|
|
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) : ""}${
|
|
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
|
-
/**
|
|
5
|
-
|
|
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
|
-
|
|
2
|
-
import {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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?.(
|
|
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);
|