@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.
- package/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/src/cli/commands.ts +43 -7
- package/src/cli/contract.ts +9 -0
- package/src/cli/files.ts +8 -1
- package/src/cli/new.ts +32 -8
- package/src/cli/pack.ts +21 -0
- package/src/cli/ref.ts +156 -4
- package/src/cli/scope.ts +14 -5
- package/src/cli/text.ts +18 -2
- package/src/core/config-keys.ts +6 -2
- package/src/core/config.ts +32 -5
- 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/pack.ts +137 -0
- package/src/core/ref-name.ts +56 -12
- package/src/core/ref.ts +148 -20
- package/src/core/sync.ts +1 -1
- package/src/runtime/annotate-view.ts +4 -2
package/src/core/pack.ts
ADDED
|
@@ -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
|
+
}
|
package/src/core/ref-name.ts
CHANGED
|
@@ -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
|
|
6
|
-
* `refs
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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):
|
|
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
|
|
39
|
-
return
|
|
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
|
-
(
|
|
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
|
-
*
|
|
162
|
-
*
|
|
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
|
|
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,
|
|
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
|
|
332
|
+
const meta: RefMeta = { name, rev: pin, path: snapshot.path };
|
|
205
333
|
try {
|
|
206
|
-
for (const [path, blob] of
|
|
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
|
|
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
|
-
|
|
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
|
|
679
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
678
680
|
event.preventDefault();
|
|
679
681
|
submit();
|
|
680
682
|
} else if (event.key === "Escape") {
|