@hajimism/dek 0.3.2 → 0.3.3

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.
@@ -0,0 +1,137 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { loadConfig } from "./config.ts";
4
+ import { DekError } from "./error.ts";
5
+ import { licenseText } from "./licenses.ts";
6
+ import { type DistOptions, distFile } from "./path.ts";
7
+ import { keepsInDeck, LICENSE_NAMES, MAX_SNAPSHOT_BYTES } from "./ref.ts";
8
+ import { isRefDeckName } from "./ref-name.ts";
9
+ import { followInside, requireInside, writeInside } from "./safe-fs.ts";
10
+
11
+ /**
12
+ * A deck handed over as a file, to be read as a ref by someone who cannot reach it on GitHub:
13
+ * a gzipped tar of a project that holds only that deck, under a folder named for it. The deck
14
+ * keeps what `dekc ref` keeps of a repository; the project's dek.toml goes empty, so the
15
+ * sender's refs and voice settings stay home. Its LICENSE is the one `packLicense` picks.
16
+ */
17
+ export async function packDeck(
18
+ project: { root: string },
19
+ deck: { name: string; dir: string },
20
+ options: { maxBytes?: number } = {},
21
+ ): Promise<Uint8Array> {
22
+ if (!isRefDeckName(deck.name)) {
23
+ throw new DekError(`deck "${deck.name}" cannot be read as a ref by that name`, {
24
+ path: deck.dir,
25
+ hint: "a ref names its deck with no space or @; rename the deck's folder, e.g. to 2026-10-talk, and pack it again",
26
+ });
27
+ }
28
+ // Each file by its path in the pack and where it really is, measured before any is read.
29
+ const sources: Array<readonly [string, string]> = deckFiles(deck.dir, project.root).map(
30
+ (path) =>
31
+ [`decks/${deck.name}/${path}`, requireInside(join(deck.dir, path), project.root)] as const,
32
+ );
33
+ const license = packLicense(project.root);
34
+ if (license?.path) {
35
+ sources.push([license.name, license.path]);
36
+ }
37
+ const maxBytes = options.maxBytes ?? MAX_SNAPSHOT_BYTES;
38
+ const size = sources.reduce((sum, [, real]) => sum + statSync(real).size, 0);
39
+ if (size > maxBytes) {
40
+ throw new DekError(
41
+ `deck "${deck.name}" is larger than a ref keeps, ${maxBytes / 1024 / 1024} MB`,
42
+ {
43
+ path: deck.dir,
44
+ hint: "a deck to read as a model should be far smaller; check its assets",
45
+ },
46
+ );
47
+ }
48
+ const files = new Map<string, Uint8Array>([["dek.toml", new Uint8Array()]]);
49
+ for (const [path, real] of sources) {
50
+ files.set(path, readFileSync(real));
51
+ }
52
+ if (license?.text !== undefined) {
53
+ files.set(license.name, new TextEncoder().encode(license.text));
54
+ }
55
+ const entries: Record<string, Uint8Array> = {};
56
+ for (const [path, bytes] of files) {
57
+ entries[`${deck.name}/${path}`] = bytes;
58
+ }
59
+ return new Bun.Archive(entries, { compress: "gzip" }).bytes();
60
+ }
61
+
62
+ /**
63
+ * The LICENSE a pack carries: the legal code `[pack] license` names, which says what the reader
64
+ * may do with a deck handed over on purpose; else the project's own LICENSE file; else none.
65
+ */
66
+ function packLicense(
67
+ root: string,
68
+ ):
69
+ | { name: string; text: string; path?: undefined }
70
+ | { name: string; path: string; text?: undefined }
71
+ | undefined {
72
+ const id = loadConfig(join(root, "dek.toml")).pack?.license;
73
+ if (id) {
74
+ return { name: "LICENSE", text: licenseText(id) };
75
+ }
76
+ const name = LICENSE_NAMES.find((file) => isFile(join(root, file), root));
77
+ return name ? { name, path: requireInside(join(root, name), root) } : undefined;
78
+ }
79
+
80
+ /** Said to the sender, who can fix it, when a pack would go out with no license. */
81
+ const NO_PACK_LICENSE =
82
+ 'the pack carries no license, so its reader is told to ask you before reusing it; add `[pack] license = "CC0-1.0"` to dek.toml to let them reuse it freely, or put a LICENSE at the project root';
83
+
84
+ /** `dekc pack`: write the deck's pack as dist/<deck>.tgz. */
85
+ export async function writePack(
86
+ project: { root: string },
87
+ deck: { name: string; dir: string },
88
+ options: DistOptions = {},
89
+ ): Promise<{ outPath: string; warnings: string[] }> {
90
+ const bytes = await packDeck(project, deck);
91
+ const outPath = distFile(project, deck, "tgz", options);
92
+ writeInside(outPath, bytes, project.root);
93
+ return { outPath, warnings: packLicense(project.root) ? [] : [NO_PACK_LICENSE] };
94
+ }
95
+
96
+ /** What `deckFiles` looks at in the deck's own folder; dist/, .cache/, and voice/ are not read. */
97
+ const DECK_TOP = new Set(["script.md", "theme.css", "slides", "assets"]);
98
+
99
+ /**
100
+ * The deck's files a ref keeps, relative to the deck, in a stable order. A file or folder whose
101
+ * name starts with a dot stays behind; a link dek would not follow anywhere else is an error.
102
+ */
103
+ function deckFiles(deckDir: string, root: string): string[] {
104
+ const found: string[] = [];
105
+ const walk = (rel: string) => {
106
+ const dir = requireInside(join(deckDir, rel), root);
107
+ for (const name of readdirSync(dir).sort()) {
108
+ if (name.startsWith(".") || (rel === "" && !DECK_TOP.has(name))) {
109
+ continue;
110
+ }
111
+ const path = rel === "" ? name : `${rel}/${name}`;
112
+ const real = requireInside(join(deckDir, path), root);
113
+ if (!existsSync(real)) {
114
+ throw new DekError(`${relative(root, join(deckDir, path))} is missing`, {
115
+ path: join(deckDir, path),
116
+ hint: "it is a symlink to nothing; remove it, or copy the file it meant in its place",
117
+ });
118
+ }
119
+ if (statSync(real).isDirectory()) {
120
+ walk(path);
121
+ } else if (keepsInDeck(path)) {
122
+ found.push(path);
123
+ }
124
+ }
125
+ };
126
+ walk("");
127
+ return found;
128
+ }
129
+
130
+ function isFile(path: string, root: string): boolean {
131
+ const real = followInside(path, root);
132
+ try {
133
+ return real !== undefined && statSync(real).isFile();
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
@@ -2,11 +2,13 @@ import { DekError } from "./error.ts";
2
2
 
3
3
  /**
4
4
  * A ref is someone else's deck, kept as a read-only snapshot to read as a
5
- * model. dek.toml `[refs]` pins each one to a commit; the snapshot under
6
- * `refs/owner/repo/deck` is only a copy of that commit and can be fetched again.
5
+ * model. dek.toml `[refs]` pins each one: a deck on GitHub to a commit, a pack
6
+ * to a digest of its files. The snapshot under `refs/` is only a copy of what
7
+ * the pin names, fetched or unpacked again whenever it is missing.
7
8
  * This module only names refs; `ref.ts` fetches and stores them.
8
9
  */
9
- export type RefSource = {
10
+ export type GithubRefSource = {
11
+ kind: "github";
10
12
  /** `owner/repo/deck`, the key in dek.toml `[refs]`. */
11
13
  name: string;
12
14
  owner: string;
@@ -18,13 +20,32 @@ export type RefSource = {
18
20
  path?: string;
19
21
  };
20
22
 
23
+ /** A deck handed over as a file by `dekc pack`, committed as `packs/<deck>.tgz`. */
24
+ export type FileRefSource = {
25
+ kind: "file";
26
+ /** `file/<deck>`, the key in dek.toml `[refs]`. */
27
+ name: string;
28
+ deck: string;
29
+ };
30
+
31
+ export type RefSource = GithubRefSource | FileRefSource;
32
+
33
+ /** The first segment of a pack's ref name; no GitHub owner may take it, so refs/ never overlaps. */
34
+ const PACK_OWNER = "file";
35
+
21
36
  const OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
22
37
  const REPO_RE = /^[A-Za-z0-9._-]+$/;
23
38
  const DECK_RE = /^[^/\\\s@]+$/;
24
39
  const SHA_RE = /^[0-9a-f]{40}$/;
40
+ const DIGEST_RE = /^[0-9a-f]{64}$/;
25
41
 
26
42
  const REF_HINT =
27
- "name a ref as owner/repo/deck, e.g. hajimism/dek/why-dek or hajimism/dek/why-dek@v1";
43
+ "name a ref as owner/repo/deck, e.g. hajimism/dek/why-dek or hajimism/dek/why-dek@v1, or a pack's as file/<deck>";
44
+
45
+ /** Where a pack's ref keeps its pack, relative to the project root: committed, never ignored. */
46
+ export function packFile(deck: string): string {
47
+ return `packs/${deck}.tgz`;
48
+ }
28
49
 
29
50
  /** True for `owner/repo/deck[@rev]` or a GitHub link, which a deck name never is. */
30
51
  export function isRefName(arg: string): boolean {
@@ -34,14 +55,15 @@ export function isRefName(arg: string): boolean {
34
55
  if (arg.startsWith(".") || arg.startsWith("/") || arg.startsWith("~")) {
35
56
  return false;
36
57
  }
37
- return arg.split("@")[0]?.split("/").length === 3;
58
+ const segments = arg.split("@")[0]?.split("/") ?? [];
59
+ return segments.length === 3 || (segments.length === 2 && segments[0] === PACK_OWNER);
38
60
  }
39
61
 
40
- /** A valid `owner/repo/deck` with no rev: the form dek.toml `[refs]` keys take. */
62
+ /** A valid `owner/repo/deck` with no rev, or a pack's `file/<deck>`: the form dek.toml `[refs]` keys take. */
41
63
  export function isPlainRefName(name: string): boolean {
42
64
  try {
43
65
  const source = parseRefSource(name);
44
- return source.rev === undefined && source.name === name;
66
+ return (source.kind === "file" || source.rev === undefined) && source.name === name;
45
67
  } catch {
46
68
  return false;
47
69
  }
@@ -51,6 +73,16 @@ export function isPinnedRev(rev: string): boolean {
51
73
  return SHA_RE.test(rev);
52
74
  }
53
75
 
76
+ /** True when `pin` has the form a ref of this kind is pinned with: a commit, or a pack's digest. */
77
+ export function isPinFor(source: RefSource, pin: string): boolean {
78
+ return source.kind === "file" ? DIGEST_RE.test(pin) : SHA_RE.test(pin);
79
+ }
80
+
81
+ /** True for a deck name a ref can carry: no slash, space, or @, and not . or .. */
82
+ export function isRefDeckName(deck: string | undefined): deck is string {
83
+ return deck !== undefined && DECK_RE.test(deck) && deck !== "." && deck !== "..";
84
+ }
85
+
54
86
  export function parseRefSource(input: string): RefSource {
55
87
  const trimmed = input.trim();
56
88
  if (/^https?:\/\//i.test(trimmed)) {
@@ -60,6 +92,17 @@ export function parseRefSource(input: string): RefSource {
60
92
  const path = at === -1 ? trimmed : trimmed.slice(0, at);
61
93
  const rev = at === -1 ? undefined : trimmed.slice(at + 1);
62
94
  const [owner, repo, deck, ...extra] = path.split("/");
95
+ if (owner === PACK_OWNER && deck === undefined) {
96
+ if (rev !== undefined || !isRefDeckName(repo)) {
97
+ throw new DekError(`"${input}" is not a ref name`, { hint: REF_HINT });
98
+ }
99
+ return { kind: "file", name: `${PACK_OWNER}/${repo}`, deck: repo };
100
+ }
101
+ if (owner?.toLowerCase() === PACK_OWNER && deck !== undefined) {
102
+ throw new DekError(`"${input}" is not a ref name`, {
103
+ hint: "file is kept for packs, so no GitHub owner of that name can be pinned; name a pack's ref as file/<deck>",
104
+ });
105
+ }
63
106
  const valid =
64
107
  extra.length === 0 &&
65
108
  owner !== undefined &&
@@ -68,15 +111,13 @@ export function parseRefSource(input: string): RefSource {
68
111
  REPO_RE.test(repo) &&
69
112
  repo !== "." &&
70
113
  repo !== ".." &&
71
- deck !== undefined &&
72
- DECK_RE.test(deck) &&
73
- deck !== "." &&
74
- deck !== ".." &&
114
+ isRefDeckName(deck) &&
75
115
  (rev === undefined || /^[^\s@]+$/.test(rev));
76
116
  if (!valid) {
77
117
  throw new DekError(`"${input}" is not a ref name`, { hint: REF_HINT });
78
118
  }
79
119
  return {
120
+ kind: "github",
80
121
  name: `${owner}/${repo}/${deck}`,
81
122
  owner,
82
123
  repo,
@@ -90,7 +131,7 @@ export function parseRefSource(input: string): RefSource {
90
131
  * link people copy from a browser. A branch name with a slash cannot be told
91
132
  * apart from the path, so such a branch goes after `@` instead.
92
133
  */
93
- function parseGithubUrl(input: string): RefSource {
134
+ function parseGithubUrl(input: string): GithubRefSource {
94
135
  const url = new URL(input);
95
136
  const [owner = "", repo = "", kind, rev, ...rest] = url.pathname.split("/").filter(Boolean);
96
137
  const decks = rest.indexOf("decks");
@@ -106,5 +147,8 @@ function parseGithubUrl(input: string): RefSource {
106
147
  });
107
148
  }
108
149
  const source = parseRefSource(`${owner}/${repo.replace(/\.git$/, "")}/${deck}@${rev}`);
150
+ if (source.kind !== "github") {
151
+ throw new DekError(`"${input}" is not a link to a deck on GitHub`, { hint: REF_HINT });
152
+ }
109
153
  return { ...source, path: rest.slice(0, decks + 2).join("/") };
110
154
  }
package/src/core/ref.ts CHANGED
@@ -11,6 +11,7 @@ import { outputDir } from "./safe-fs.ts";
11
11
  export {
12
12
  isPinnedRev,
13
13
  isRefName,
14
+ packFile,
14
15
  parseRefSource,
15
16
  type RefSource,
16
17
  } from "./ref-name.ts";
@@ -35,8 +36,10 @@ export const RefMeta = z.object({
35
36
  export type RefMeta = z.infer<typeof RefMeta>;
36
37
 
37
38
  export function refDir(root: string, name: string): string {
38
- const { owner, repo, deck } = parseRefSource(name);
39
- return join(root, "refs", owner, repo, deck);
39
+ const source = parseRefSource(name);
40
+ return source.kind === "file"
41
+ ? join(root, "refs", "file", source.deck)
42
+ : join(root, "refs", source.owner, source.repo, source.deck);
40
43
  }
41
44
 
42
45
  /** The marker of a snapshot, or nothing when it is missing or not one dek wrote. */
@@ -53,7 +56,7 @@ export function readRefMeta(dir: string): RefMeta | undefined {
53
56
  }
54
57
  }
55
58
 
56
- const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
59
+ export const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
57
60
 
58
61
  /** The snapshot's license file, relative to the snapshot, when the source had one. */
59
62
  export function refLicense(dir: string): string | null {
@@ -86,7 +89,12 @@ export function refState(project: { root: string; configPath: string }, name: st
86
89
  export type RepoFiles = Map<string, Blob>;
87
90
 
88
91
  /** Beyond this, a snapshot is refused: a deck to read is a few hundred kilobytes. */
89
- const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024;
92
+ export const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024;
93
+
94
+ /** A pack's tar, unzipped, may hold its files and their headers, not much more. */
95
+ const MAX_PACK_TAR_BYTES = 2 * MAX_SNAPSHOT_BYTES;
96
+
97
+ const PACK_HINT = "ask for a pack its author made with `dekc pack <deck>`";
90
98
 
91
99
  /**
92
100
  * Reads a GitHub tarball. Bun leaves out links; a path that is absolute, has
@@ -108,6 +116,64 @@ export async function readTarball(bytes: Uint8Array): Promise<RepoFiles> {
108
116
  return files;
109
117
  }
110
118
 
119
+ /**
120
+ * Reads a pack from someone else. It is gunzipped through a count that stops at `maxTarBytes`
121
+ * before Bun takes any of it apart, so a small file that inflates without end is refused rather
122
+ * than held in memory; the tar is then read as a GitHub tarball is.
123
+ */
124
+ export async function readPack(
125
+ bytes: Uint8Array,
126
+ limits: { maxBytes?: number; maxTarBytes?: number } = {},
127
+ ): Promise<RepoFiles> {
128
+ const maxBytes = limits.maxBytes ?? MAX_SNAPSHOT_BYTES;
129
+ const maxTarBytes = limits.maxTarBytes ?? MAX_PACK_TAR_BYTES;
130
+ if (bytes.byteLength > maxBytes) {
131
+ throw new DekError(`the pack is larger than ${formatMb(maxBytes)}`, { hint: PACK_HINT });
132
+ }
133
+ const chunks: Uint8Array[] = [];
134
+ let size = 0;
135
+ try {
136
+ const reader = new Blob([new Uint8Array(bytes)])
137
+ .stream()
138
+ .pipeThrough(new DecompressionStream("gzip"))
139
+ .getReader();
140
+ for (;;) {
141
+ const { done, value } = await reader.read();
142
+ if (done) {
143
+ break;
144
+ }
145
+ size += value.byteLength;
146
+ if (size > maxTarBytes) {
147
+ await reader.cancel();
148
+ throw new DekError(`the pack unpacks to more than ${formatMb(maxTarBytes)}`, {
149
+ hint: PACK_HINT,
150
+ });
151
+ }
152
+ chunks.push(value);
153
+ }
154
+ } catch (error) {
155
+ if (error instanceof DekError) {
156
+ throw error;
157
+ }
158
+ throw new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT });
159
+ }
160
+ const tar = new Uint8Array(size);
161
+ let offset = 0;
162
+ for (const chunk of chunks) {
163
+ tar.set(chunk, offset);
164
+ offset += chunk.byteLength;
165
+ }
166
+ try {
167
+ return await readTarball(tar);
168
+ } catch {
169
+ throw new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT });
170
+ }
171
+ }
172
+
173
+ function formatMb(bytes: number): string {
174
+ return bytes >= 1024 * 1024 ? `${bytes / 1024 / 1024} MB` : `${bytes} bytes`;
175
+ }
176
+
111
177
  const DECK_SCRIPT_RE = /^(?:(.*)\/)?decks\/([^/]+)\/script\.md$/;
112
178
 
113
179
  /** The decks in a repository: each `decks/<name>/script.md` beside its project's dek.toml. */
@@ -119,14 +185,32 @@ function repoDecks(files: RepoFiles): Array<{ name: string; prefix: string }> {
119
185
  });
120
186
  }
121
187
 
188
+ /** The name of the one deck a pack holds; a pack of none, or of more, is refused. */
189
+ export function deckOfPack(files: RepoFiles): string {
190
+ const names = [...new Set(repoDecks(files).map((deck) => deck.name))].sort();
191
+ const [name, ...others] = names;
192
+ if (name === undefined) {
193
+ throw new DekError("the pack holds no deck", {
194
+ hint: `a pack is one deck beside a dek.toml; ${PACK_HINT}`,
195
+ });
196
+ }
197
+ if (others.length > 0) {
198
+ throw new DekError(`the pack holds more than one deck: ${names.join(", ")}`, {
199
+ hint: `a pack is one deck; ${PACK_HINT}`,
200
+ });
201
+ }
202
+ return name;
203
+ }
204
+
122
205
  /** The directory of the project that holds the deck, as a prefix: "" or "sample/". */
123
206
  export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
124
- const repo = `${source.owner}/${source.repo}`;
207
+ const repo = source.kind === "file" ? "the pack" : `${source.owner}/${source.repo}`;
208
+ const path = source.kind === "github" ? source.path : undefined;
125
209
  const decks = repoDecks(files);
126
210
  const matches = decks.filter(
127
211
  (deck) =>
128
212
  deck.name === source.deck &&
129
- (source.path === undefined || `${deck.prefix}decks/${deck.name}` === source.path),
213
+ (path === undefined || `${deck.prefix}decks/${deck.name}` === path),
130
214
  );
131
215
  const [match, ...others] = matches;
132
216
  if (!match) {
@@ -148,7 +232,7 @@ export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
148
232
  }
149
233
 
150
234
  /** Inside the deck, only what reading it needs: not dist/, .cache/, or voice/. */
151
- function keepsInDeck(path: string): boolean {
235
+ export function keepsInDeck(path: string): boolean {
152
236
  return (
153
237
  path === "script.md" ||
154
238
  path === "theme.css" ||
@@ -157,17 +241,19 @@ function keepsInDeck(path: string): boolean {
157
241
  );
158
242
  }
159
243
 
244
+ /** The files a snapshot keeps, by their paths in it, and where the deck was in its source. */
245
+ export type Snapshot = {
246
+ files: Map<string, Blob>;
247
+ license: string | undefined;
248
+ /** The deck's directory in the source repository or pack. */
249
+ path: string;
250
+ };
251
+
160
252
  /**
161
- * Writes the snapshot of one deck at one commit under refs/, laid out as the
162
- * project it came from so it resolves like any dek project. It is written
163
- * beside the old one and swapped in, so a failure leaves the old one whole.
253
+ * What a snapshot keeps of a repository or a pack: its project's dek.toml, the deck's files that
254
+ * reading needs, and its license, laid out as the project it came from.
164
255
  */
165
- export async function installSnapshot(
166
- root: string,
167
- source: RefSource,
168
- sha: string,
169
- files: RepoFiles,
170
- ): Promise<{ dir: string; license: string | null; path: string }> {
256
+ export function pickSnapshot(files: RepoFiles, source: RefSource): Snapshot {
171
257
  const prefix = findDeckInRepo(files, source);
172
258
  const deckPrefix = `${prefix}decks/${source.deck}/`;
173
259
  const license =
@@ -190,10 +276,52 @@ export async function installSnapshot(
190
276
  hint: "a deck to read as a model should be far smaller; check its assets",
191
277
  });
192
278
  }
279
+ return { files: picked, license, path: `${prefix}decks/${source.deck}` };
280
+ }
281
+
282
+ /**
283
+ * What a pack's ref is pinned to: the sha256 of every file the snapshot keeps, in byte order of
284
+ * their paths, each as its path, a NUL, its size in decimal digits, a NUL, and its bytes. It
285
+ * follows the files, not the tarball, whose headers carry the time it was made.
286
+ */
287
+ export async function snapshotDigest(snapshot: Snapshot): Promise<string> {
288
+ const hasher = new Bun.CryptoHasher("sha256");
289
+ const encoder = new TextEncoder();
290
+ const paths = [...snapshot.files.keys()].sort((a, b) =>
291
+ Buffer.compare(encoder.encode(a), encoder.encode(b)),
292
+ );
293
+ for (const path of paths) {
294
+ const blob = snapshot.files.get(path) ?? new Blob([]);
295
+ hasher.update(`${path}\0${blob.size}\0`);
296
+ hasher.update(new Uint8Array(await blob.arrayBuffer()));
297
+ }
298
+ return hasher.digest("hex");
299
+ }
193
300
 
301
+ /** Writes the snapshot of one deck at one commit under refs/; see writeSnapshot. */
302
+ export async function installSnapshot(
303
+ root: string,
304
+ source: RefSource,
305
+ sha: string,
306
+ files: RepoFiles,
307
+ ): Promise<{ dir: string; license: string | null; path: string }> {
308
+ return writeSnapshot(root, source.name, sha, pickSnapshot(files, source));
309
+ }
310
+
311
+ /**
312
+ * Writes a snapshot under refs/, laid out as the project it came from so it resolves like any
313
+ * dek project, and marks it with what it is pinned to. It is written beside the old one and
314
+ * swapped in, so a failure leaves the old one whole.
315
+ */
316
+ export async function writeSnapshot(
317
+ root: string,
318
+ name: string,
319
+ pin: string,
320
+ snapshot: Snapshot,
321
+ ): Promise<{ dir: string; license: string | null; path: string }> {
194
322
  // refs/ is dek's; a link on the way would aim the swap below, and its delete, elsewhere.
195
323
  const refsDir = join(root, "refs");
196
- const dir = refDir(root, source.name);
324
+ const dir = refDir(root, name);
197
325
  outputDir(dirname(dir), root);
198
326
  if (isLink(dir)) {
199
327
  await rm(dir);
@@ -201,9 +329,9 @@ export async function installSnapshot(
201
329
  const suffix = `${process.pid}-${Date.now().toString(36)}`;
202
330
  const temp = join(refsDir, `.tmp-${suffix}`);
203
331
  const old = join(refsDir, `.old-${suffix}`);
204
- const meta: RefMeta = { name: source.name, rev: sha, path: `${prefix}decks/${source.deck}` };
332
+ const meta: RefMeta = { name, rev: pin, path: snapshot.path };
205
333
  try {
206
- for (const [path, blob] of picked) {
334
+ for (const [path, blob] of snapshot.files) {
207
335
  await mkdir(dirname(join(temp, path)), { recursive: true });
208
336
  await writeFile(join(temp, path), new Uint8Array(await blob.arrayBuffer()));
209
337
  }
@@ -224,7 +352,7 @@ export async function installSnapshot(
224
352
  await rm(old, { recursive: true, force: true });
225
353
  }
226
354
  }
227
- return { dir, license: license ?? null, path: meta.path };
355
+ return { dir, license: snapshot.license ?? null, path: meta.path };
228
356
  }
229
357
 
230
358
  /** The snapshot deck's title and slide count, or nothing when it does not parse. */
package/src/core/sync.ts CHANGED
@@ -336,7 +336,7 @@ function referencesSection(root: string): string {
336
336
  return `
337
337
  ## References
338
338
 
339
- Other people's decks, pinned in \`dek.toml\` \`[refs]\`, to read as models. They are read-only: read a slide with \`dekc show <ref> <slug>\`, then write your own in this deck's theme.
339
+ Other people's decks, pinned in \`dek.toml\` \`[refs]\`, to read as models. They are read-only. To build a deck on a ref's layouts, start it with \`dekc new <name> --theme-from <ref>\`, which copies the ref's theme into it. Otherwise read a slide with \`dekc show <ref> <slug>\`, then write your own in this deck's theme.
340
340
 
341
341
  ${lines.join("\n")}
342
342
  `;
@@ -671,10 +671,12 @@ export function createAnnotateView(options: {
671
671
  return;
672
672
  }
673
673
  event.stopPropagation();
674
- if (event.target !== field) {
674
+ // Safari sends the key that commits or cancels a conversion after compositionend, with
675
+ // isComposing false; keyCode 229 still marks it as the input method's.
676
+ if (event.target !== field || event.isComposing || event.keyCode === 229) {
675
677
  return;
676
678
  }
677
- if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
679
+ if (event.key === "Enter" && !event.shiftKey) {
678
680
  event.preventDefault();
679
681
  submit();
680
682
  } else if (event.key === "Escape") {