@hajimism/dek 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +2 -1
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +84 -14
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +11 -6
- package/src/cli/contract.ts +9 -1
- package/src/cli/files.ts +1 -5
- package/src/cli/flags.ts +1 -1
- package/src/cli/goto.ts +3 -1
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +4 -3
- package/src/cli/main.ts +7 -5
- package/src/cli/new.ts +14 -7
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +91 -9
- package/src/cli/result.ts +5 -2
- package/src/cli/scope.ts +17 -28
- package/src/cli/shot.ts +2 -1
- package/src/cli/text.ts +27 -2
- package/src/cli/theme.ts +16 -2
- package/src/cli/voice.ts +4 -3
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- package/src/core/config.ts +61 -12
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +6 -2
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +3 -1
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +30 -10
- package/src/core/pack.ts +2 -2
- package/src/core/playwright-module.d.ts +1 -0
- package/src/core/playwright-visual.ts +180 -20
- package/src/core/playwright.ts +202 -25
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +55 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +47 -8
- package/src/core/ref.ts +172 -16
- package/src/core/resolve.ts +20 -9
- package/src/core/shot/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +25 -17
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +10 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/sync.ts +2 -2
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +154 -15
- package/src/core/voice.ts +3 -5
- package/src/core/zod.ts +9 -1
- package/src/server/dev.ts +15 -4
- package/src/server/lock.ts +48 -5
- package/src/server/routes.ts +7 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
package/src/core/pptx.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { type DistOptions, distFile } from "./path.ts";
|
|
5
5
|
import { type PlaywrightRunner, requirePlaywright } from "./playwright.ts";
|
|
6
|
-
import { pptxPackage } from "./pptx-package.ts";
|
|
6
|
+
import { mapPictures, type PptxLayer, pptxPackage } from "./pptx-package.ts";
|
|
7
|
+
import { deckTheme } from "./pptx-theme.ts";
|
|
7
8
|
import { presenterSlides } from "./presenter.ts";
|
|
8
9
|
import { asResolvedDeck, type ResolvedDeck } from "./resolve.ts";
|
|
9
10
|
import { writeInside } from "./safe-fs.ts";
|
|
@@ -11,7 +12,16 @@ import { logicalSize } from "./size.ts";
|
|
|
11
12
|
import { lastStop } from "./step.ts";
|
|
12
13
|
import { loadSlideSources, renderSlideHtml } from "./still-page.ts";
|
|
13
14
|
|
|
14
|
-
export type PptxResult = {
|
|
15
|
+
export type PptxResult = {
|
|
16
|
+
outPath: string;
|
|
17
|
+
/**
|
|
18
|
+
* What was laid over the pictures: how many shapes, lines included, and pictures, and the
|
|
19
|
+
* slugs of the slides that kept everything in their picture.
|
|
20
|
+
*/
|
|
21
|
+
lifted: PptxLifted;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type PptxLifted = { shapes: number; pictures: number; untouched: string[] };
|
|
15
25
|
|
|
16
26
|
export type PptxOptions = DistOptions & {
|
|
17
27
|
/** For anyone who has the file: the notes leave out the stage directions and comments. */
|
|
@@ -20,9 +30,10 @@ export type PptxOptions = DistOptions & {
|
|
|
20
30
|
};
|
|
21
31
|
|
|
22
32
|
/**
|
|
23
|
-
* The deck as a PowerPoint file: each slide at its last beat, as the PDF prints it,
|
|
24
|
-
*
|
|
25
|
-
* notes. A slide script that cannot run stops it, as it
|
|
33
|
+
* The deck as a PowerPoint file: each slide at its last beat, as the PDF prints it, its plain
|
|
34
|
+
* boxes, pictures, and text as PowerPoint's own over a picture of the rest, in the deck's theme
|
|
35
|
+
* (ADRs 20 to 22), and its script in the notes. A slide script that cannot run stops it, as it
|
|
36
|
+
* stops the PDF.
|
|
26
37
|
*/
|
|
27
38
|
export async function pptxDeck(dir: string, options?: PptxOptions): Promise<PptxResult>;
|
|
28
39
|
export async function pptxDeck(source: ResolvedDeck, options?: PptxOptions): Promise<PptxResult>;
|
|
@@ -56,20 +67,56 @@ export async function pptxDeck(
|
|
|
56
67
|
options.runner,
|
|
57
68
|
);
|
|
58
69
|
const scripts = presenterSlides(deck, options.public ? "audience" : "speaker");
|
|
70
|
+
// The deck's tokens are PowerPoint's theme, as most slides have them.
|
|
71
|
+
const theme = deckTheme(answer.slides.flatMap((slide) => (slide.theme ? [slide.theme] : [])));
|
|
59
72
|
const bytes = pptxPackage({
|
|
60
73
|
title: deck.deck.title,
|
|
61
74
|
lang: deck.deck.lang,
|
|
62
75
|
size,
|
|
76
|
+
theme,
|
|
63
77
|
slides: answer.slides.map((slide, index) => ({
|
|
64
|
-
picture
|
|
78
|
+
...(slide.picture === false
|
|
79
|
+
? {}
|
|
80
|
+
: { picture: readFileSync(join(shots, `${index + 1}.png`)) }),
|
|
81
|
+
...(slide.background ? { background: slide.background } : {}),
|
|
65
82
|
description: slide.description || (sections[index]?.title ?? slide.slug),
|
|
66
|
-
|
|
83
|
+
layers: mapPictures(slide.layers, ({ png, svg, ...picture }) => ({
|
|
84
|
+
...picture,
|
|
85
|
+
png: readFileSync(png),
|
|
86
|
+
...(svg === undefined ? {} : { svg }),
|
|
87
|
+
})),
|
|
67
88
|
notes: scripts[index]?.script ?? "",
|
|
68
89
|
})),
|
|
69
90
|
});
|
|
70
91
|
writeInside(outPath, bytes, project.root);
|
|
92
|
+
const lifted: PptxLifted = { shapes: 0, pictures: 0, untouched: [] };
|
|
93
|
+
for (const slide of answer.slides) {
|
|
94
|
+
const counts = countLifted(slide.layers);
|
|
95
|
+
lifted.shapes += counts.shapes;
|
|
96
|
+
lifted.pictures += counts.pictures;
|
|
97
|
+
if (counts.shapes + counts.pictures === 0) {
|
|
98
|
+
lifted.untouched.push(slide.slug);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { outPath, lifted };
|
|
71
102
|
} finally {
|
|
72
103
|
rmSync(shots, { recursive: true, force: true });
|
|
73
104
|
}
|
|
74
|
-
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function countLifted<I>(layers: PptxLayer<I>[]): { shapes: number; pictures: number } {
|
|
108
|
+
let shapes = 0;
|
|
109
|
+
let pictures = 0;
|
|
110
|
+
for (const layer of layers) {
|
|
111
|
+
if (layer.kind === "shape") {
|
|
112
|
+
const inner = countLifted(layer.layers);
|
|
113
|
+
shapes += 1 + inner.shapes;
|
|
114
|
+
pictures += inner.pictures;
|
|
115
|
+
} else if (layer.kind === "rule") {
|
|
116
|
+
shapes += 1;
|
|
117
|
+
} else if (layer.kind === "picture") {
|
|
118
|
+
pictures += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { shapes, pictures };
|
|
75
122
|
}
|
package/src/core/prose.ts
CHANGED
|
@@ -14,3 +14,26 @@ function joined(items: string[], conjunction: string): string {
|
|
|
14
14
|
}
|
|
15
15
|
return `${items.slice(0, -1).join(", ")}, ${conjunction} ${items.at(-1)}`;
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Names from someone else's files as a hint lists them: those `shows` passes, then how many others
|
|
20
|
+
* there are, in the words of `others`. An other could hold anything, and an agent takes a hint as
|
|
21
|
+
* dek's own words.
|
|
22
|
+
*/
|
|
23
|
+
export function listShown(
|
|
24
|
+
names: string[],
|
|
25
|
+
shows: (name: string) => boolean,
|
|
26
|
+
others: (count: number) => string,
|
|
27
|
+
): string {
|
|
28
|
+
const shown = names.filter(shows);
|
|
29
|
+
const left = names.length - shown.length;
|
|
30
|
+
if (left === 0) {
|
|
31
|
+
return shown.join(", ");
|
|
32
|
+
}
|
|
33
|
+
return shown.length === 0 ? others(left) : `${shown.join(", ")}, and ${others(left)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** How many names a list leaves out because they are no plain word: see `listShown`. */
|
|
37
|
+
export function unprinted(count: number): string {
|
|
38
|
+
return `${count} whose ${count === 1 ? "name" : "names"} dek does not print`;
|
|
39
|
+
}
|
package/src/core/pseudo-text.ts
CHANGED
|
@@ -14,17 +14,34 @@ type DomNode = {
|
|
|
14
14
|
* `<host>::<pseudo>`. The page exposes no box for a pseudo-element, and Chromium, which lays it
|
|
15
15
|
* out, does: through the DevTools protocol. One that is not laid out has no box and is left out.
|
|
16
16
|
*/
|
|
17
|
-
export
|
|
17
|
+
export function pseudoTextBoxes(page: Page): Promise<Map<string, Box>> {
|
|
18
|
+
return pseudoElementBoxes(page, PSEUDO_TEXT_HOST, "content", ["before", "after"]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The `content` or `border` box, in the viewport, of each pseudo-element of the `types` given
|
|
23
|
+
* of every element marked with `attr`, keyed `<the attribute's value>::<pseudo>`.
|
|
24
|
+
*/
|
|
25
|
+
export async function pseudoElementBoxes(
|
|
26
|
+
page: Page,
|
|
27
|
+
attr: string,
|
|
28
|
+
model: "content" | "border",
|
|
29
|
+
types: ReadonlyArray<"before" | "after" | "marker">,
|
|
30
|
+
): Promise<Map<string, Box>> {
|
|
18
31
|
const cdp = await page.context().newCDPSession(page);
|
|
19
32
|
try {
|
|
20
33
|
const { root } = (await cdp.send("DOM.getDocument", { depth: -1 })) as { root: DomNode };
|
|
21
34
|
const boxes = new Map<string, Box>();
|
|
22
35
|
for (const node of walk(root)) {
|
|
23
|
-
const host = attribute(node,
|
|
36
|
+
const host = attribute(node, attr);
|
|
24
37
|
for (const pseudo of host === undefined ? [] : (node.pseudoElements ?? [])) {
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
-
|
|
38
|
+
const type = types.find((each) => each === pseudo.pseudoType);
|
|
39
|
+
if (type === undefined) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const box = await boxOf(cdp, pseudo.nodeId, model);
|
|
43
|
+
if (box) {
|
|
44
|
+
boxes.set(`${host}::${type}`, box);
|
|
28
45
|
}
|
|
29
46
|
}
|
|
30
47
|
}
|
|
@@ -48,13 +65,18 @@ function attribute(node: DomNode, name: string): string | undefined {
|
|
|
48
65
|
return at >= 0 && at % 2 === 0 ? attributes[at + 1] : undefined;
|
|
49
66
|
}
|
|
50
67
|
|
|
51
|
-
async function
|
|
68
|
+
async function boxOf(
|
|
69
|
+
cdp: CDPSession,
|
|
70
|
+
nodeId: number,
|
|
71
|
+
model: "content" | "border",
|
|
72
|
+
): Promise<Box | undefined> {
|
|
52
73
|
try {
|
|
53
|
-
const { model } = (await cdp.send("DOM.getBoxModel", { nodeId })) as {
|
|
54
|
-
model: { content: number[] };
|
|
74
|
+
const { model: boxes } = (await cdp.send("DOM.getBoxModel", { nodeId })) as {
|
|
75
|
+
model: { content: number[]; border: number[] };
|
|
55
76
|
};
|
|
56
|
-
const
|
|
57
|
-
const
|
|
77
|
+
const quad = boxes[model];
|
|
78
|
+
const xs = quad.filter((_, index) => index % 2 === 0);
|
|
79
|
+
const ys = quad.filter((_, index) => index % 2 === 1);
|
|
58
80
|
return {
|
|
59
81
|
left: Math.min(...xs),
|
|
60
82
|
top: Math.min(...ys),
|
package/src/core/ref-name.ts
CHANGED
|
@@ -35,12 +35,24 @@ const PACK_OWNER = "file";
|
|
|
35
35
|
|
|
36
36
|
const OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
|
|
37
37
|
const REPO_RE = /^[A-Za-z0-9._-]+$/;
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* A ref's deck name: letters, marks, and digits of any script, dots, dashes, and underscores, not
|
|
40
|
+
* first a dash. It is chosen by whoever made the pack or the repository, and dek prints it in the
|
|
41
|
+
* commands it suggests and in AGENTS.md, inside a code span: no shell, code span, or command line
|
|
42
|
+
* reads anything in it.
|
|
43
|
+
*/
|
|
44
|
+
const DECK_RE = /^[\p{L}\p{M}\p{N}._][\p{L}\p{M}\p{N}._-]{0,63}$/u;
|
|
45
|
+
|
|
46
|
+
/** Bytes a ref's deck name may take: its pack, `<deck>.tgz`, stays within a file name's 255. */
|
|
47
|
+
const DECK_MAX_BYTES = 200;
|
|
39
48
|
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
40
49
|
const DIGEST_RE = /^[0-9a-f]{64}$/;
|
|
41
50
|
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
/** What a ref's deck name may hold, as a hint says it. */
|
|
52
|
+
export const REF_DECK_NAME_RULE =
|
|
53
|
+
"a ref names its deck with up to 64 letters, digits, dots, dashes, and underscores, not first a dash, each accented letter one character (NFC)";
|
|
54
|
+
|
|
55
|
+
const REF_HINT = `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>; ${REF_DECK_NAME_RULE}`;
|
|
44
56
|
|
|
45
57
|
/** Where a pack's ref keeps its pack, relative to the project root: committed, never ignored. */
|
|
46
58
|
export function packFile(deck: string): string {
|
|
@@ -78,9 +90,19 @@ export function isPinFor(source: RefSource, pin: string): boolean {
|
|
|
78
90
|
return source.kind === "file" ? DIGEST_RE.test(pin) : SHA_RE.test(pin);
|
|
79
91
|
}
|
|
80
92
|
|
|
81
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* True for a deck name a ref can carry: see DECK_RE; not . or ..; and composed (NFC), since a
|
|
95
|
+
* file system that ignores the form would give two spellings of one name one folder.
|
|
96
|
+
*/
|
|
82
97
|
export function isRefDeckName(deck: string | undefined): deck is string {
|
|
83
|
-
return
|
|
98
|
+
return (
|
|
99
|
+
deck !== undefined &&
|
|
100
|
+
DECK_RE.test(deck) &&
|
|
101
|
+
deck === deck.normalize("NFC") &&
|
|
102
|
+
Buffer.byteLength(deck) <= DECK_MAX_BYTES &&
|
|
103
|
+
deck !== "." &&
|
|
104
|
+
deck !== ".."
|
|
105
|
+
);
|
|
84
106
|
}
|
|
85
107
|
|
|
86
108
|
export function parseRefSource(input: string): RefSource {
|
|
@@ -133,22 +155,39 @@ export function parseRefSource(input: string): RefSource {
|
|
|
133
155
|
*/
|
|
134
156
|
function parseGithubUrl(input: string): GithubRefSource {
|
|
135
157
|
const url = new URL(input);
|
|
136
|
-
const [owner = "", repo = "", kind, rev, ...
|
|
137
|
-
|
|
138
|
-
const
|
|
158
|
+
const [owner = "", repo = "", kind, rev, ...encoded] = url.pathname.split("/").filter(Boolean);
|
|
159
|
+
// The folders after the rev as the repository names them; GitHub's link encodes any but ASCII.
|
|
160
|
+
const rest = decodeSegments(encoded);
|
|
161
|
+
const decks = rest?.indexOf("decks") ?? -1;
|
|
162
|
+
const deck = decks === -1 ? undefined : rest?.[decks + 1];
|
|
139
163
|
if (
|
|
140
164
|
url.hostname !== "github.com" ||
|
|
141
165
|
(kind !== "tree" && kind !== "blob") ||
|
|
142
166
|
!rev ||
|
|
167
|
+
rest === undefined ||
|
|
143
168
|
deck === undefined
|
|
144
169
|
) {
|
|
145
170
|
throw new DekError(`"${input}" is not a link to a deck on GitHub`, {
|
|
146
171
|
hint: "link to the deck's folder, e.g. https://github.com/hajimism/dek/tree/main/sample/decks/why-dek, or name it as owner/repo/deck",
|
|
147
172
|
});
|
|
148
173
|
}
|
|
174
|
+
if (!isRefDeckName(deck)) {
|
|
175
|
+
throw new DekError(`"${input}" is not a link to a deck a ref can name`, {
|
|
176
|
+
hint: REF_DECK_NAME_RULE,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
149
179
|
const source = parseRefSource(`${owner}/${repo.replace(/\.git$/, "")}/${deck}@${rev}`);
|
|
150
180
|
if (source.kind !== "github") {
|
|
151
181
|
throw new DekError(`"${input}" is not a link to a deck on GitHub`, { hint: REF_HINT });
|
|
152
182
|
}
|
|
153
183
|
return { ...source, path: rest.slice(0, decks + 2).join("/") };
|
|
154
184
|
}
|
|
185
|
+
|
|
186
|
+
/** Each segment of a link's path decoded, or nothing when one is not valid percent-encoding. */
|
|
187
|
+
function decodeSegments(segments: string[]): string[] | undefined {
|
|
188
|
+
try {
|
|
189
|
+
return segments.map((segment) => decodeURIComponent(segment));
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|
package/src/core/ref.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { loadConfig } from "./config.ts";
|
|
6
6
|
import { DekError } from "./error.ts";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { shellQuote } from "./escape.ts";
|
|
8
|
+
import { listShown } from "./prose.ts";
|
|
9
|
+
import {
|
|
10
|
+
isPlainRefName,
|
|
11
|
+
isRefDeckName,
|
|
12
|
+
packFile,
|
|
13
|
+
parseRefSource,
|
|
14
|
+
REF_DECK_NAME_RULE,
|
|
15
|
+
type RefSource,
|
|
16
|
+
} from "./ref-name.ts";
|
|
17
|
+
import { listSlides, type Project, resolveProject } from "./resolve.ts";
|
|
18
|
+
import { followInside, outputDir } from "./safe-fs.ts";
|
|
10
19
|
|
|
11
20
|
export {
|
|
12
21
|
isPinnedRev,
|
|
@@ -24,6 +33,49 @@ function isLink(path: string): boolean {
|
|
|
24
33
|
}
|
|
25
34
|
}
|
|
26
35
|
|
|
36
|
+
/** The first link on the way from `<root>/<top>` down to `path`, both included, if any. */
|
|
37
|
+
function linkUnder(root: string, top: "refs" | "packs", path: string): string | undefined {
|
|
38
|
+
let at = join(root, top);
|
|
39
|
+
for (const segment of ["", ...relative(at, path).split(sep)]) {
|
|
40
|
+
at = join(at, segment);
|
|
41
|
+
if (isLink(at)) {
|
|
42
|
+
return at;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Refuses a link on the way to the snapshot at `dir`. refs/ is dek's own folder, which a cloned
|
|
50
|
+
* project can still commit: a link there, even one that stays in the project, would aim a
|
|
51
|
+
* snapshot's swap and its delete at what it points to, such as a deck under decks/.
|
|
52
|
+
*/
|
|
53
|
+
export function refuseLinkUnderRefs(root: string, dir: string): void {
|
|
54
|
+
const link = linkUnder(root, "refs", dir);
|
|
55
|
+
if (link !== undefined) {
|
|
56
|
+
const rel = relative(root, link);
|
|
57
|
+
throw new DekError(`${rel} is a symlink; dek follows no link under refs/`, {
|
|
58
|
+
path: link,
|
|
59
|
+
hint: `remove ${rel}: refs/ holds only the snapshots \`dekc ref\` writes, and the next read of a pinned ref fetches it again`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Refuses a link at packs/, or at the copy of `deck`'s pack kept there. dek writes and removes
|
|
66
|
+
* that copy by name, so through a link it would overwrite, or delete, what the link points to.
|
|
67
|
+
*/
|
|
68
|
+
export function refuseLinkUnderPacks(root: string, deck: string): void {
|
|
69
|
+
const link = linkUnder(root, "packs", join(root, packFile(deck)));
|
|
70
|
+
if (link !== undefined) {
|
|
71
|
+
const rel = relative(root, link);
|
|
72
|
+
throw new DekError(`${rel} is a symlink; dek follows no link under packs/`, {
|
|
73
|
+
path: link,
|
|
74
|
+
hint: `remove ${rel}, then run \`dekc ref <file>.tgz\` with the pack you were sent: packs/ holds the copies it keeps, as plain files`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
27
79
|
/** Written into each snapshot; its presence is what makes a directory a ref. */
|
|
28
80
|
export const REF_MARKER = ".ref.json";
|
|
29
81
|
|
|
@@ -44,8 +96,10 @@ export function refDir(root: string, name: string): string {
|
|
|
44
96
|
|
|
45
97
|
/** The marker of a snapshot, or nothing when it is missing or not one dek wrote. */
|
|
46
98
|
export function readRefMeta(dir: string): RefMeta | undefined {
|
|
47
|
-
const
|
|
48
|
-
|
|
99
|
+
const marker = join(dir, REF_MARKER);
|
|
100
|
+
// dek writes the marker as a plain file; a link out of the snapshot is none of its.
|
|
101
|
+
const path = existsSync(marker) ? followInside(marker, dir) : undefined;
|
|
102
|
+
if (path === undefined) {
|
|
49
103
|
return undefined;
|
|
50
104
|
}
|
|
51
105
|
try {
|
|
@@ -56,6 +110,87 @@ export function readRefMeta(dir: string): RefMeta | undefined {
|
|
|
56
110
|
}
|
|
57
111
|
}
|
|
58
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The snapshot `dir` is in, and the ref it holds: the outermost folder from `dir` up that is one
|
|
115
|
+
* dek wrote. A ref may carry a dek.toml, and a marker, of its own in its deck's assets; neither
|
|
116
|
+
* hides the snapshot around them or names it. A marker anywhere else makes nothing a ref.
|
|
117
|
+
*/
|
|
118
|
+
function refAround(dir: string): { dir: string; name: string } | undefined {
|
|
119
|
+
let found: { dir: string; name: string } | undefined;
|
|
120
|
+
for (let at = resolve(dir); ; at = dirname(at)) {
|
|
121
|
+
const name = snapshotName(at);
|
|
122
|
+
if (name !== undefined) {
|
|
123
|
+
found = { dir: at, name };
|
|
124
|
+
}
|
|
125
|
+
if (dirname(at) === at) {
|
|
126
|
+
return found;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The ref `dir` holds, when it is a snapshot as dek writes one: a dek.toml beside a marker that
|
|
133
|
+
* names a ref, at that ref's own place under a project's refs/.
|
|
134
|
+
*/
|
|
135
|
+
function snapshotName(dir: string): string | undefined {
|
|
136
|
+
if (!isThere(join(dir, "dek.toml"))) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const name = readRefMeta(dir)?.name;
|
|
140
|
+
if (name === undefined || !isPlainRefName(name)) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
// The same folder, not the same spelling: a disk that ignores case, or a link at refs/, gives
|
|
144
|
+
// the path to a snapshot another spelling than its name.
|
|
145
|
+
const place = refDir("", name);
|
|
146
|
+
const project = resolve(dir, ...place.split(sep).map(() => ".."));
|
|
147
|
+
return isSameFolder(join(project, place), dir) ? name : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isSameFolder(a: string, b: string): boolean {
|
|
151
|
+
try {
|
|
152
|
+
const [one, other] = [statSync(a), statSync(b)];
|
|
153
|
+
return one.dev === other.dev && one.ino === other.ino;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isThere(path: string): boolean {
|
|
160
|
+
try {
|
|
161
|
+
lstatSync(path);
|
|
162
|
+
return true;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Refuses `dir` when it is inside a ref: no command, the dev server's included, runs or writes there. */
|
|
169
|
+
export function refuseInsideRef(dir: string): void {
|
|
170
|
+
const ref = refAround(dir);
|
|
171
|
+
if (ref !== undefined) {
|
|
172
|
+
throw new DekError("this directory is inside a ref; refs are read-only", {
|
|
173
|
+
path: ref.dir,
|
|
174
|
+
hint: `run dek from your own project instead: \`dekc show ${ref.name} <slug>\``,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The project around `cwd`, which is the user's own: never one inside a ref. */
|
|
180
|
+
export function requireProject(cwd: string): Project {
|
|
181
|
+
const project = resolveProject(cwd);
|
|
182
|
+
refuseInsideRef(project.root);
|
|
183
|
+
return project;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** What a command that writes says when it is handed a ref by name. */
|
|
187
|
+
export function refReadOnlyError(name: string): DekError {
|
|
188
|
+
const ref = shellQuote(name);
|
|
189
|
+
return new DekError(`"${name}" is a ref; refs are read-only`, {
|
|
190
|
+
hint: `start a deck on its theme with \`dekc new <name> --theme-from ${ref}\`, or read it with \`dekc show ${ref} <slug>\` and write what you need in your own deck's theme`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
59
194
|
export const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
|
|
60
195
|
|
|
61
196
|
/** The snapshot's license file, relative to the snapshot, when the source had one. */
|
|
@@ -81,7 +216,11 @@ export function refState(project: { root: string; configPath: string }, name: st
|
|
|
81
216
|
name,
|
|
82
217
|
...(pinned !== undefined ? { pinned } : {}),
|
|
83
218
|
dir,
|
|
84
|
-
|
|
219
|
+
// Never through a link under refs/: one is fetched again, which refuses the link.
|
|
220
|
+
fetched:
|
|
221
|
+
pinned !== undefined &&
|
|
222
|
+
linkUnder(project.root, "refs", dir) === undefined &&
|
|
223
|
+
readRefMeta(dir)?.rev === pinned,
|
|
85
224
|
};
|
|
86
225
|
}
|
|
87
226
|
|
|
@@ -185,7 +324,16 @@ function repoDecks(files: RepoFiles): Array<{ name: string; prefix: string }> {
|
|
|
185
324
|
});
|
|
186
325
|
}
|
|
187
326
|
|
|
188
|
-
/**
|
|
327
|
+
/** Deck names as a hint lists them: only those a ref can carry. */
|
|
328
|
+
function listDecks(names: string[]): string {
|
|
329
|
+
return listShown(
|
|
330
|
+
names,
|
|
331
|
+
isRefDeckName,
|
|
332
|
+
(count) => `${count} whose ${count === 1 ? "name" : "names"} a ref cannot carry`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The name of the one deck a pack holds; a pack of none, of more, or of one no ref can name is refused. */
|
|
189
337
|
export function deckOfPack(files: RepoFiles): string {
|
|
190
338
|
const names = [...new Set(repoDecks(files).map((deck) => deck.name))].sort();
|
|
191
339
|
const [name, ...others] = names;
|
|
@@ -195,10 +343,15 @@ export function deckOfPack(files: RepoFiles): string {
|
|
|
195
343
|
});
|
|
196
344
|
}
|
|
197
345
|
if (others.length > 0) {
|
|
198
|
-
throw new DekError(`the pack holds more than one deck: ${names
|
|
346
|
+
throw new DekError(`the pack holds more than one deck: ${listDecks(names)}`, {
|
|
199
347
|
hint: `a pack is one deck; ${PACK_HINT}`,
|
|
200
348
|
});
|
|
201
349
|
}
|
|
350
|
+
if (!isRefDeckName(name)) {
|
|
351
|
+
throw new DekError("the pack's deck has a name a ref cannot carry", {
|
|
352
|
+
hint: `${REF_DECK_NAME_RULE}; ask its author to rename the deck's folder and pack it again`,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
202
355
|
return name;
|
|
203
356
|
}
|
|
204
357
|
|
|
@@ -218,14 +371,19 @@ export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
|
|
|
218
371
|
throw new DekError(`${repo} has no deck "${source.deck}"`, {
|
|
219
372
|
hint:
|
|
220
373
|
names.length > 0
|
|
221
|
-
? `decks in ${repo}: ${names
|
|
374
|
+
? `decks in ${repo}: ${listDecks(names)}`
|
|
222
375
|
: `${repo} holds no dek project (decks/<name>/script.md beside a dek.toml)`,
|
|
223
376
|
});
|
|
224
377
|
}
|
|
225
378
|
if (others.length > 0) {
|
|
226
379
|
const paths = matches.map((deck) => `${deck.prefix}decks/${deck.name}`).sort();
|
|
380
|
+
const listed = listShown(
|
|
381
|
+
paths,
|
|
382
|
+
(path) => path.split("/").every(isRefDeckName),
|
|
383
|
+
(count) => `${count} whose ${count === 1 ? "path" : "paths"} a hint cannot show`,
|
|
384
|
+
);
|
|
227
385
|
throw new DekError(`${repo} has more than one deck named "${source.deck}"`, {
|
|
228
|
-
hint: `pass the GitHub link to the one you mean; they are at ${
|
|
386
|
+
hint: `pass the GitHub link to the one you mean; they are at ${listed}`,
|
|
229
387
|
});
|
|
230
388
|
}
|
|
231
389
|
return match.prefix;
|
|
@@ -319,13 +477,11 @@ export async function writeSnapshot(
|
|
|
319
477
|
pin: string,
|
|
320
478
|
snapshot: Snapshot,
|
|
321
479
|
): 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
480
|
const refsDir = join(root, "refs");
|
|
324
481
|
const dir = refDir(root, name);
|
|
482
|
+
// A link on the way would aim the swap below, and its delete, at what it points to.
|
|
483
|
+
refuseLinkUnderRefs(root, dir);
|
|
325
484
|
outputDir(dirname(dir), root);
|
|
326
|
-
if (isLink(dir)) {
|
|
327
|
-
await rm(dir);
|
|
328
|
-
}
|
|
329
485
|
const suffix = `${process.pid}-${Date.now().toString(36)}`;
|
|
330
486
|
const temp = join(refsDir, `.tmp-${suffix}`);
|
|
331
487
|
const old = join(refsDir, `.old-${suffix}`);
|
package/src/core/resolve.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { existsSync, readdirSync,
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { deckPaths } from "./deck-paths.ts";
|
|
4
4
|
import { DekError } from "./error.ts";
|
|
5
5
|
import { walkUp } from "./optional.ts";
|
|
6
6
|
import { parseScript } from "./parse.ts";
|
|
7
|
-
import { deckProjectRoot } from "./path.ts";
|
|
7
|
+
import { deckProjectRoot, isInside } from "./path.ts";
|
|
8
8
|
import { readSourceIfExists, requireInside } from "./safe-fs.ts";
|
|
9
9
|
import type { Deck, Section } from "./schema.ts";
|
|
10
10
|
import { suggest } from "./suggest.ts";
|
|
@@ -14,6 +14,11 @@ export type ProjectDeck = {
|
|
|
14
14
|
dir: string;
|
|
15
15
|
scriptPath: string;
|
|
16
16
|
deck: Deck;
|
|
17
|
+
/**
|
|
18
|
+
* Set on a ref's deck: the folder that stands for its `.cache`, in the project that reads it,
|
|
19
|
+
* and that project's root. A snapshot is never written once `dekc ref` wrote it.
|
|
20
|
+
*/
|
|
21
|
+
cache?: { dir: string; root: string };
|
|
17
22
|
};
|
|
18
23
|
|
|
19
24
|
export type Project = {
|
|
@@ -28,11 +33,6 @@ export type ResolvedDeck = {
|
|
|
28
33
|
deck: ProjectDeck;
|
|
29
34
|
};
|
|
30
35
|
|
|
31
|
-
/** The file's text, or nothing when there is no such file. */
|
|
32
|
-
export function readTextIfExists(path: string): string | undefined {
|
|
33
|
-
return existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
36
|
/**
|
|
37
37
|
* A file of the deck's, or nothing when there is none. It may be a symlink to elsewhere in the
|
|
38
38
|
* project, but not out of it: what a deck reads, a build publishes.
|
|
@@ -150,14 +150,25 @@ function loadDecks(root: string): {
|
|
|
150
150
|
if (!existsSync(decksDir)) {
|
|
151
151
|
return { decks: [], failed: [] };
|
|
152
152
|
}
|
|
153
|
-
|
|
153
|
+
// A link out of the project would list, serve, and watch the folders of wherever it leads. It
|
|
154
|
+
// may lead elsewhere in the project, but not into a hidden folder; a folder has no kind to keep.
|
|
155
|
+
const realRoot = realpathSync(root);
|
|
156
|
+
const realDecks = realpathSync(decksDir);
|
|
157
|
+
const rel = relative(realRoot, realDecks);
|
|
158
|
+
if (!isInside(realDecks, realRoot) || rel.split(sep).some((part) => part.startsWith("."))) {
|
|
159
|
+
throw new DekError("decks leads outside the project", {
|
|
160
|
+
path: decksDir,
|
|
161
|
+
hint: "dek follows decks/ only to a folder inside the project and outside hidden folders; move the decks into the project instead",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (!statSync(realDecks).isDirectory()) {
|
|
154
165
|
throw new DekError("decks is not a directory", { path: decksDir });
|
|
155
166
|
}
|
|
156
167
|
|
|
157
168
|
const decks: ProjectDeck[] = [];
|
|
158
169
|
const failed: Project["failed"] = [];
|
|
159
170
|
|
|
160
|
-
for (const entry of readdirSync(
|
|
171
|
+
for (const entry of readdirSync(realDecks, { withFileTypes: true })
|
|
161
172
|
.filter((item) => item.isDirectory())
|
|
162
173
|
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
163
174
|
const loaded = readDeck(root, entry.name);
|
package/src/core/shot/morph.ts
CHANGED
|
@@ -62,7 +62,7 @@ export async function shotMorph(
|
|
|
62
62
|
const step = stepKey(fromSection.beats, from.beatIndex);
|
|
63
63
|
|
|
64
64
|
const html = videoDocument(resolved, options.playerScript);
|
|
65
|
-
const entry = morphEntry(deck
|
|
65
|
+
const entry = morphEntry(deck, options.from, options.to, at, html);
|
|
66
66
|
if (!entry.cached) {
|
|
67
67
|
await requirePlaywright(
|
|
68
68
|
{
|
package/src/core/shot/motion.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { isAbsolute, join, relative } from "node:path";
|
|
3
3
|
import pkg from "../../../package.json";
|
|
4
4
|
import { DekError } from "../error.ts";
|
|
5
|
-
import { deckProjectRoot } from "../path.ts";
|
|
6
5
|
import { type PlaywrightRunner, requirePlaywright } from "../playwright.ts";
|
|
7
6
|
import { deckStops, retreat } from "../position.ts";
|
|
8
7
|
import { asResolvedDeck, type ResolvedDeck, requireSection } from "../resolve.ts";
|
|
@@ -103,7 +102,7 @@ export async function shotMotion(
|
|
|
103
102
|
const key = `${JSON.stringify([pkg.version, SHEET_LAYOUT_VERSION, MOTION_FRACTIONS, plan.from, beats, title])}\0${html}`;
|
|
104
103
|
// Each step keeps its own entry, so runs of different steps neither evict nor race each other.
|
|
105
104
|
const capture = options.step === undefined ? [section.slug] : [section.slug, String(first)];
|
|
106
|
-
const bundle = bundleEntry(deck
|
|
105
|
+
const bundle = bundleEntry(deck, "motion", capture, key);
|
|
107
106
|
const manifest = join(bundle.dir, "motion.json");
|
|
108
107
|
const cached = readMotionManifest(manifest, bundle.dir);
|
|
109
108
|
if (cached) {
|
|
@@ -112,7 +111,7 @@ export async function shotMotion(
|
|
|
112
111
|
}
|
|
113
112
|
// Chromium writes the frames and sheets by name, so no link may stand where one goes.
|
|
114
113
|
dropLinks(bundle.dir);
|
|
115
|
-
dropLinks(outputDir(join(bundle.dir, "frames"),
|
|
114
|
+
dropLinks(outputDir(join(bundle.dir, "frames"), bundle.root));
|
|
116
115
|
|
|
117
116
|
const response = await requirePlaywright(
|
|
118
117
|
{
|
package/src/core/shot/still.ts
CHANGED
|
@@ -84,7 +84,7 @@ export async function shotSheet(
|
|
|
84
84
|
}));
|
|
85
85
|
// The tiles' paths hash what they show, so the key changes whenever a tile does.
|
|
86
86
|
const bundle = bundleEntry(
|
|
87
|
-
deck
|
|
87
|
+
deck,
|
|
88
88
|
"sheets",
|
|
89
89
|
[],
|
|
90
90
|
JSON.stringify([SHEET_LAYOUT_VERSION, deck.deck.title, cells]),
|
|
@@ -136,7 +136,7 @@ export function still(
|
|
|
136
136
|
step: string = stepKey(section.beats, beatIndex),
|
|
137
137
|
): Still {
|
|
138
138
|
const html = renderSlideHtml(sources, section.slug, beatIndex);
|
|
139
|
-
const entry = stillEntry(deck
|
|
139
|
+
const entry = stillEntry(deck, section, beatIndex, html);
|
|
140
140
|
return { html, slug: section.slug, step, screenshotPath: entry.path, entry };
|
|
141
141
|
}
|
|
142
142
|
|