@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.
- package/CHANGELOG.md +90 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +2 -1
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +84 -14
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +54 -13
- package/src/cli/contract.ts +18 -1
- package/src/cli/files.ts +9 -6
- package/src/cli/flags.ts +1 -1
- package/src/cli/goto.ts +3 -1
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +4 -3
- package/src/cli/main.ts +7 -5
- package/src/cli/new.ts +41 -10
- package/src/cli/pack.ts +21 -0
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +243 -9
- package/src/cli/result.ts +5 -2
- package/src/cli/scope.ts +25 -27
- package/src/cli/shot.ts +2 -1
- package/src/cli/text.ts +45 -4
- package/src/cli/theme.ts +16 -2
- package/src/cli/voice.ts +4 -3
- 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/config-keys.ts +6 -2
- package/src/core/config.ts +88 -12
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/licenses/CC-BY-4.0.txt +396 -0
- package/src/core/licenses/CC0-1.0.txt +121 -0
- package/src/core/licenses.ts +20 -0
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +6 -2
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +3 -1
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +30 -10
- package/src/core/pack.ts +137 -0
- package/src/core/playwright-module.d.ts +1 -0
- package/src/core/playwright-visual.ts +180 -20
- package/src/core/playwright.ts +202 -25
- 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 +55 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +100 -17
- package/src/core/ref.ts +318 -34
- package/src/core/resolve.ts +20 -9
- 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 +25 -17
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +10 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/sync.ts +3 -3
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +154 -15
- package/src/core/voice.ts +3 -5
- package/src/core/zod.ts +9 -1
- package/src/runtime/annotate-view.ts +4 -2
- package/src/server/dev.ts +15 -4
- package/src/server/lock.ts +48 -5
- package/src/server/routes.ts +7 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
package/src/core/ref.ts
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { loadConfig } from "./config.ts";
|
|
6
6
|
import { DekError } from "./error.ts";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { shellQuote } from "./escape.ts";
|
|
8
|
+
import { listShown } from "./prose.ts";
|
|
9
|
+
import {
|
|
10
|
+
isPlainRefName,
|
|
11
|
+
isRefDeckName,
|
|
12
|
+
packFile,
|
|
13
|
+
parseRefSource,
|
|
14
|
+
REF_DECK_NAME_RULE,
|
|
15
|
+
type RefSource,
|
|
16
|
+
} from "./ref-name.ts";
|
|
17
|
+
import { listSlides, type Project, resolveProject } from "./resolve.ts";
|
|
18
|
+
import { followInside, outputDir } from "./safe-fs.ts";
|
|
10
19
|
|
|
11
20
|
export {
|
|
12
21
|
isPinnedRev,
|
|
13
22
|
isRefName,
|
|
23
|
+
packFile,
|
|
14
24
|
parseRefSource,
|
|
15
25
|
type RefSource,
|
|
16
26
|
} from "./ref-name.ts";
|
|
@@ -23,6 +33,49 @@ function isLink(path: string): boolean {
|
|
|
23
33
|
}
|
|
24
34
|
}
|
|
25
35
|
|
|
36
|
+
/** The first link on the way from `<root>/<top>` down to `path`, both included, if any. */
|
|
37
|
+
function linkUnder(root: string, top: "refs" | "packs", path: string): string | undefined {
|
|
38
|
+
let at = join(root, top);
|
|
39
|
+
for (const segment of ["", ...relative(at, path).split(sep)]) {
|
|
40
|
+
at = join(at, segment);
|
|
41
|
+
if (isLink(at)) {
|
|
42
|
+
return at;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Refuses a link on the way to the snapshot at `dir`. refs/ is dek's own folder, which a cloned
|
|
50
|
+
* project can still commit: a link there, even one that stays in the project, would aim a
|
|
51
|
+
* snapshot's swap and its delete at what it points to, such as a deck under decks/.
|
|
52
|
+
*/
|
|
53
|
+
export function refuseLinkUnderRefs(root: string, dir: string): void {
|
|
54
|
+
const link = linkUnder(root, "refs", dir);
|
|
55
|
+
if (link !== undefined) {
|
|
56
|
+
const rel = relative(root, link);
|
|
57
|
+
throw new DekError(`${rel} is a symlink; dek follows no link under refs/`, {
|
|
58
|
+
path: link,
|
|
59
|
+
hint: `remove ${rel}: refs/ holds only the snapshots \`dekc ref\` writes, and the next read of a pinned ref fetches it again`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Refuses a link at packs/, or at the copy of `deck`'s pack kept there. dek writes and removes
|
|
66
|
+
* that copy by name, so through a link it would overwrite, or delete, what the link points to.
|
|
67
|
+
*/
|
|
68
|
+
export function refuseLinkUnderPacks(root: string, deck: string): void {
|
|
69
|
+
const link = linkUnder(root, "packs", join(root, packFile(deck)));
|
|
70
|
+
if (link !== undefined) {
|
|
71
|
+
const rel = relative(root, link);
|
|
72
|
+
throw new DekError(`${rel} is a symlink; dek follows no link under packs/`, {
|
|
73
|
+
path: link,
|
|
74
|
+
hint: `remove ${rel}, then run \`dekc ref <file>.tgz\` with the pack you were sent: packs/ holds the copies it keeps, as plain files`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
26
79
|
/** Written into each snapshot; its presence is what makes a directory a ref. */
|
|
27
80
|
export const REF_MARKER = ".ref.json";
|
|
28
81
|
|
|
@@ -35,14 +88,18 @@ export const RefMeta = z.object({
|
|
|
35
88
|
export type RefMeta = z.infer<typeof RefMeta>;
|
|
36
89
|
|
|
37
90
|
export function refDir(root: string, name: string): string {
|
|
38
|
-
const
|
|
39
|
-
return
|
|
91
|
+
const source = parseRefSource(name);
|
|
92
|
+
return source.kind === "file"
|
|
93
|
+
? join(root, "refs", "file", source.deck)
|
|
94
|
+
: join(root, "refs", source.owner, source.repo, source.deck);
|
|
40
95
|
}
|
|
41
96
|
|
|
42
97
|
/** The marker of a snapshot, or nothing when it is missing or not one dek wrote. */
|
|
43
98
|
export function readRefMeta(dir: string): RefMeta | undefined {
|
|
44
|
-
const
|
|
45
|
-
|
|
99
|
+
const marker = join(dir, REF_MARKER);
|
|
100
|
+
// dek writes the marker as a plain file; a link out of the snapshot is none of its.
|
|
101
|
+
const path = existsSync(marker) ? followInside(marker, dir) : undefined;
|
|
102
|
+
if (path === undefined) {
|
|
46
103
|
return undefined;
|
|
47
104
|
}
|
|
48
105
|
try {
|
|
@@ -53,7 +110,88 @@ export function readRefMeta(dir: string): RefMeta | undefined {
|
|
|
53
110
|
}
|
|
54
111
|
}
|
|
55
112
|
|
|
56
|
-
|
|
113
|
+
/**
|
|
114
|
+
* The snapshot `dir` is in, and the ref it holds: the outermost folder from `dir` up that is one
|
|
115
|
+
* dek wrote. A ref may carry a dek.toml, and a marker, of its own in its deck's assets; neither
|
|
116
|
+
* hides the snapshot around them or names it. A marker anywhere else makes nothing a ref.
|
|
117
|
+
*/
|
|
118
|
+
function refAround(dir: string): { dir: string; name: string } | undefined {
|
|
119
|
+
let found: { dir: string; name: string } | undefined;
|
|
120
|
+
for (let at = resolve(dir); ; at = dirname(at)) {
|
|
121
|
+
const name = snapshotName(at);
|
|
122
|
+
if (name !== undefined) {
|
|
123
|
+
found = { dir: at, name };
|
|
124
|
+
}
|
|
125
|
+
if (dirname(at) === at) {
|
|
126
|
+
return found;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The ref `dir` holds, when it is a snapshot as dek writes one: a dek.toml beside a marker that
|
|
133
|
+
* names a ref, at that ref's own place under a project's refs/.
|
|
134
|
+
*/
|
|
135
|
+
function snapshotName(dir: string): string | undefined {
|
|
136
|
+
if (!isThere(join(dir, "dek.toml"))) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const name = readRefMeta(dir)?.name;
|
|
140
|
+
if (name === undefined || !isPlainRefName(name)) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
// The same folder, not the same spelling: a disk that ignores case, or a link at refs/, gives
|
|
144
|
+
// the path to a snapshot another spelling than its name.
|
|
145
|
+
const place = refDir("", name);
|
|
146
|
+
const project = resolve(dir, ...place.split(sep).map(() => ".."));
|
|
147
|
+
return isSameFolder(join(project, place), dir) ? name : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isSameFolder(a: string, b: string): boolean {
|
|
151
|
+
try {
|
|
152
|
+
const [one, other] = [statSync(a), statSync(b)];
|
|
153
|
+
return one.dev === other.dev && one.ino === other.ino;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isThere(path: string): boolean {
|
|
160
|
+
try {
|
|
161
|
+
lstatSync(path);
|
|
162
|
+
return true;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Refuses `dir` when it is inside a ref: no command, the dev server's included, runs or writes there. */
|
|
169
|
+
export function refuseInsideRef(dir: string): void {
|
|
170
|
+
const ref = refAround(dir);
|
|
171
|
+
if (ref !== undefined) {
|
|
172
|
+
throw new DekError("this directory is inside a ref; refs are read-only", {
|
|
173
|
+
path: ref.dir,
|
|
174
|
+
hint: `run dek from your own project instead: \`dekc show ${ref.name} <slug>\``,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The project around `cwd`, which is the user's own: never one inside a ref. */
|
|
180
|
+
export function requireProject(cwd: string): Project {
|
|
181
|
+
const project = resolveProject(cwd);
|
|
182
|
+
refuseInsideRef(project.root);
|
|
183
|
+
return project;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** What a command that writes says when it is handed a ref by name. */
|
|
187
|
+
export function refReadOnlyError(name: string): DekError {
|
|
188
|
+
const ref = shellQuote(name);
|
|
189
|
+
return new DekError(`"${name}" is a ref; refs are read-only`, {
|
|
190
|
+
hint: `start a deck on its theme with \`dekc new <name> --theme-from ${ref}\`, or read it with \`dekc show ${ref} <slug>\` and write what you need in your own deck's theme`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
|
|
57
195
|
|
|
58
196
|
/** The snapshot's license file, relative to the snapshot, when the source had one. */
|
|
59
197
|
export function refLicense(dir: string): string | null {
|
|
@@ -78,7 +216,11 @@ export function refState(project: { root: string; configPath: string }, name: st
|
|
|
78
216
|
name,
|
|
79
217
|
...(pinned !== undefined ? { pinned } : {}),
|
|
80
218
|
dir,
|
|
81
|
-
|
|
219
|
+
// Never through a link under refs/: one is fetched again, which refuses the link.
|
|
220
|
+
fetched:
|
|
221
|
+
pinned !== undefined &&
|
|
222
|
+
linkUnder(project.root, "refs", dir) === undefined &&
|
|
223
|
+
readRefMeta(dir)?.rev === pinned,
|
|
82
224
|
};
|
|
83
225
|
}
|
|
84
226
|
|
|
@@ -86,7 +228,12 @@ export function refState(project: { root: string; configPath: string }, name: st
|
|
|
86
228
|
export type RepoFiles = Map<string, Blob>;
|
|
87
229
|
|
|
88
230
|
/** Beyond this, a snapshot is refused: a deck to read is a few hundred kilobytes. */
|
|
89
|
-
const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024;
|
|
231
|
+
export const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024;
|
|
232
|
+
|
|
233
|
+
/** A pack's tar, unzipped, may hold its files and their headers, not much more. */
|
|
234
|
+
const MAX_PACK_TAR_BYTES = 2 * MAX_SNAPSHOT_BYTES;
|
|
235
|
+
|
|
236
|
+
const PACK_HINT = "ask for a pack its author made with `dekc pack <deck>`";
|
|
90
237
|
|
|
91
238
|
/**
|
|
92
239
|
* Reads a GitHub tarball. Bun leaves out links; a path that is absolute, has
|
|
@@ -108,6 +255,64 @@ export async function readTarball(bytes: Uint8Array): Promise<RepoFiles> {
|
|
|
108
255
|
return files;
|
|
109
256
|
}
|
|
110
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Reads a pack from someone else. It is gunzipped through a count that stops at `maxTarBytes`
|
|
260
|
+
* before Bun takes any of it apart, so a small file that inflates without end is refused rather
|
|
261
|
+
* than held in memory; the tar is then read as a GitHub tarball is.
|
|
262
|
+
*/
|
|
263
|
+
export async function readPack(
|
|
264
|
+
bytes: Uint8Array,
|
|
265
|
+
limits: { maxBytes?: number; maxTarBytes?: number } = {},
|
|
266
|
+
): Promise<RepoFiles> {
|
|
267
|
+
const maxBytes = limits.maxBytes ?? MAX_SNAPSHOT_BYTES;
|
|
268
|
+
const maxTarBytes = limits.maxTarBytes ?? MAX_PACK_TAR_BYTES;
|
|
269
|
+
if (bytes.byteLength > maxBytes) {
|
|
270
|
+
throw new DekError(`the pack is larger than ${formatMb(maxBytes)}`, { hint: PACK_HINT });
|
|
271
|
+
}
|
|
272
|
+
const chunks: Uint8Array[] = [];
|
|
273
|
+
let size = 0;
|
|
274
|
+
try {
|
|
275
|
+
const reader = new Blob([new Uint8Array(bytes)])
|
|
276
|
+
.stream()
|
|
277
|
+
.pipeThrough(new DecompressionStream("gzip"))
|
|
278
|
+
.getReader();
|
|
279
|
+
for (;;) {
|
|
280
|
+
const { done, value } = await reader.read();
|
|
281
|
+
if (done) {
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
size += value.byteLength;
|
|
285
|
+
if (size > maxTarBytes) {
|
|
286
|
+
await reader.cancel();
|
|
287
|
+
throw new DekError(`the pack unpacks to more than ${formatMb(maxTarBytes)}`, {
|
|
288
|
+
hint: PACK_HINT,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
chunks.push(value);
|
|
292
|
+
}
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (error instanceof DekError) {
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
throw new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT });
|
|
298
|
+
}
|
|
299
|
+
const tar = new Uint8Array(size);
|
|
300
|
+
let offset = 0;
|
|
301
|
+
for (const chunk of chunks) {
|
|
302
|
+
tar.set(chunk, offset);
|
|
303
|
+
offset += chunk.byteLength;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
return await readTarball(tar);
|
|
307
|
+
} catch {
|
|
308
|
+
throw new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function formatMb(bytes: number): string {
|
|
313
|
+
return bytes >= 1024 * 1024 ? `${bytes / 1024 / 1024} MB` : `${bytes} bytes`;
|
|
314
|
+
}
|
|
315
|
+
|
|
111
316
|
const DECK_SCRIPT_RE = /^(?:(.*)\/)?decks\/([^/]+)\/script\.md$/;
|
|
112
317
|
|
|
113
318
|
/** The decks in a repository: each `decks/<name>/script.md` beside its project's dek.toml. */
|
|
@@ -119,14 +324,46 @@ function repoDecks(files: RepoFiles): Array<{ name: string; prefix: string }> {
|
|
|
119
324
|
});
|
|
120
325
|
}
|
|
121
326
|
|
|
327
|
+
/** Deck names as a hint lists them: only those a ref can carry. */
|
|
328
|
+
function listDecks(names: string[]): string {
|
|
329
|
+
return listShown(
|
|
330
|
+
names,
|
|
331
|
+
isRefDeckName,
|
|
332
|
+
(count) => `${count} whose ${count === 1 ? "name" : "names"} a ref cannot carry`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The name of the one deck a pack holds; a pack of none, of more, or of one no ref can name is refused. */
|
|
337
|
+
export function deckOfPack(files: RepoFiles): string {
|
|
338
|
+
const names = [...new Set(repoDecks(files).map((deck) => deck.name))].sort();
|
|
339
|
+
const [name, ...others] = names;
|
|
340
|
+
if (name === undefined) {
|
|
341
|
+
throw new DekError("the pack holds no deck", {
|
|
342
|
+
hint: `a pack is one deck beside a dek.toml; ${PACK_HINT}`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (others.length > 0) {
|
|
346
|
+
throw new DekError(`the pack holds more than one deck: ${listDecks(names)}`, {
|
|
347
|
+
hint: `a pack is one deck; ${PACK_HINT}`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
if (!isRefDeckName(name)) {
|
|
351
|
+
throw new DekError("the pack's deck has a name a ref cannot carry", {
|
|
352
|
+
hint: `${REF_DECK_NAME_RULE}; ask its author to rename the deck's folder and pack it again`,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return name;
|
|
356
|
+
}
|
|
357
|
+
|
|
122
358
|
/** The directory of the project that holds the deck, as a prefix: "" or "sample/". */
|
|
123
359
|
export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
|
|
124
|
-
const repo = `${source.owner}/${source.repo}`;
|
|
360
|
+
const repo = source.kind === "file" ? "the pack" : `${source.owner}/${source.repo}`;
|
|
361
|
+
const path = source.kind === "github" ? source.path : undefined;
|
|
125
362
|
const decks = repoDecks(files);
|
|
126
363
|
const matches = decks.filter(
|
|
127
364
|
(deck) =>
|
|
128
365
|
deck.name === source.deck &&
|
|
129
|
-
(
|
|
366
|
+
(path === undefined || `${deck.prefix}decks/${deck.name}` === path),
|
|
130
367
|
);
|
|
131
368
|
const [match, ...others] = matches;
|
|
132
369
|
if (!match) {
|
|
@@ -134,21 +371,26 @@ export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
|
|
|
134
371
|
throw new DekError(`${repo} has no deck "${source.deck}"`, {
|
|
135
372
|
hint:
|
|
136
373
|
names.length > 0
|
|
137
|
-
? `decks in ${repo}: ${names
|
|
374
|
+
? `decks in ${repo}: ${listDecks(names)}`
|
|
138
375
|
: `${repo} holds no dek project (decks/<name>/script.md beside a dek.toml)`,
|
|
139
376
|
});
|
|
140
377
|
}
|
|
141
378
|
if (others.length > 0) {
|
|
142
379
|
const paths = matches.map((deck) => `${deck.prefix}decks/${deck.name}`).sort();
|
|
380
|
+
const listed = listShown(
|
|
381
|
+
paths,
|
|
382
|
+
(path) => path.split("/").every(isRefDeckName),
|
|
383
|
+
(count) => `${count} whose ${count === 1 ? "path" : "paths"} a hint cannot show`,
|
|
384
|
+
);
|
|
143
385
|
throw new DekError(`${repo} has more than one deck named "${source.deck}"`, {
|
|
144
|
-
hint: `pass the GitHub link to the one you mean; they are at ${
|
|
386
|
+
hint: `pass the GitHub link to the one you mean; they are at ${listed}`,
|
|
145
387
|
});
|
|
146
388
|
}
|
|
147
389
|
return match.prefix;
|
|
148
390
|
}
|
|
149
391
|
|
|
150
392
|
/** Inside the deck, only what reading it needs: not dist/, .cache/, or voice/. */
|
|
151
|
-
function keepsInDeck(path: string): boolean {
|
|
393
|
+
export function keepsInDeck(path: string): boolean {
|
|
152
394
|
return (
|
|
153
395
|
path === "script.md" ||
|
|
154
396
|
path === "theme.css" ||
|
|
@@ -157,17 +399,19 @@ function keepsInDeck(path: string): boolean {
|
|
|
157
399
|
);
|
|
158
400
|
}
|
|
159
401
|
|
|
402
|
+
/** The files a snapshot keeps, by their paths in it, and where the deck was in its source. */
|
|
403
|
+
export type Snapshot = {
|
|
404
|
+
files: Map<string, Blob>;
|
|
405
|
+
license: string | undefined;
|
|
406
|
+
/** The deck's directory in the source repository or pack. */
|
|
407
|
+
path: string;
|
|
408
|
+
};
|
|
409
|
+
|
|
160
410
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* beside the old one and swapped in, so a failure leaves the old one whole.
|
|
411
|
+
* What a snapshot keeps of a repository or a pack: its project's dek.toml, the deck's files that
|
|
412
|
+
* reading needs, and its license, laid out as the project it came from.
|
|
164
413
|
*/
|
|
165
|
-
export
|
|
166
|
-
root: string,
|
|
167
|
-
source: RefSource,
|
|
168
|
-
sha: string,
|
|
169
|
-
files: RepoFiles,
|
|
170
|
-
): Promise<{ dir: string; license: string | null; path: string }> {
|
|
414
|
+
export function pickSnapshot(files: RepoFiles, source: RefSource): Snapshot {
|
|
171
415
|
const prefix = findDeckInRepo(files, source);
|
|
172
416
|
const deckPrefix = `${prefix}decks/${source.deck}/`;
|
|
173
417
|
const license =
|
|
@@ -190,20 +434,60 @@ export async function installSnapshot(
|
|
|
190
434
|
hint: "a deck to read as a model should be far smaller; check its assets",
|
|
191
435
|
});
|
|
192
436
|
}
|
|
437
|
+
return { files: picked, license, path: `${prefix}decks/${source.deck}` };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* What a pack's ref is pinned to: the sha256 of every file the snapshot keeps, in byte order of
|
|
442
|
+
* their paths, each as its path, a NUL, its size in decimal digits, a NUL, and its bytes. It
|
|
443
|
+
* follows the files, not the tarball, whose headers carry the time it was made.
|
|
444
|
+
*/
|
|
445
|
+
export async function snapshotDigest(snapshot: Snapshot): Promise<string> {
|
|
446
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
447
|
+
const encoder = new TextEncoder();
|
|
448
|
+
const paths = [...snapshot.files.keys()].sort((a, b) =>
|
|
449
|
+
Buffer.compare(encoder.encode(a), encoder.encode(b)),
|
|
450
|
+
);
|
|
451
|
+
for (const path of paths) {
|
|
452
|
+
const blob = snapshot.files.get(path) ?? new Blob([]);
|
|
453
|
+
hasher.update(`${path}\0${blob.size}\0`);
|
|
454
|
+
hasher.update(new Uint8Array(await blob.arrayBuffer()));
|
|
455
|
+
}
|
|
456
|
+
return hasher.digest("hex");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Writes the snapshot of one deck at one commit under refs/; see writeSnapshot. */
|
|
460
|
+
export async function installSnapshot(
|
|
461
|
+
root: string,
|
|
462
|
+
source: RefSource,
|
|
463
|
+
sha: string,
|
|
464
|
+
files: RepoFiles,
|
|
465
|
+
): Promise<{ dir: string; license: string | null; path: string }> {
|
|
466
|
+
return writeSnapshot(root, source.name, sha, pickSnapshot(files, source));
|
|
467
|
+
}
|
|
193
468
|
|
|
194
|
-
|
|
469
|
+
/**
|
|
470
|
+
* Writes a snapshot under refs/, laid out as the project it came from so it resolves like any
|
|
471
|
+
* dek project, and marks it with what it is pinned to. It is written beside the old one and
|
|
472
|
+
* swapped in, so a failure leaves the old one whole.
|
|
473
|
+
*/
|
|
474
|
+
export async function writeSnapshot(
|
|
475
|
+
root: string,
|
|
476
|
+
name: string,
|
|
477
|
+
pin: string,
|
|
478
|
+
snapshot: Snapshot,
|
|
479
|
+
): Promise<{ dir: string; license: string | null; path: string }> {
|
|
195
480
|
const refsDir = join(root, "refs");
|
|
196
|
-
const dir = refDir(root,
|
|
481
|
+
const dir = refDir(root, name);
|
|
482
|
+
// A link on the way would aim the swap below, and its delete, at what it points to.
|
|
483
|
+
refuseLinkUnderRefs(root, dir);
|
|
197
484
|
outputDir(dirname(dir), root);
|
|
198
|
-
if (isLink(dir)) {
|
|
199
|
-
await rm(dir);
|
|
200
|
-
}
|
|
201
485
|
const suffix = `${process.pid}-${Date.now().toString(36)}`;
|
|
202
486
|
const temp = join(refsDir, `.tmp-${suffix}`);
|
|
203
487
|
const old = join(refsDir, `.old-${suffix}`);
|
|
204
|
-
const meta: RefMeta = { name
|
|
488
|
+
const meta: RefMeta = { name, rev: pin, path: snapshot.path };
|
|
205
489
|
try {
|
|
206
|
-
for (const [path, blob] of
|
|
490
|
+
for (const [path, blob] of snapshot.files) {
|
|
207
491
|
await mkdir(dirname(join(temp, path)), { recursive: true });
|
|
208
492
|
await writeFile(join(temp, path), new Uint8Array(await blob.arrayBuffer()));
|
|
209
493
|
}
|
|
@@ -224,7 +508,7 @@ export async function installSnapshot(
|
|
|
224
508
|
await rm(old, { recursive: true, force: true });
|
|
225
509
|
}
|
|
226
510
|
}
|
|
227
|
-
return { dir, license: license ?? null, path: meta.path };
|
|
511
|
+
return { dir, license: snapshot.license ?? null, path: meta.path };
|
|
228
512
|
}
|
|
229
513
|
|
|
230
514
|
/** The snapshot deck's title and slide count, or nothing when it does not parse. */
|
package/src/core/resolve.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { existsSync, readdirSync,
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { deckPaths } from "./deck-paths.ts";
|
|
4
4
|
import { DekError } from "./error.ts";
|
|
5
5
|
import { walkUp } from "./optional.ts";
|
|
6
6
|
import { parseScript } from "./parse.ts";
|
|
7
|
-
import { deckProjectRoot } from "./path.ts";
|
|
7
|
+
import { deckProjectRoot, isInside } from "./path.ts";
|
|
8
8
|
import { readSourceIfExists, requireInside } from "./safe-fs.ts";
|
|
9
9
|
import type { Deck, Section } from "./schema.ts";
|
|
10
10
|
import { suggest } from "./suggest.ts";
|
|
@@ -14,6 +14,11 @@ export type ProjectDeck = {
|
|
|
14
14
|
dir: string;
|
|
15
15
|
scriptPath: string;
|
|
16
16
|
deck: Deck;
|
|
17
|
+
/**
|
|
18
|
+
* Set on a ref's deck: the folder that stands for its `.cache`, in the project that reads it,
|
|
19
|
+
* and that project's root. A snapshot is never written once `dekc ref` wrote it.
|
|
20
|
+
*/
|
|
21
|
+
cache?: { dir: string; root: string };
|
|
17
22
|
};
|
|
18
23
|
|
|
19
24
|
export type Project = {
|
|
@@ -28,11 +33,6 @@ export type ResolvedDeck = {
|
|
|
28
33
|
deck: ProjectDeck;
|
|
29
34
|
};
|
|
30
35
|
|
|
31
|
-
/** The file's text, or nothing when there is no such file. */
|
|
32
|
-
export function readTextIfExists(path: string): string | undefined {
|
|
33
|
-
return existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
36
|
/**
|
|
37
37
|
* A file of the deck's, or nothing when there is none. It may be a symlink to elsewhere in the
|
|
38
38
|
* project, but not out of it: what a deck reads, a build publishes.
|
|
@@ -150,14 +150,25 @@ function loadDecks(root: string): {
|
|
|
150
150
|
if (!existsSync(decksDir)) {
|
|
151
151
|
return { decks: [], failed: [] };
|
|
152
152
|
}
|
|
153
|
-
|
|
153
|
+
// A link out of the project would list, serve, and watch the folders of wherever it leads. It
|
|
154
|
+
// may lead elsewhere in the project, but not into a hidden folder; a folder has no kind to keep.
|
|
155
|
+
const realRoot = realpathSync(root);
|
|
156
|
+
const realDecks = realpathSync(decksDir);
|
|
157
|
+
const rel = relative(realRoot, realDecks);
|
|
158
|
+
if (!isInside(realDecks, realRoot) || rel.split(sep).some((part) => part.startsWith("."))) {
|
|
159
|
+
throw new DekError("decks leads outside the project", {
|
|
160
|
+
path: decksDir,
|
|
161
|
+
hint: "dek follows decks/ only to a folder inside the project and outside hidden folders; move the decks into the project instead",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (!statSync(realDecks).isDirectory()) {
|
|
154
165
|
throw new DekError("decks is not a directory", { path: decksDir });
|
|
155
166
|
}
|
|
156
167
|
|
|
157
168
|
const decks: ProjectDeck[] = [];
|
|
158
169
|
const failed: Project["failed"] = [];
|
|
159
170
|
|
|
160
|
-
for (const entry of readdirSync(
|
|
171
|
+
for (const entry of readdirSync(realDecks, { withFileTypes: true })
|
|
161
172
|
.filter((item) => item.isDirectory())
|
|
162
173
|
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
163
174
|
const loaded = readDeck(root, entry.name);
|
package/src/core/shot/morph.ts
CHANGED
|
@@ -62,7 +62,7 @@ export async function shotMorph(
|
|
|
62
62
|
const step = stepKey(fromSection.beats, from.beatIndex);
|
|
63
63
|
|
|
64
64
|
const html = videoDocument(resolved, options.playerScript);
|
|
65
|
-
const entry = morphEntry(deck
|
|
65
|
+
const entry = morphEntry(deck, options.from, options.to, at, html);
|
|
66
66
|
if (!entry.cached) {
|
|
67
67
|
await requirePlaywright(
|
|
68
68
|
{
|
package/src/core/shot/motion.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { isAbsolute, join, relative } from "node:path";
|
|
3
3
|
import pkg from "../../../package.json";
|
|
4
4
|
import { DekError } from "../error.ts";
|
|
5
|
-
import { deckProjectRoot } from "../path.ts";
|
|
6
5
|
import { type PlaywrightRunner, requirePlaywright } from "../playwright.ts";
|
|
7
6
|
import { deckStops, retreat } from "../position.ts";
|
|
8
7
|
import { asResolvedDeck, type ResolvedDeck, requireSection } from "../resolve.ts";
|
|
@@ -103,7 +102,7 @@ export async function shotMotion(
|
|
|
103
102
|
const key = `${JSON.stringify([pkg.version, SHEET_LAYOUT_VERSION, MOTION_FRACTIONS, plan.from, beats, title])}\0${html}`;
|
|
104
103
|
// Each step keeps its own entry, so runs of different steps neither evict nor race each other.
|
|
105
104
|
const capture = options.step === undefined ? [section.slug] : [section.slug, String(first)];
|
|
106
|
-
const bundle = bundleEntry(deck
|
|
105
|
+
const bundle = bundleEntry(deck, "motion", capture, key);
|
|
107
106
|
const manifest = join(bundle.dir, "motion.json");
|
|
108
107
|
const cached = readMotionManifest(manifest, bundle.dir);
|
|
109
108
|
if (cached) {
|
|
@@ -112,7 +111,7 @@ export async function shotMotion(
|
|
|
112
111
|
}
|
|
113
112
|
// Chromium writes the frames and sheets by name, so no link may stand where one goes.
|
|
114
113
|
dropLinks(bundle.dir);
|
|
115
|
-
dropLinks(outputDir(join(bundle.dir, "frames"),
|
|
114
|
+
dropLinks(outputDir(join(bundle.dir, "frames"), bundle.root));
|
|
116
115
|
|
|
117
116
|
const response = await requirePlaywright(
|
|
118
117
|
{
|
package/src/core/shot/still.ts
CHANGED
|
@@ -84,7 +84,7 @@ export async function shotSheet(
|
|
|
84
84
|
}));
|
|
85
85
|
// The tiles' paths hash what they show, so the key changes whenever a tile does.
|
|
86
86
|
const bundle = bundleEntry(
|
|
87
|
-
deck
|
|
87
|
+
deck,
|
|
88
88
|
"sheets",
|
|
89
89
|
[],
|
|
90
90
|
JSON.stringify([SHEET_LAYOUT_VERSION, deck.deck.title, cells]),
|
|
@@ -136,7 +136,7 @@ export function still(
|
|
|
136
136
|
step: string = stepKey(section.beats, beatIndex),
|
|
137
137
|
): Still {
|
|
138
138
|
const html = renderSlideHtml(sources, section.slug, beatIndex);
|
|
139
|
-
const entry = stillEntry(deck
|
|
139
|
+
const entry = stillEntry(deck, section, beatIndex, html);
|
|
140
140
|
return { html, slug: section.slug, step, screenshotPath: entry.path, entry };
|
|
141
141
|
}
|
|
142
142
|
|