@hajimism/dek 0.3.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +136 -0
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/package.json +5 -3
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +95 -18
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +57 -19
- package/src/cli/contract.ts +58 -4
- package/src/cli/files.ts +2 -5
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +5 -4
- package/src/cli/goto.ts +44 -11
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +36 -16
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +51 -14
- package/src/cli/new.ts +28 -8
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +92 -9
- package/src/cli/result.ts +47 -6
- package/src/cli/scope.ts +57 -29
- package/src/cli/shot.ts +78 -6
- package/src/cli/show.ts +15 -2
- package/src/cli/text.ts +88 -11
- package/src/cli/theme.ts +58 -10
- package/src/cli/tty.ts +16 -2
- package/src/cli/voice.ts +15 -9
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- package/src/core/assets.ts +18 -10
- package/src/core/attribute-value.ts +188 -0
- package/src/core/build.ts +3 -3
- package/src/core/config.ts +61 -12
- package/src/core/css-scan.ts +173 -39
- package/src/core/css-transform.ts +57 -11
- package/src/core/css.ts +19 -13
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/foreign-text.ts +138 -0
- package/src/core/github.ts +1 -0
- package/src/core/html-entities.ts +318 -0
- package/src/core/html-scan.ts +49 -27
- package/src/core/html.ts +21 -15
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +50 -8
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +24 -19
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +57 -27
- package/src/core/order.ts +14 -0
- package/src/core/pack.ts +2 -2
- package/src/core/path.ts +8 -2
- package/src/core/playwright-module.d.ts +13 -1
- package/src/core/playwright-motion.ts +1 -0
- package/src/core/playwright-sheet.ts +1 -0
- package/src/core/playwright-visual.ts +207 -30
- package/src/core/playwright.ts +279 -44
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +56 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +52 -8
- package/src/core/ref.ts +308 -54
- package/src/core/resolve.ts +23 -11
- package/src/core/rumdl.ts +51 -25
- package/src/core/safe-fs.ts +19 -2
- package/src/core/sandbox.ts +217 -0
- package/src/core/shot/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +26 -17
- package/src/core/slide-eval-worker.ts +1 -0
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +12 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/spawn.ts +131 -32
- package/src/core/sync.ts +5 -14
- package/src/core/tar.ts +315 -0
- package/src/core/text-contrast.ts +2 -0
- package/src/core/toml-keys.ts +116 -51
- package/src/core/url-attributes.ts +27 -9
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +182 -24
- package/src/core/voice.ts +5 -6
- package/src/core/zod.ts +9 -1
- package/src/server/auth.ts +21 -0
- package/src/server/dev.ts +26 -7
- package/src/server/lock.ts +50 -7
- package/src/server/routes.ts +7 -2
- package/src/server/serial.ts +8 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
- package/src/video/mux.ts +27 -3
- package/src/video/recorder.ts +3 -2
- package/src/video/worker.ts +1 -0
- package/src/voice/engine.ts +81 -18
package/src/core/ref.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
// biome-ignore lint/style/noRestrictedImports: mkdir, writeFile, rename, and rm, which write a snapshot into a fresh folder in refs/, which outputDir checked, and swap it in whole.
|
|
2
3
|
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { loadConfig } from "./config.ts";
|
|
6
7
|
import { DekError } from "./error.ts";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { shellQuote } from "./escape.ts";
|
|
9
|
+
import { foreignList } from "./foreign-text.ts";
|
|
10
|
+
import {
|
|
11
|
+
isPlainRefName,
|
|
12
|
+
isRefDeckName,
|
|
13
|
+
packFile,
|
|
14
|
+
parseRefSource,
|
|
15
|
+
REF_DECK_NAME_RULE,
|
|
16
|
+
type RefSource,
|
|
17
|
+
} from "./ref-name.ts";
|
|
18
|
+
import { listSlides, type Project, resolveProject } from "./resolve.ts";
|
|
19
|
+
import { followInside, outputDir } from "./safe-fs.ts";
|
|
20
|
+
import { readTar } from "./tar.ts";
|
|
10
21
|
|
|
11
22
|
export {
|
|
12
23
|
isPinnedRev,
|
|
@@ -24,6 +35,49 @@ function isLink(path: string): boolean {
|
|
|
24
35
|
}
|
|
25
36
|
}
|
|
26
37
|
|
|
38
|
+
/** The first link on the way from `<root>/<top>` down to `path`, both included, if any. */
|
|
39
|
+
function linkUnder(root: string, top: "refs" | "packs", path: string): string | undefined {
|
|
40
|
+
let at = join(root, top);
|
|
41
|
+
for (const segment of ["", ...relative(at, path).split(sep)]) {
|
|
42
|
+
at = join(at, segment);
|
|
43
|
+
if (isLink(at)) {
|
|
44
|
+
return at;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Refuses a link on the way to the snapshot at `dir`. refs/ is dek's own folder, which a cloned
|
|
52
|
+
* project can still commit: a link there, even one that stays in the project, would aim a
|
|
53
|
+
* snapshot's swap and its delete at what it points to, such as a deck under decks/.
|
|
54
|
+
*/
|
|
55
|
+
export function refuseLinkUnderRefs(root: string, dir: string): void {
|
|
56
|
+
const link = linkUnder(root, "refs", dir);
|
|
57
|
+
if (link !== undefined) {
|
|
58
|
+
const rel = relative(root, link);
|
|
59
|
+
throw new DekError(`${rel} is a symlink; dek follows no link under refs/`, {
|
|
60
|
+
path: link,
|
|
61
|
+
hint: `remove ${rel}: refs/ holds only the snapshots \`dekc ref\` writes, and the next read of a pinned ref fetches it again`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Refuses a link at packs/, or at the copy of `deck`'s pack kept there. dek writes and removes
|
|
68
|
+
* that copy by name, so through a link it would overwrite, or delete, what the link points to.
|
|
69
|
+
*/
|
|
70
|
+
export function refuseLinkUnderPacks(root: string, deck: string): void {
|
|
71
|
+
const link = linkUnder(root, "packs", join(root, packFile(deck)));
|
|
72
|
+
if (link !== undefined) {
|
|
73
|
+
const rel = relative(root, link);
|
|
74
|
+
throw new DekError(`${rel} is a symlink; dek follows no link under packs/`, {
|
|
75
|
+
path: link,
|
|
76
|
+
hint: `remove ${rel}, then run \`dekc ref <file>.tgz\` with the pack you were sent: packs/ holds the copies it keeps, as plain files`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
27
81
|
/** Written into each snapshot; its presence is what makes a directory a ref. */
|
|
28
82
|
export const REF_MARKER = ".ref.json";
|
|
29
83
|
|
|
@@ -44,8 +98,10 @@ export function refDir(root: string, name: string): string {
|
|
|
44
98
|
|
|
45
99
|
/** The marker of a snapshot, or nothing when it is missing or not one dek wrote. */
|
|
46
100
|
export function readRefMeta(dir: string): RefMeta | undefined {
|
|
47
|
-
const
|
|
48
|
-
|
|
101
|
+
const marker = join(dir, REF_MARKER);
|
|
102
|
+
// dek writes the marker as a plain file; a link out of the snapshot is none of its.
|
|
103
|
+
const path = existsSync(marker) ? followInside(marker, dir) : undefined;
|
|
104
|
+
if (path === undefined) {
|
|
49
105
|
return undefined;
|
|
50
106
|
}
|
|
51
107
|
try {
|
|
@@ -56,6 +112,87 @@ export function readRefMeta(dir: string): RefMeta | undefined {
|
|
|
56
112
|
}
|
|
57
113
|
}
|
|
58
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The snapshot `dir` is in, and the ref it holds: the outermost folder from `dir` up that is one
|
|
117
|
+
* dek wrote. A ref may carry a dek.toml, and a marker, of its own in its deck's assets; neither
|
|
118
|
+
* hides the snapshot around them or names it. A marker anywhere else makes nothing a ref.
|
|
119
|
+
*/
|
|
120
|
+
function refAround(dir: string): { dir: string; name: string } | undefined {
|
|
121
|
+
let found: { dir: string; name: string } | undefined;
|
|
122
|
+
for (let at = resolve(dir); ; at = dirname(at)) {
|
|
123
|
+
const name = snapshotName(at);
|
|
124
|
+
if (name !== undefined) {
|
|
125
|
+
found = { dir: at, name };
|
|
126
|
+
}
|
|
127
|
+
if (dirname(at) === at) {
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The ref `dir` holds, when it is a snapshot as dek writes one: a dek.toml beside a marker that
|
|
135
|
+
* names a ref, at that ref's own place under a project's refs/.
|
|
136
|
+
*/
|
|
137
|
+
export function snapshotName(dir: string): string | undefined {
|
|
138
|
+
if (!isThere(join(dir, "dek.toml"))) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
const name = readRefMeta(dir)?.name;
|
|
142
|
+
if (name === undefined || !isPlainRefName(name)) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
// The same folder, not the same spelling: a disk that ignores case, or a link at refs/, gives
|
|
146
|
+
// the path to a snapshot another spelling than its name.
|
|
147
|
+
const place = refDir("", name);
|
|
148
|
+
const project = resolve(dir, ...place.split(sep).map(() => ".."));
|
|
149
|
+
return isSameFolder(join(project, place), dir) ? name : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isSameFolder(a: string, b: string): boolean {
|
|
153
|
+
try {
|
|
154
|
+
const [one, other] = [statSync(a), statSync(b)];
|
|
155
|
+
return one.dev === other.dev && one.ino === other.ino;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isThere(path: string): boolean {
|
|
162
|
+
try {
|
|
163
|
+
lstatSync(path);
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Refuses `dir` when it is inside a ref: no command, the dev server's included, runs or writes there. */
|
|
171
|
+
export function refuseInsideRef(dir: string): void {
|
|
172
|
+
const ref = refAround(dir);
|
|
173
|
+
if (ref !== undefined) {
|
|
174
|
+
throw new DekError("this directory is inside a ref; refs are read-only", {
|
|
175
|
+
path: ref.dir,
|
|
176
|
+
hint: `run dek from your own project instead: \`dekc show ${ref.name} <slug>\``,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The project around `cwd`, which is the user's own: never one inside a ref. */
|
|
182
|
+
export function requireProject(cwd: string): Project {
|
|
183
|
+
const project = resolveProject(cwd);
|
|
184
|
+
refuseInsideRef(project.root);
|
|
185
|
+
return project;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** What a command that writes says when it is handed a ref by name. */
|
|
189
|
+
export function refReadOnlyError(name: string): DekError {
|
|
190
|
+
const ref = shellQuote(name);
|
|
191
|
+
return new DekError(`"${name}" is a ref; refs are read-only`, {
|
|
192
|
+
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`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
59
196
|
export const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
|
|
60
197
|
|
|
61
198
|
/** The snapshot's license file, relative to the snapshot, when the source had one. */
|
|
@@ -81,7 +218,11 @@ export function refState(project: { root: string; configPath: string }, name: st
|
|
|
81
218
|
name,
|
|
82
219
|
...(pinned !== undefined ? { pinned } : {}),
|
|
83
220
|
dir,
|
|
84
|
-
|
|
221
|
+
// Never through a link under refs/: one is fetched again, which refuses the link.
|
|
222
|
+
fetched:
|
|
223
|
+
pinned !== undefined &&
|
|
224
|
+
linkUnder(project.root, "refs", dir) === undefined &&
|
|
225
|
+
readRefMeta(dir)?.rev === pinned,
|
|
85
226
|
};
|
|
86
227
|
}
|
|
87
228
|
|
|
@@ -94,42 +235,84 @@ export const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024;
|
|
|
94
235
|
/** A pack's tar, unzipped, may hold its files and their headers, not much more. */
|
|
95
236
|
const MAX_PACK_TAR_BYTES = 2 * MAX_SNAPSHOT_BYTES;
|
|
96
237
|
|
|
97
|
-
const PACK_HINT = "ask for a pack its author made with `dekc pack <deck>`";
|
|
98
|
-
|
|
99
238
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* later can land outside the snapshot.
|
|
239
|
+
* A repository's tar, unzipped. A repository of text inflates some 3.5 times, so about five
|
|
240
|
+
* times the 200 MB dek downloads of it: 1 GB.
|
|
103
241
|
*/
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
242
|
+
const MAX_TARBALL_TAR_BYTES = 1024 * 1024 * 1024;
|
|
243
|
+
|
|
244
|
+
/** A deck is a few hundred files; a pack of more is not one `dekc pack` made. */
|
|
245
|
+
const MAX_PACK_ENTRIES = 10_000;
|
|
246
|
+
|
|
247
|
+
/** A repository's files, folders, and the headers before them, as many as a large one holds. */
|
|
248
|
+
const MAX_TARBALL_ENTRIES = 200_000;
|
|
249
|
+
|
|
250
|
+
const PACK_HINT = "ask for a pack its author made with `dekc pack <deck>`";
|
|
251
|
+
|
|
252
|
+
/** Reads a GitHub tarball, as `readGzippedTar` reads any. */
|
|
253
|
+
export async function readTarball(
|
|
254
|
+
bytes: Uint8Array,
|
|
255
|
+
limits: { maxTarBytes?: number; maxEntries?: number } = {},
|
|
256
|
+
): Promise<RepoFiles> {
|
|
257
|
+
const maxTarBytes = limits.maxTarBytes ?? MAX_TARBALL_TAR_BYTES;
|
|
258
|
+
const maxEntries = limits.maxEntries ?? MAX_TARBALL_ENTRIES;
|
|
259
|
+
return readGzippedTar(
|
|
260
|
+
bytes,
|
|
261
|
+
{ maxTarBytes, maxEntries },
|
|
262
|
+
{
|
|
263
|
+
tooLarge: () =>
|
|
264
|
+
new DekError(`the repository unpacks to more than ${formatMb(maxTarBytes)}`, {
|
|
265
|
+
hint: "dekc ref downloads and unpacks the whole repository, so keep decks in a smaller repository, or ask the deck's author for a pack made with `dekc pack <deck>` and add it with `dekc ref <file>.tgz`",
|
|
266
|
+
}),
|
|
267
|
+
notTar: () =>
|
|
268
|
+
new DekError("GitHub's tarball of the repository is not a gzipped tar", {
|
|
269
|
+
hint: "run the command again",
|
|
270
|
+
}),
|
|
271
|
+
holds: (what) =>
|
|
272
|
+
new DekError(`the repository's tarball holds ${what}, which dek does not read`, {
|
|
273
|
+
hint: "the repository holds something dek cannot read; ask the deck's author for a pack made with `dekc pack <deck>` and pin the deck from it with `dekc ref <file>.tgz`",
|
|
274
|
+
}),
|
|
275
|
+
},
|
|
276
|
+
);
|
|
117
277
|
}
|
|
118
278
|
|
|
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
|
-
*/
|
|
279
|
+
/** Reads a pack from someone else, no larger than `maxBytes`, as `readGzippedTar` reads any. */
|
|
124
280
|
export async function readPack(
|
|
125
281
|
bytes: Uint8Array,
|
|
126
|
-
limits: { maxBytes?: number; maxTarBytes?: number } = {},
|
|
282
|
+
limits: { maxBytes?: number; maxTarBytes?: number; maxEntries?: number } = {},
|
|
127
283
|
): Promise<RepoFiles> {
|
|
128
284
|
const maxBytes = limits.maxBytes ?? MAX_SNAPSHOT_BYTES;
|
|
129
285
|
const maxTarBytes = limits.maxTarBytes ?? MAX_PACK_TAR_BYTES;
|
|
286
|
+
const maxEntries = limits.maxEntries ?? MAX_PACK_ENTRIES;
|
|
130
287
|
if (bytes.byteLength > maxBytes) {
|
|
131
288
|
throw new DekError(`the pack is larger than ${formatMb(maxBytes)}`, { hint: PACK_HINT });
|
|
132
289
|
}
|
|
290
|
+
return readGzippedTar(
|
|
291
|
+
bytes,
|
|
292
|
+
{ maxTarBytes, maxEntries },
|
|
293
|
+
{
|
|
294
|
+
tooLarge: () =>
|
|
295
|
+
new DekError(`the pack unpacks to more than ${formatMb(maxTarBytes)}`, { hint: PACK_HINT }),
|
|
296
|
+
notTar: () =>
|
|
297
|
+
new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT }),
|
|
298
|
+
holds: (what) =>
|
|
299
|
+
new DekError(`the pack holds ${what}, which dek does not read`, { hint: PACK_HINT }),
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The files of a gzipped tar from elsewhere. It is gunzipped through a count that stops at
|
|
306
|
+
* `maxTarBytes`, so a small file that inflates without end is refused rather than held in
|
|
307
|
+
* memory, and the tar is then read by dek's own reader (`readTar`), whose files are the bytes
|
|
308
|
+
* the tar stores and no more. The top folder is dropped, and so is a path that is absolute, has
|
|
309
|
+
* an empty segment, or climbs with `..`, so nothing written later can land outside the snapshot.
|
|
310
|
+
*/
|
|
311
|
+
async function readGzippedTar(
|
|
312
|
+
bytes: Uint8Array,
|
|
313
|
+
{ maxTarBytes, maxEntries }: { maxTarBytes: number; maxEntries: number },
|
|
314
|
+
errors: { tooLarge: () => DekError; notTar: () => DekError; holds: (what: string) => DekError },
|
|
315
|
+
): Promise<RepoFiles> {
|
|
133
316
|
const chunks: Uint8Array[] = [];
|
|
134
317
|
let size = 0;
|
|
135
318
|
try {
|
|
@@ -145,9 +328,7 @@ export async function readPack(
|
|
|
145
328
|
size += value.byteLength;
|
|
146
329
|
if (size > maxTarBytes) {
|
|
147
330
|
await reader.cancel();
|
|
148
|
-
throw
|
|
149
|
-
hint: PACK_HINT,
|
|
150
|
-
});
|
|
331
|
+
throw errors.tooLarge();
|
|
151
332
|
}
|
|
152
333
|
chunks.push(value);
|
|
153
334
|
}
|
|
@@ -155,7 +336,7 @@ export async function readPack(
|
|
|
155
336
|
if (error instanceof DekError) {
|
|
156
337
|
throw error;
|
|
157
338
|
}
|
|
158
|
-
throw
|
|
339
|
+
throw errors.notTar();
|
|
159
340
|
}
|
|
160
341
|
const tar = new Uint8Array(size);
|
|
161
342
|
let offset = 0;
|
|
@@ -163,15 +344,36 @@ export async function readPack(
|
|
|
163
344
|
tar.set(chunk, offset);
|
|
164
345
|
offset += chunk.byteLength;
|
|
165
346
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
347
|
+
// The tar holds them all now; let them go before its files are made.
|
|
348
|
+
chunks.length = 0;
|
|
349
|
+
const entries = readTar(tar, { maxBytes: maxTarBytes, maxEntries });
|
|
350
|
+
if (!(entries instanceof Map)) {
|
|
351
|
+
throw entries.kind === "too large"
|
|
352
|
+
? errors.tooLarge()
|
|
353
|
+
: entries.kind === "not a tar"
|
|
354
|
+
? errors.notTar()
|
|
355
|
+
: errors.holds(entries.what);
|
|
170
356
|
}
|
|
357
|
+
const files: RepoFiles = new Map();
|
|
358
|
+
for (const [path, blob] of entries) {
|
|
359
|
+
const [, ...segments] = path.split("/");
|
|
360
|
+
const safe =
|
|
361
|
+
segments.length > 0 &&
|
|
362
|
+
!path.includes("\\") &&
|
|
363
|
+
segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
364
|
+
if (safe && !path.startsWith("/")) {
|
|
365
|
+
files.set(segments.join("/"), blob);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return files;
|
|
171
369
|
}
|
|
172
370
|
|
|
173
371
|
function formatMb(bytes: number): string {
|
|
174
|
-
|
|
372
|
+
const mb = 1024 * 1024;
|
|
373
|
+
if (bytes >= 1024 * mb && bytes % (1024 * mb) === 0) {
|
|
374
|
+
return `${bytes / 1024 / mb} GB`;
|
|
375
|
+
}
|
|
376
|
+
return bytes >= mb ? `${bytes / mb} MB` : `${bytes} bytes`;
|
|
175
377
|
}
|
|
176
378
|
|
|
177
379
|
const DECK_SCRIPT_RE = /^(?:(.*)\/)?decks\/([^/]+)\/script\.md$/;
|
|
@@ -185,7 +387,37 @@ function repoDecks(files: RepoFiles): Array<{ name: string; prefix: string }> {
|
|
|
185
387
|
});
|
|
186
388
|
}
|
|
187
389
|
|
|
188
|
-
/**
|
|
390
|
+
/**
|
|
391
|
+
* Names from someone else's repository or pack as a message lists them: those `shows` passes,
|
|
392
|
+
* as foreignList shows them, then how many others there are, in the words of `others`.
|
|
393
|
+
*/
|
|
394
|
+
function listCarried(
|
|
395
|
+
names: string[],
|
|
396
|
+
shows: (name: string) => boolean,
|
|
397
|
+
others: (count: number) => string,
|
|
398
|
+
): string {
|
|
399
|
+
const shown = names.filter(shows);
|
|
400
|
+
const left = names.length - shown.length;
|
|
401
|
+
if (left === 0) {
|
|
402
|
+
return foreignList(shown);
|
|
403
|
+
}
|
|
404
|
+
return shown.length === 0 ? others(left) : `${foreignList(shown)}, and ${others(left)}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Deck names as a message lists them: only those a ref can carry. */
|
|
408
|
+
function listDecks(names: string[]): string {
|
|
409
|
+
return listCarried(
|
|
410
|
+
names,
|
|
411
|
+
isRefDeckName,
|
|
412
|
+
(count) => `${count} whose ${count === 1 ? "name" : "names"} a ref cannot carry`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The name of the one deck a pack holds; a pack of none, of more, or of one no ref can name is
|
|
418
|
+
* refused. The names are the pack author's, so the message lists them as listDecks does, and the
|
|
419
|
+
* hint is dek's.
|
|
420
|
+
*/
|
|
189
421
|
export function deckOfPack(files: RepoFiles): string {
|
|
190
422
|
const names = [...new Set(repoDecks(files).map((deck) => deck.name))].sort();
|
|
191
423
|
const [name, ...others] = names;
|
|
@@ -195,14 +427,23 @@ export function deckOfPack(files: RepoFiles): string {
|
|
|
195
427
|
});
|
|
196
428
|
}
|
|
197
429
|
if (others.length > 0) {
|
|
198
|
-
throw new DekError(`the pack holds more than one deck: ${names
|
|
430
|
+
throw new DekError(`the pack holds more than one deck: ${listDecks(names)}`, {
|
|
199
431
|
hint: `a pack is one deck; ${PACK_HINT}`,
|
|
200
432
|
});
|
|
201
433
|
}
|
|
434
|
+
if (!isRefDeckName(name)) {
|
|
435
|
+
throw new DekError("the pack's deck has a name a ref cannot carry", {
|
|
436
|
+
hint: `${REF_DECK_NAME_RULE}; ask its author to rename the deck's folder and pack it again`,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
202
439
|
return name;
|
|
203
440
|
}
|
|
204
441
|
|
|
205
|
-
/**
|
|
442
|
+
/**
|
|
443
|
+
* The directory of the project that holds the deck, as a prefix: "" or "sample/". The folder
|
|
444
|
+
* names the source holds are its author's: an error lists them in its message, as foreignList
|
|
445
|
+
* does, and its hint is dek's words alone.
|
|
446
|
+
*/
|
|
206
447
|
export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
|
|
207
448
|
const repo = source.kind === "file" ? "the pack" : `${source.owner}/${source.repo}`;
|
|
208
449
|
const path = source.kind === "github" ? source.path : undefined;
|
|
@@ -215,17 +456,32 @@ export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
|
|
|
215
456
|
const [match, ...others] = matches;
|
|
216
457
|
if (!match) {
|
|
217
458
|
const names = [...new Set(decks.map((deck) => deck.name))].sort();
|
|
218
|
-
|
|
459
|
+
if (names.length === 0) {
|
|
460
|
+
throw new DekError(`${repo} has no deck "${source.deck}"`, {
|
|
461
|
+
hint: `${repo} holds no dek project (decks/<name>/script.md beside a dek.toml)`,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
throw new DekError(`${repo} has no deck "${source.deck}", only ${listDecks(names)}`, {
|
|
219
465
|
hint:
|
|
220
|
-
|
|
221
|
-
? `
|
|
222
|
-
:
|
|
466
|
+
source.kind === "file"
|
|
467
|
+
? `add the pack you were sent with \`dekc ref <file>.tgz\`, which pins the deck it holds`
|
|
468
|
+
: names.some(isRefDeckName)
|
|
469
|
+
? `pin one of them: \`dekc ref ${repo}/<deck>\``
|
|
470
|
+
: `${REF_DECK_NAME_RULE}; ask its author to rename the deck's folder`,
|
|
223
471
|
});
|
|
224
472
|
}
|
|
225
473
|
if (others.length > 0) {
|
|
226
474
|
const paths = matches.map((deck) => `${deck.prefix}decks/${deck.name}`).sort();
|
|
227
|
-
|
|
228
|
-
|
|
475
|
+
const listed = listCarried(
|
|
476
|
+
paths,
|
|
477
|
+
(path) => path.split("/").every(isRefDeckName),
|
|
478
|
+
(count) => `${count} whose ${count === 1 ? "path" : "paths"} dek does not print`,
|
|
479
|
+
);
|
|
480
|
+
throw new DekError(`${repo} has more than one deck named "${source.deck}", at ${listed}`, {
|
|
481
|
+
hint:
|
|
482
|
+
source.kind === "file"
|
|
483
|
+
? `a pack is one deck; ${PACK_HINT}`
|
|
484
|
+
: `pass the GitHub link to the folder of the one you mean, https://github.com/${repo}/tree/<rev>/<path>`,
|
|
229
485
|
});
|
|
230
486
|
}
|
|
231
487
|
return match.prefix;
|
|
@@ -319,13 +575,11 @@ export async function writeSnapshot(
|
|
|
319
575
|
pin: string,
|
|
320
576
|
snapshot: Snapshot,
|
|
321
577
|
): Promise<{ dir: string; license: string | null; path: string }> {
|
|
322
|
-
// refs/ is dek's; a link on the way would aim the swap below, and its delete, elsewhere.
|
|
323
578
|
const refsDir = join(root, "refs");
|
|
324
579
|
const dir = refDir(root, name);
|
|
580
|
+
// A link on the way would aim the swap below, and its delete, at what it points to.
|
|
581
|
+
refuseLinkUnderRefs(root, dir);
|
|
325
582
|
outputDir(dirname(dir), root);
|
|
326
|
-
if (isLink(dir)) {
|
|
327
|
-
await rm(dir);
|
|
328
|
-
}
|
|
329
583
|
const suffix = `${process.pid}-${Date.now().toString(36)}`;
|
|
330
584
|
const temp = join(refsDir, `.tmp-${suffix}`);
|
|
331
585
|
const old = join(refsDir, `.old-${suffix}`);
|
package/src/core/resolve.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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
|
+
import { compareCodeUnits } from "./order.ts";
|
|
6
7
|
import { parseScript } from "./parse.ts";
|
|
7
|
-
import { deckProjectRoot } from "./path.ts";
|
|
8
|
+
import { deckProjectRoot, isInside } from "./path.ts";
|
|
8
9
|
import { readSourceIfExists, requireInside } from "./safe-fs.ts";
|
|
9
10
|
import type { Deck, Section } from "./schema.ts";
|
|
10
11
|
import { suggest } from "./suggest.ts";
|
|
@@ -14,6 +15,11 @@ export type ProjectDeck = {
|
|
|
14
15
|
dir: string;
|
|
15
16
|
scriptPath: string;
|
|
16
17
|
deck: Deck;
|
|
18
|
+
/**
|
|
19
|
+
* Set on a ref's deck: the folder that stands for its `.cache`, in the project that reads it,
|
|
20
|
+
* and that project's root. A snapshot is never written once `dekc ref` wrote it.
|
|
21
|
+
*/
|
|
22
|
+
cache?: { dir: string; root: string };
|
|
17
23
|
};
|
|
18
24
|
|
|
19
25
|
export type Project = {
|
|
@@ -28,11 +34,6 @@ export type ResolvedDeck = {
|
|
|
28
34
|
deck: ProjectDeck;
|
|
29
35
|
};
|
|
30
36
|
|
|
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
37
|
/**
|
|
37
38
|
* A file of the deck's, or nothing when there is none. It may be a symlink to elsewhere in the
|
|
38
39
|
* project, but not out of it: what a deck reads, a build publishes.
|
|
@@ -115,7 +116,7 @@ export function listSlideFiles(
|
|
|
115
116
|
readdirSync(dir)
|
|
116
117
|
// A declaration file carries types for the editor, not a slide.
|
|
117
118
|
.filter((name) => name.endsWith(ext) && !name.endsWith(".d.ts"))
|
|
118
|
-
.sort(
|
|
119
|
+
.sort(compareCodeUnits)
|
|
119
120
|
.flatMap((name) => {
|
|
120
121
|
const path = join(dir, name);
|
|
121
122
|
if (!statSync(requireInside(path, root)).isFile()) {
|
|
@@ -150,16 +151,27 @@ function loadDecks(root: string): {
|
|
|
150
151
|
if (!existsSync(decksDir)) {
|
|
151
152
|
return { decks: [], failed: [] };
|
|
152
153
|
}
|
|
153
|
-
|
|
154
|
+
// A link out of the project would list, serve, and watch the folders of wherever it leads. It
|
|
155
|
+
// may lead elsewhere in the project, but not into a hidden folder; a folder has no kind to keep.
|
|
156
|
+
const realRoot = realpathSync(root);
|
|
157
|
+
const realDecks = realpathSync(decksDir);
|
|
158
|
+
const rel = relative(realRoot, realDecks);
|
|
159
|
+
if (!isInside(realDecks, realRoot) || rel.split(sep).some((part) => part.startsWith("."))) {
|
|
160
|
+
throw new DekError("decks leads outside the project", {
|
|
161
|
+
path: decksDir,
|
|
162
|
+
hint: "dek follows decks/ only to a folder inside the project and outside hidden folders; move the decks into the project instead",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (!statSync(realDecks).isDirectory()) {
|
|
154
166
|
throw new DekError("decks is not a directory", { path: decksDir });
|
|
155
167
|
}
|
|
156
168
|
|
|
157
169
|
const decks: ProjectDeck[] = [];
|
|
158
170
|
const failed: Project["failed"] = [];
|
|
159
171
|
|
|
160
|
-
for (const entry of readdirSync(
|
|
172
|
+
for (const entry of readdirSync(realDecks, { withFileTypes: true })
|
|
161
173
|
.filter((item) => item.isDirectory())
|
|
162
|
-
.sort((a, b) => a.name
|
|
174
|
+
.sort((a, b) => compareCodeUnits(a.name, b.name))) {
|
|
163
175
|
const loaded = readDeck(root, entry.name);
|
|
164
176
|
if (loaded.ok) {
|
|
165
177
|
decks.push(loaded.deck);
|