@hajimism/dek 0.1.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/LICENSE +21 -0
- package/README.ja.md +75 -0
- package/README.md +75 -0
- package/package.json +65 -0
- package/src/cli/address.ts +53 -0
- package/src/cli/build.ts +85 -0
- package/src/cli/check.ts +97 -0
- package/src/cli/commands.ts +851 -0
- package/src/cli/contract.ts +444 -0
- package/src/cli/cues.ts +18 -0
- package/src/cli/files.ts +309 -0
- package/src/cli/flags.ts +295 -0
- package/src/cli/format.ts +156 -0
- package/src/cli/goto.ts +96 -0
- package/src/cli/init.ts +136 -0
- package/src/cli/keep-alive.ts +72 -0
- package/src/cli/lint.ts +84 -0
- package/src/cli/ls.ts +119 -0
- package/src/cli/main.ts +231 -0
- package/src/cli/marks.ts +16 -0
- package/src/cli/mv.ts +37 -0
- package/src/cli/new.ts +64 -0
- package/src/cli/pdf.ts +16 -0
- package/src/cli/qr.ts +466 -0
- package/src/cli/ref.ts +165 -0
- package/src/cli/rehearse.ts +42 -0
- package/src/cli/result.ts +161 -0
- package/src/cli/scope.ts +308 -0
- package/src/cli/serve.ts +125 -0
- package/src/cli/shot.ts +134 -0
- package/src/cli/show.ts +103 -0
- package/src/cli/sync.ts +37 -0
- package/src/cli/text.ts +479 -0
- package/src/cli/theme.ts +56 -0
- package/src/cli/tty.ts +81 -0
- package/src/cli/usage.ts +198 -0
- package/src/cli/video.ts +17 -0
- package/src/cli/voice.ts +113 -0
- package/src/cli.ts +4 -0
- package/src/core/assets.ts +239 -0
- package/src/core/build.ts +77 -0
- package/src/core/bundled-theme.ts +15 -0
- package/src/core/capture-go.ts +72 -0
- package/src/core/chrome.ts +128 -0
- package/src/core/config-keys.ts +82 -0
- package/src/core/config.ts +95 -0
- package/src/core/css-scan.ts +158 -0
- package/src/core/css-transform.ts +140 -0
- package/src/core/css-value.ts +127 -0
- package/src/core/css.ts +545 -0
- package/src/core/cue.ts +187 -0
- package/src/core/deck-paths.ts +34 -0
- package/src/core/diagnostic.ts +114 -0
- package/src/core/document.ts +218 -0
- package/src/core/error.ts +31 -0
- package/src/core/escape.ts +16 -0
- package/src/core/fill.ts +100 -0
- package/src/core/finish-beat.ts +30 -0
- package/src/core/github.ts +234 -0
- package/src/core/html-scan.ts +253 -0
- package/src/core/html.ts +234 -0
- package/src/core/in-page-go.ts +128 -0
- package/src/core/lines.ts +43 -0
- package/src/core/lint/asset-refs.ts +109 -0
- package/src/core/lint/context.ts +116 -0
- package/src/core/lint/files.ts +117 -0
- package/src/core/lint/keys.ts +48 -0
- package/src/core/lint/morph.ts +91 -0
- package/src/core/lint/script.ts +102 -0
- package/src/core/lint/shared-styles.ts +165 -0
- package/src/core/lint/slide-html.ts +466 -0
- package/src/core/lint/slide-script.ts +179 -0
- package/src/core/lint/theme-css.ts +295 -0
- package/src/core/lint/timing.ts +66 -0
- package/src/core/lint/tokens.ts +520 -0
- package/src/core/lint/voice.ts +76 -0
- package/src/core/lint.ts +137 -0
- package/src/core/live-protocol.ts +130 -0
- package/src/core/marks.ts +198 -0
- package/src/core/mv.ts +293 -0
- package/src/core/ogp.ts +99 -0
- package/src/core/optional.ts +24 -0
- package/src/core/overflow.ts +220 -0
- package/src/core/page-pool.ts +53 -0
- package/src/core/page.ts +66 -0
- package/src/core/parse.ts +307 -0
- package/src/core/path.ts +58 -0
- package/src/core/pdf.ts +71 -0
- package/src/core/playwright-module.d.ts +43 -0
- package/src/core/playwright-motion.ts +42 -0
- package/src/core/playwright-sheet.ts +32 -0
- package/src/core/playwright-visual.ts +391 -0
- package/src/core/playwright-worker.ts +5 -0
- package/src/core/playwright.ts +431 -0
- package/src/core/position.ts +146 -0
- package/src/core/presenter-state.ts +43 -0
- package/src/core/presenter.ts +131 -0
- package/src/core/prose.ts +16 -0
- package/src/core/pseudo-text.ts +67 -0
- package/src/core/rail-width.ts +4 -0
- package/src/core/ref-name.ts +110 -0
- package/src/core/ref.ts +238 -0
- package/src/core/resolve.ts +228 -0
- package/src/core/rumdl.ts +99 -0
- package/src/core/safe-fs.ts +196 -0
- package/src/core/sarif.ts +135 -0
- package/src/core/schema.ts +89 -0
- package/src/core/script-lines.ts +53 -0
- package/src/core/sheet.ts +223 -0
- package/src/core/shot/morph.ts +80 -0
- package/src/core/shot/motion.ts +187 -0
- package/src/core/shot/still.ts +216 -0
- package/src/core/shot-cache.ts +140 -0
- package/src/core/size.ts +11 -0
- package/src/core/skeleton-record.ts +51 -0
- package/src/core/skeleton.ts +89 -0
- package/src/core/slide-draw.ts +165 -0
- package/src/core/slide-eval-worker.ts +58 -0
- package/src/core/slide-measure.ts +272 -0
- package/src/core/slide-script-eval.ts +258 -0
- package/src/core/slide-script.ts +252 -0
- package/src/core/spawn.ts +128 -0
- package/src/core/step.ts +94 -0
- package/src/core/still-page.ts +70 -0
- package/src/core/suggest.ts +33 -0
- package/src/core/sync.ts +316 -0
- package/src/core/text-contrast.ts +466 -0
- package/src/core/theme-excerpt.ts +187 -0
- package/src/core/theme-facts.ts +51 -0
- package/src/core/timeline.ts +216 -0
- package/src/core/timing.ts +88 -0
- package/src/core/toml-keys.ts +349 -0
- package/src/core/url-attributes.ts +103 -0
- package/src/core/video-document.ts +15 -0
- package/src/core/visual-cache.ts +100 -0
- package/src/core/visual.ts +558 -0
- package/src/core/voice.ts +321 -0
- package/src/core/zod.ts +79 -0
- package/src/runtime/browser.ts +203 -0
- package/src/runtime/clone.ts +52 -0
- package/src/runtime/deck-control.ts +43 -0
- package/src/runtime/fit.ts +32 -0
- package/src/runtime/go.ts +56 -0
- package/src/runtime/input.ts +128 -0
- package/src/runtime/laser-view.ts +110 -0
- package/src/runtime/laser.ts +145 -0
- package/src/runtime/live-host.ts +33 -0
- package/src/runtime/live-reload.ts +29 -0
- package/src/runtime/live.ts +88 -0
- package/src/runtime/marks-view.ts +74 -0
- package/src/runtime/motion.ts +113 -0
- package/src/runtime/navigator.ts +189 -0
- package/src/runtime/player.ts +28 -0
- package/src/runtime/presenter-view.ts +289 -0
- package/src/runtime/presenter.ts +52 -0
- package/src/runtime/rail-view.ts +229 -0
- package/src/runtime/rail.ts +52 -0
- package/src/runtime/rehearse.ts +264 -0
- package/src/runtime/routes.ts +142 -0
- package/src/runtime/settle.ts +45 -0
- package/src/runtime/slide.d.ts +28 -0
- package/src/runtime/socket.ts +143 -0
- package/src/runtime/stage.ts +259 -0
- package/src/runtime/step.ts +139 -0
- package/src/runtime/window.ts +38 -0
- package/src/server/auth.ts +210 -0
- package/src/server/deck-snapshot.ts +146 -0
- package/src/server/dev.ts +251 -0
- package/src/server/hub.ts +104 -0
- package/src/server/lan.ts +51 -0
- package/src/server/lock.ts +97 -0
- package/src/server/pages.ts +71 -0
- package/src/server/pairing.ts +40 -0
- package/src/server/project-watch.ts +142 -0
- package/src/server/rooms.ts +133 -0
- package/src/server/routes.ts +286 -0
- package/src/server/serial.ts +25 -0
- package/src/server/watch.ts +308 -0
- package/src/theme/default.css +175 -0
- package/src/video/bake.ts +152 -0
- package/src/video/mux.ts +103 -0
- package/src/video/recorder.ts +115 -0
- package/src/video/sidecar.ts +60 -0
- package/src/video/worker.ts +51 -0
- package/src/voice/engine.ts +177 -0
- package/src/voice/synth.ts +127 -0
- package/src/voice/wav.ts +115 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { DekConfig } from "./config.ts";
|
|
2
|
+
import type { PresenterSlide } from "./presenter-state.ts";
|
|
3
|
+
import type { ProjectDeck } from "./resolve.ts";
|
|
4
|
+
import { type ScriptLine, scriptLines } from "./script-lines.ts";
|
|
5
|
+
import { formatSectionScript, sectionTiming } from "./timing.ts";
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
nextPresenterTitle,
|
|
9
|
+
type PresenterSlide,
|
|
10
|
+
presenterState,
|
|
11
|
+
} from "./presenter-state.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Who reads the script the page carries: the speaker reads all of it, and a page anyone may open
|
|
15
|
+
* carries only what is said aloud, since the directions and the comments were for the speaker.
|
|
16
|
+
*/
|
|
17
|
+
export type ScriptReader = "speaker" | "audience";
|
|
18
|
+
|
|
19
|
+
export function presenterSlides(
|
|
20
|
+
deck: ProjectDeck,
|
|
21
|
+
config: DekConfig,
|
|
22
|
+
reader: ScriptReader = "speaker",
|
|
23
|
+
): PresenterSlide[] {
|
|
24
|
+
const timing = sectionTiming(deck.deck.sections, deck.deck.duration, config);
|
|
25
|
+
const budgetBySlug = new Map(timing.map((row) => [row.slug, row.budgetSeconds]));
|
|
26
|
+
return deck.deck.sections.map((section) => {
|
|
27
|
+
const script = formatSectionScript(section);
|
|
28
|
+
return {
|
|
29
|
+
slug: section.slug,
|
|
30
|
+
title: section.title,
|
|
31
|
+
script: reflowScript(reader === "audience" ? withoutDirections(script) : script),
|
|
32
|
+
beats: section.beats.map((beat) => ({ id: beat.id, title: beat.title })),
|
|
33
|
+
...(budgetBySlug.get(section.slug) !== undefined
|
|
34
|
+
? { budgetSeconds: budgetBySlug.get(section.slug) }
|
|
35
|
+
: {}),
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const COMMENT_RE = /<!--[\s\S]*?(?:-->|$)/g;
|
|
41
|
+
const DIRECTION_RE = /^\s*>/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The script without what was for the speaker alone: stage directions, which are blockquotes, and
|
|
45
|
+
* HTML comments. A fenced code block is text the slide shows, so a `>` or a comment in it stays.
|
|
46
|
+
* The blank lines a removal leaves are folded into one, as between any two paragraphs.
|
|
47
|
+
*/
|
|
48
|
+
export function withoutDirections(script: string): string {
|
|
49
|
+
const kept: ScriptLine[] = [];
|
|
50
|
+
for (const run of fenceRuns(scriptLines(script))) {
|
|
51
|
+
if (run[0]?.fenced) {
|
|
52
|
+
kept.push(...run);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const prose = run
|
|
56
|
+
.map((line) => line.text)
|
|
57
|
+
.join("\n")
|
|
58
|
+
.replace(COMMENT_RE, "");
|
|
59
|
+
for (const text of prose.split("\n")) {
|
|
60
|
+
if (!DIRECTION_RE.test(text)) {
|
|
61
|
+
kept.push({ text: text.trimEnd(), literal: false, fenced: false });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const out: string[] = [];
|
|
66
|
+
for (const line of kept) {
|
|
67
|
+
const blank = !line.fenced && line.text.trim() === "";
|
|
68
|
+
if (blank && (out.length === 0 || out.at(-1) === "")) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out.push(line.text);
|
|
72
|
+
}
|
|
73
|
+
while (out.at(-1) === "") {
|
|
74
|
+
out.pop();
|
|
75
|
+
}
|
|
76
|
+
return out.join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The lines cut into runs that are all fenced or all not, in order. */
|
|
80
|
+
function fenceRuns(lines: ScriptLine[]): ScriptLine[][] {
|
|
81
|
+
const runs: ScriptLine[][] = [];
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
const run = runs.at(-1);
|
|
84
|
+
if (run && run[0]?.fenced === line.fenced) {
|
|
85
|
+
run.push(line);
|
|
86
|
+
} else {
|
|
87
|
+
runs.push([line]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return runs;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const FENCE_RE = /^\s*(```|~~~)/;
|
|
94
|
+
/** Lines that stand on their own: blank, stage direction, list item, table row, heading. */
|
|
95
|
+
const BLOCK_LINE_RE = /^\s*($|>|[-*+]\s|\d+\.\s|\||#)/;
|
|
96
|
+
const WIDE_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\u3000-\u303f\uff00-\uffef]/u;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Joins the soft line breaks of each paragraph, so the presenter wraps the
|
|
100
|
+
* script to its own width instead of the editor's. Japanese joins without a
|
|
101
|
+
* space; everything else joins with one.
|
|
102
|
+
*/
|
|
103
|
+
export function reflowScript(script: string): string {
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
let inFence = false;
|
|
106
|
+
let joinable = false;
|
|
107
|
+
for (const raw of script.split(/\r?\n/)) {
|
|
108
|
+
if (FENCE_RE.test(raw)) {
|
|
109
|
+
inFence = !inFence;
|
|
110
|
+
out.push(raw);
|
|
111
|
+
joinable = false;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (inFence || BLOCK_LINE_RE.test(raw)) {
|
|
115
|
+
out.push(raw);
|
|
116
|
+
joinable = false;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const line = raw.trim();
|
|
120
|
+
const last = out.length - 1;
|
|
121
|
+
const previous = out[last];
|
|
122
|
+
if (joinable && previous !== undefined) {
|
|
123
|
+
const wide = WIDE_RE.test([...previous].at(-1) ?? "") || WIDE_RE.test([...line][0] ?? "");
|
|
124
|
+
out[last] = `${previous}${wide ? "" : " "}${line}`;
|
|
125
|
+
} else {
|
|
126
|
+
out.push(line);
|
|
127
|
+
}
|
|
128
|
+
joinable = true;
|
|
129
|
+
}
|
|
130
|
+
return out.join("\n");
|
|
131
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** "a", "a and b", "a, b, and c". */
|
|
2
|
+
export function listed(items: string[]): string {
|
|
3
|
+
return joined(items, "and");
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** "a", "a or b", "a, b, or c". */
|
|
7
|
+
export function either(items: string[]): string {
|
|
8
|
+
return joined(items, "or");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function joined(items: string[], conjunction: string): string {
|
|
12
|
+
if (items.length <= 2) {
|
|
13
|
+
return items.join(` ${conjunction} `);
|
|
14
|
+
}
|
|
15
|
+
return `${items.slice(0, -1).join(", ")}, ${conjunction} ${items.at(-1)}`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CDPSession, Page } from "playwright";
|
|
2
|
+
import { type Box, PSEUDO_TEXT_HOST } from "./slide-measure.ts";
|
|
3
|
+
|
|
4
|
+
/** What `DOM.getDocument` gives of a node: enough to find a marked host's pseudo-elements. */
|
|
5
|
+
type DomNode = {
|
|
6
|
+
nodeId: number;
|
|
7
|
+
attributes?: string[];
|
|
8
|
+
children?: DomNode[];
|
|
9
|
+
pseudoElements?: Array<{ nodeId: number; pseudoType?: string }>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The content box of each pseudo-element `measureSlideInPage` marked as drawing text, keyed
|
|
14
|
+
* `<host>::<pseudo>`. The page exposes no box for a pseudo-element, and Chromium, which lays it
|
|
15
|
+
* out, does: through the DevTools protocol. One that is not laid out has no box and is left out.
|
|
16
|
+
*/
|
|
17
|
+
export async function pseudoTextBoxes(page: Page): Promise<Map<string, Box>> {
|
|
18
|
+
const cdp = await page.context().newCDPSession(page);
|
|
19
|
+
try {
|
|
20
|
+
const { root } = (await cdp.send("DOM.getDocument", { depth: -1 })) as { root: DomNode };
|
|
21
|
+
const boxes = new Map<string, Box>();
|
|
22
|
+
for (const node of walk(root)) {
|
|
23
|
+
const host = attribute(node, PSEUDO_TEXT_HOST);
|
|
24
|
+
for (const pseudo of host === undefined ? [] : (node.pseudoElements ?? [])) {
|
|
25
|
+
const box = await contentBox(cdp, pseudo.nodeId);
|
|
26
|
+
if (box && (pseudo.pseudoType === "before" || pseudo.pseudoType === "after")) {
|
|
27
|
+
boxes.set(`${host}::${pseudo.pseudoType}`, box);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return boxes;
|
|
32
|
+
} finally {
|
|
33
|
+
await cdp.detach();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function* walk(node: DomNode): Generator<DomNode> {
|
|
38
|
+
yield node;
|
|
39
|
+
for (const child of node.children ?? []) {
|
|
40
|
+
yield* walk(child);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An attribute's value from CDP's flat `[name, value, name, value, ...]` list. */
|
|
45
|
+
function attribute(node: DomNode, name: string): string | undefined {
|
|
46
|
+
const attributes = node.attributes ?? [];
|
|
47
|
+
const at = attributes.indexOf(name);
|
|
48
|
+
return at >= 0 && at % 2 === 0 ? attributes[at + 1] : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function contentBox(cdp: CDPSession, nodeId: number): Promise<Box | undefined> {
|
|
52
|
+
try {
|
|
53
|
+
const { model } = (await cdp.send("DOM.getBoxModel", { nodeId })) as {
|
|
54
|
+
model: { content: number[] };
|
|
55
|
+
};
|
|
56
|
+
const xs = model.content.filter((_, index) => index % 2 === 0);
|
|
57
|
+
const ys = model.content.filter((_, index) => index % 2 === 1);
|
|
58
|
+
return {
|
|
59
|
+
left: Math.min(...xs),
|
|
60
|
+
top: Math.min(...ys),
|
|
61
|
+
right: Math.max(...xs),
|
|
62
|
+
bottom: Math.max(...ys),
|
|
63
|
+
};
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { DekError } from "./error.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A ref is someone else's deck, kept as a read-only snapshot to read as a
|
|
5
|
+
* model. dek.toml `[refs]` pins each one to a commit; the snapshot under
|
|
6
|
+
* `refs/owner/repo/deck` is only a copy of that commit and can be fetched again.
|
|
7
|
+
* This module only names refs; `ref.ts` fetches and stores them.
|
|
8
|
+
*/
|
|
9
|
+
export type RefSource = {
|
|
10
|
+
/** `owner/repo/deck`, the key in dek.toml `[refs]`. */
|
|
11
|
+
name: string;
|
|
12
|
+
owner: string;
|
|
13
|
+
repo: string;
|
|
14
|
+
deck: string;
|
|
15
|
+
/** A tag, branch, or sha after `@`. */
|
|
16
|
+
rev?: string;
|
|
17
|
+
/** The deck's directory in the repository, known when a GitHub URL named it. */
|
|
18
|
+
path?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
|
|
22
|
+
const REPO_RE = /^[A-Za-z0-9._-]+$/;
|
|
23
|
+
const DECK_RE = /^[^/\\\s@]+$/;
|
|
24
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
25
|
+
|
|
26
|
+
const REF_HINT =
|
|
27
|
+
"name a ref as owner/repo/deck, e.g. hajimism/dek/why-dek or hajimism/dek/why-dek@v1";
|
|
28
|
+
|
|
29
|
+
/** True for `owner/repo/deck[@rev]` or a GitHub link, which a deck name never is. */
|
|
30
|
+
export function isRefName(arg: string): boolean {
|
|
31
|
+
if (/^https?:\/\/(www\.)?github\.com\//i.test(arg)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (arg.startsWith(".") || arg.startsWith("/") || arg.startsWith("~")) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return arg.split("@")[0]?.split("/").length === 3;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A valid `owner/repo/deck` with no rev: the form dek.toml `[refs]` keys take. */
|
|
41
|
+
export function isPlainRefName(name: string): boolean {
|
|
42
|
+
try {
|
|
43
|
+
const source = parseRefSource(name);
|
|
44
|
+
return source.rev === undefined && source.name === name;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isPinnedRev(rev: string): boolean {
|
|
51
|
+
return SHA_RE.test(rev);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseRefSource(input: string): RefSource {
|
|
55
|
+
const trimmed = input.trim();
|
|
56
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
57
|
+
return parseGithubUrl(trimmed);
|
|
58
|
+
}
|
|
59
|
+
const at = trimmed.indexOf("@");
|
|
60
|
+
const path = at === -1 ? trimmed : trimmed.slice(0, at);
|
|
61
|
+
const rev = at === -1 ? undefined : trimmed.slice(at + 1);
|
|
62
|
+
const [owner, repo, deck, ...extra] = path.split("/");
|
|
63
|
+
const valid =
|
|
64
|
+
extra.length === 0 &&
|
|
65
|
+
owner !== undefined &&
|
|
66
|
+
OWNER_RE.test(owner) &&
|
|
67
|
+
repo !== undefined &&
|
|
68
|
+
REPO_RE.test(repo) &&
|
|
69
|
+
repo !== "." &&
|
|
70
|
+
repo !== ".." &&
|
|
71
|
+
deck !== undefined &&
|
|
72
|
+
DECK_RE.test(deck) &&
|
|
73
|
+
deck !== "." &&
|
|
74
|
+
deck !== ".." &&
|
|
75
|
+
(rev === undefined || /^[^\s@]+$/.test(rev));
|
|
76
|
+
if (!valid) {
|
|
77
|
+
throw new DekError(`"${input}" is not a ref name`, { hint: REF_HINT });
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
name: `${owner}/${repo}/${deck}`,
|
|
81
|
+
owner,
|
|
82
|
+
repo,
|
|
83
|
+
deck,
|
|
84
|
+
...(rev !== undefined ? { rev } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `https://github.com/owner/repo/tree/<rev>/<path>/decks/<deck>[/...]`, the
|
|
90
|
+
* link people copy from a browser. A branch name with a slash cannot be told
|
|
91
|
+
* apart from the path, so such a branch goes after `@` instead.
|
|
92
|
+
*/
|
|
93
|
+
function parseGithubUrl(input: string): RefSource {
|
|
94
|
+
const url = new URL(input);
|
|
95
|
+
const [owner = "", repo = "", kind, rev, ...rest] = url.pathname.split("/").filter(Boolean);
|
|
96
|
+
const decks = rest.indexOf("decks");
|
|
97
|
+
const deck = decks === -1 ? undefined : rest[decks + 1];
|
|
98
|
+
if (
|
|
99
|
+
url.hostname !== "github.com" ||
|
|
100
|
+
(kind !== "tree" && kind !== "blob") ||
|
|
101
|
+
!rev ||
|
|
102
|
+
deck === undefined
|
|
103
|
+
) {
|
|
104
|
+
throw new DekError(`"${input}" is not a link to a deck on GitHub`, {
|
|
105
|
+
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",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const source = parseRefSource(`${owner}/${repo.replace(/\.git$/, "")}/${deck}@${rev}`);
|
|
109
|
+
return { ...source, path: rest.slice(0, decks + 2).join("/") };
|
|
110
|
+
}
|
package/src/core/ref.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { loadConfig } from "./config.ts";
|
|
6
|
+
import { DekError } from "./error.ts";
|
|
7
|
+
import { parseRefSource, type RefSource } from "./ref-name.ts";
|
|
8
|
+
import { listSlides, resolveProject } from "./resolve.ts";
|
|
9
|
+
import { outputDir } from "./safe-fs.ts";
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
isPinnedRev,
|
|
13
|
+
isRefName,
|
|
14
|
+
parseRefSource,
|
|
15
|
+
type RefSource,
|
|
16
|
+
} from "./ref-name.ts";
|
|
17
|
+
|
|
18
|
+
function isLink(path: string): boolean {
|
|
19
|
+
try {
|
|
20
|
+
return lstatSync(path).isSymbolicLink();
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Written into each snapshot; its presence is what makes a directory a ref. */
|
|
27
|
+
export const REF_MARKER = ".ref.json";
|
|
28
|
+
|
|
29
|
+
export const RefMeta = z.object({
|
|
30
|
+
name: z.string(),
|
|
31
|
+
rev: z.string(),
|
|
32
|
+
/** The deck's directory inside the source repository. */
|
|
33
|
+
path: z.string(),
|
|
34
|
+
});
|
|
35
|
+
export type RefMeta = z.infer<typeof RefMeta>;
|
|
36
|
+
|
|
37
|
+
export function refDir(root: string, name: string): string {
|
|
38
|
+
const { owner, repo, deck } = parseRefSource(name);
|
|
39
|
+
return join(root, "refs", owner, repo, deck);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The marker of a snapshot, or nothing when it is missing or not one dek wrote. */
|
|
43
|
+
export function readRefMeta(dir: string): RefMeta | undefined {
|
|
44
|
+
const path = join(dir, REF_MARKER);
|
|
45
|
+
if (!existsSync(path)) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const parsed = RefMeta.safeParse(JSON.parse(readFileSync(path, "utf8")));
|
|
50
|
+
return parsed.success ? parsed.data : undefined;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
|
|
57
|
+
|
|
58
|
+
/** The snapshot's license file, relative to the snapshot, when the source had one. */
|
|
59
|
+
export function refLicense(dir: string): string | null {
|
|
60
|
+
return LICENSE_NAMES.find((name) => existsSync(join(dir, name))) ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What the project knows about one ref: its pin in dek.toml and whether the snapshot matches it. */
|
|
64
|
+
export type RefState = {
|
|
65
|
+
name: string;
|
|
66
|
+
/** The commit dek.toml pins, or nothing when the ref is not added. */
|
|
67
|
+
pinned?: string;
|
|
68
|
+
/** The snapshot directory, whether or not anything is there. */
|
|
69
|
+
dir: string;
|
|
70
|
+
/** True when the snapshot on disk is the pinned commit. */
|
|
71
|
+
fetched: boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function refState(project: { root: string; configPath: string }, name: string): RefState {
|
|
75
|
+
const pinned = loadConfig(project.configPath).refs?.[name];
|
|
76
|
+
const dir = refDir(project.root, name);
|
|
77
|
+
return {
|
|
78
|
+
name,
|
|
79
|
+
...(pinned !== undefined ? { pinned } : {}),
|
|
80
|
+
dir,
|
|
81
|
+
fetched: pinned !== undefined && readRefMeta(dir)?.rev === pinned,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A repository's files by path, without the tarball's top directory. */
|
|
86
|
+
export type RepoFiles = Map<string, Blob>;
|
|
87
|
+
|
|
88
|
+
/** Beyond this, a snapshot is refused: a deck to read is a few hundred kilobytes. */
|
|
89
|
+
const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reads a GitHub tarball. Bun leaves out links; a path that is absolute, has
|
|
93
|
+
* an empty segment, or climbs with `..` is dropped here, so nothing written
|
|
94
|
+
* later can land outside the snapshot.
|
|
95
|
+
*/
|
|
96
|
+
export async function readTarball(bytes: Uint8Array): Promise<RepoFiles> {
|
|
97
|
+
const files: RepoFiles = new Map();
|
|
98
|
+
for (const [path, blob] of await new Bun.Archive(bytes).files()) {
|
|
99
|
+
const [, ...segments] = path.split("/");
|
|
100
|
+
const safe =
|
|
101
|
+
segments.length > 0 &&
|
|
102
|
+
!path.includes("\\") &&
|
|
103
|
+
segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
104
|
+
if (safe && !path.startsWith("/")) {
|
|
105
|
+
files.set(segments.join("/"), blob);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return files;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const DECK_SCRIPT_RE = /^(?:(.*)\/)?decks\/([^/]+)\/script\.md$/;
|
|
112
|
+
|
|
113
|
+
/** The decks in a repository: each `decks/<name>/script.md` beside its project's dek.toml. */
|
|
114
|
+
function repoDecks(files: RepoFiles): Array<{ name: string; prefix: string }> {
|
|
115
|
+
return [...files.keys()].flatMap((path) => {
|
|
116
|
+
const match = path.match(DECK_SCRIPT_RE);
|
|
117
|
+
const prefix = match?.[1] ? `${match[1]}/` : "";
|
|
118
|
+
return match?.[2] && files.has(`${prefix}dek.toml`) ? [{ name: match[2], prefix }] : [];
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The directory of the project that holds the deck, as a prefix: "" or "sample/". */
|
|
123
|
+
export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
|
|
124
|
+
const repo = `${source.owner}/${source.repo}`;
|
|
125
|
+
const decks = repoDecks(files);
|
|
126
|
+
const matches = decks.filter(
|
|
127
|
+
(deck) =>
|
|
128
|
+
deck.name === source.deck &&
|
|
129
|
+
(source.path === undefined || `${deck.prefix}decks/${deck.name}` === source.path),
|
|
130
|
+
);
|
|
131
|
+
const [match, ...others] = matches;
|
|
132
|
+
if (!match) {
|
|
133
|
+
const names = [...new Set(decks.map((deck) => deck.name))].sort();
|
|
134
|
+
throw new DekError(`${repo} has no deck "${source.deck}"`, {
|
|
135
|
+
hint:
|
|
136
|
+
names.length > 0
|
|
137
|
+
? `decks in ${repo}: ${names.join(", ")}`
|
|
138
|
+
: `${repo} holds no dek project (decks/<name>/script.md beside a dek.toml)`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (others.length > 0) {
|
|
142
|
+
const paths = matches.map((deck) => `${deck.prefix}decks/${deck.name}`).sort();
|
|
143
|
+
throw new DekError(`${repo} has more than one deck named "${source.deck}"`, {
|
|
144
|
+
hint: `pass the GitHub link to the one you mean; they are at ${paths.join(", ")}`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return match.prefix;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Inside the deck, only what reading it needs: not dist/, .cache/, or voice/. */
|
|
151
|
+
function keepsInDeck(path: string): boolean {
|
|
152
|
+
return (
|
|
153
|
+
path === "script.md" ||
|
|
154
|
+
path === "theme.css" ||
|
|
155
|
+
path.startsWith("slides/") ||
|
|
156
|
+
path.startsWith("assets/")
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Writes the snapshot of one deck at one commit under refs/, laid out as the
|
|
162
|
+
* project it came from so it resolves like any dek project. It is written
|
|
163
|
+
* beside the old one and swapped in, so a failure leaves the old one whole.
|
|
164
|
+
*/
|
|
165
|
+
export async function installSnapshot(
|
|
166
|
+
root: string,
|
|
167
|
+
source: RefSource,
|
|
168
|
+
sha: string,
|
|
169
|
+
files: RepoFiles,
|
|
170
|
+
): Promise<{ dir: string; license: string | null; path: string }> {
|
|
171
|
+
const prefix = findDeckInRepo(files, source);
|
|
172
|
+
const deckPrefix = `${prefix}decks/${source.deck}/`;
|
|
173
|
+
const license =
|
|
174
|
+
LICENSE_NAMES.find((name) => files.has(name)) ??
|
|
175
|
+
LICENSE_NAMES.find((name) => files.has(`${prefix}${name}`));
|
|
176
|
+
const picked = new Map<string, Blob>();
|
|
177
|
+
picked.set("dek.toml", files.get(`${prefix}dek.toml`) ?? new Blob([]));
|
|
178
|
+
for (const [path, blob] of files) {
|
|
179
|
+
const inDeck = path.startsWith(deckPrefix) ? path.slice(deckPrefix.length) : undefined;
|
|
180
|
+
if (inDeck !== undefined && keepsInDeck(inDeck)) {
|
|
181
|
+
picked.set(`decks/${source.deck}/${inDeck}`, blob);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (license) {
|
|
185
|
+
picked.set(license, files.get(license) ?? files.get(`${prefix}${license}`) ?? new Blob([]));
|
|
186
|
+
}
|
|
187
|
+
const size = [...picked.values()].reduce((sum, blob) => sum + blob.size, 0);
|
|
188
|
+
if (size > MAX_SNAPSHOT_BYTES) {
|
|
189
|
+
throw new DekError(`${source.name} is larger than ${MAX_SNAPSHOT_BYTES / 1024 / 1024} MB`, {
|
|
190
|
+
hint: "a deck to read as a model should be far smaller; check its assets",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// refs/ is dek's; a link on the way would aim the swap below, and its delete, elsewhere.
|
|
195
|
+
const refsDir = join(root, "refs");
|
|
196
|
+
const dir = refDir(root, source.name);
|
|
197
|
+
outputDir(dirname(dir), root);
|
|
198
|
+
if (isLink(dir)) {
|
|
199
|
+
await rm(dir);
|
|
200
|
+
}
|
|
201
|
+
const suffix = `${process.pid}-${Date.now().toString(36)}`;
|
|
202
|
+
const temp = join(refsDir, `.tmp-${suffix}`);
|
|
203
|
+
const old = join(refsDir, `.old-${suffix}`);
|
|
204
|
+
const meta: RefMeta = { name: source.name, rev: sha, path: `${prefix}decks/${source.deck}` };
|
|
205
|
+
try {
|
|
206
|
+
for (const [path, blob] of picked) {
|
|
207
|
+
await mkdir(dirname(join(temp, path)), { recursive: true });
|
|
208
|
+
await writeFile(join(temp, path), new Uint8Array(await blob.arrayBuffer()));
|
|
209
|
+
}
|
|
210
|
+
await writeFile(join(temp, REF_MARKER), `${JSON.stringify(meta, null, 2)}\n`);
|
|
211
|
+
if (existsSync(dir)) {
|
|
212
|
+
await rename(dir, old);
|
|
213
|
+
}
|
|
214
|
+
await rename(temp, dir);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (!existsSync(dir) && existsSync(old)) {
|
|
217
|
+
await rename(old, dir);
|
|
218
|
+
}
|
|
219
|
+
throw error;
|
|
220
|
+
} finally {
|
|
221
|
+
await rm(temp, { recursive: true, force: true });
|
|
222
|
+
// Until the snapshot is back in place, `old` may be the only copy left.
|
|
223
|
+
if (existsSync(dir)) {
|
|
224
|
+
await rm(old, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { dir, license: license ?? null, path: meta.path };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The snapshot deck's title and slide count, or nothing when it does not parse. */
|
|
231
|
+
export function refTitle(dir: string, deck: string): { title: string; slides: number } | undefined {
|
|
232
|
+
try {
|
|
233
|
+
const found = resolveProject(dir).decks.find((entry) => entry.name === deck);
|
|
234
|
+
return found ? { title: found.deck.title, slides: listSlides(found.dir).length } : undefined;
|
|
235
|
+
} catch {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
}
|