@hajimism/dek 0.3.1 → 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.
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") {