@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
package/src/core/mv.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { deckPaths } from "./deck-paths.ts";
|
|
4
|
+
import { DekError } from "./error.ts";
|
|
5
|
+
import { escapeRegExp } from "./escape.ts";
|
|
6
|
+
import { joinLines, sectionChunks, splitLines } from "./lines.ts";
|
|
7
|
+
import { asResolvedDeck, type ResolvedDeck, requireSection, SLIDE_SIDECARS } from "./resolve.ts";
|
|
8
|
+
import { Id } from "./schema.ts";
|
|
9
|
+
import { deckLayouts, skeletonHtml } from "./skeleton.ts";
|
|
10
|
+
import { skeletonRecord } from "./skeleton-record.ts";
|
|
11
|
+
import { renameTableKeys } from "./toml-keys.ts";
|
|
12
|
+
|
|
13
|
+
export function renameSection(dir: string, from: string, to: string): void;
|
|
14
|
+
export function renameSection(source: ResolvedDeck, from: string, to: string): void;
|
|
15
|
+
export function renameSection(input: string | ResolvedDeck, from: string, to: string): void {
|
|
16
|
+
if (!Id.safeParse(to).success) {
|
|
17
|
+
throw new DekError(`invalid id "${to}"`, { hint: "use [a-z0-9-] with at least one letter" });
|
|
18
|
+
}
|
|
19
|
+
const { deck } = asResolvedDeck(input);
|
|
20
|
+
const has = (slug: string): boolean => deck.deck.sections.some((entry) => entry.slug === slug);
|
|
21
|
+
if (!has(from) && has(to)) {
|
|
22
|
+
// script.md was edited first; only the files still carry the old id.
|
|
23
|
+
const steps = slideFileSteps(deck, from, to);
|
|
24
|
+
if (steps.length === 0 && !existsSync(deckPaths(deck.dir).slide(to, ".html"))) {
|
|
25
|
+
throw new DekError(`slide "${from}" not found`, {
|
|
26
|
+
path: deckPaths(deck.dir).slide(from, ".html"),
|
|
27
|
+
hint: "run `dekc lint` to see which slides have no section",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
applySteps(steps);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const section = requireSection(deck, from);
|
|
34
|
+
if (has(to)) {
|
|
35
|
+
throw new DekError(`section "${to}" already exists`, {
|
|
36
|
+
path: deck.scriptPath,
|
|
37
|
+
hint: "run `dekc ls`",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const source = readFileSync(deck.scriptPath, "utf8");
|
|
42
|
+
const lines = splitLines(source);
|
|
43
|
+
const headingIndex = section.line - 1;
|
|
44
|
+
const heading = lines[headingIndex];
|
|
45
|
+
if (heading === undefined) {
|
|
46
|
+
throw new DekError(`section "${from}" heading not found`, {
|
|
47
|
+
path: deck.scriptPath,
|
|
48
|
+
hint: "run `dekc ls`",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
lines[headingIndex] = rewriteHeadingId(heading, from, to);
|
|
52
|
+
applySteps([
|
|
53
|
+
writeStep(deck.scriptPath, source, joinLines(source, lines)),
|
|
54
|
+
...slideFileSteps(deck, from, to),
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The steps that leave `slides/<to>.*` holding the slide and voice.toml pointing at it, from
|
|
60
|
+
* whichever files the author already moved. Each file is settled on its own: one that is only at
|
|
61
|
+
* `<from>` moves, one already at `<to>` stays, and a skeleton dek wrote gives way to the author's
|
|
62
|
+
* file on the other side, as the dev server writes one for any id the script names. Only two files
|
|
63
|
+
* the author wrote, one under each id, stop the rename; everything that could stop it is worked
|
|
64
|
+
* out before any file changes.
|
|
65
|
+
*/
|
|
66
|
+
function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): FileStep[] {
|
|
67
|
+
const paths = deckPaths(deck.dir);
|
|
68
|
+
const layouts = deckLayouts(deck.dir);
|
|
69
|
+
const record = skeletonRecord(deck.dir);
|
|
70
|
+
const read = (slug: string): SlideFile | undefined => {
|
|
71
|
+
const path = paths.slide(slug, ".html");
|
|
72
|
+
if (!existsSync(path)) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
const html = readFileSync(path, "utf8");
|
|
76
|
+
return { path, html, authored: !record.owns(html, skeletonHtml(deck.deck, slug, layouts)) };
|
|
77
|
+
};
|
|
78
|
+
const source = read(from);
|
|
79
|
+
const target = read(to);
|
|
80
|
+
if (source?.authored && target?.authored) {
|
|
81
|
+
throw bothHold(source.path, target.path, from, to);
|
|
82
|
+
}
|
|
83
|
+
for (const ext of SLIDE_SIDECARS) {
|
|
84
|
+
if (existsSync(paths.slide(from, ext)) && existsSync(paths.slide(to, ext))) {
|
|
85
|
+
throw bothHold(paths.slide(from, ext), paths.slide(to, ext), from, to);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const voice = planVoiceBeatKeys(deck.dir, from, to);
|
|
90
|
+
const steps: FileStep[] = [];
|
|
91
|
+
// The HTML that ends up at <to>: the author's, wherever it is; else whichever is there.
|
|
92
|
+
const toPath = paths.slide(to, ".html");
|
|
93
|
+
const kept = source && (source.authored || !target) ? source : target;
|
|
94
|
+
if (source && kept === source) {
|
|
95
|
+
steps.push(
|
|
96
|
+
target ? writeStep(toPath, target.html, source.html) : renameStep(source.path, toPath),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (source && target) {
|
|
100
|
+
steps.push(removeStep(source.path, source.html));
|
|
101
|
+
}
|
|
102
|
+
if (kept) {
|
|
103
|
+
const next = relabel(kept.html, from, to);
|
|
104
|
+
if (next !== kept.html) {
|
|
105
|
+
steps.push(writeStep(toPath, kept.html, next));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const ext of SLIDE_SIDECARS) {
|
|
109
|
+
const sidecar = paths.slide(from, ext);
|
|
110
|
+
if (existsSync(sidecar)) {
|
|
111
|
+
steps.push(renameStep(sidecar, paths.slide(to, ext)));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (voice) {
|
|
115
|
+
steps.push(writeStep(voice.path, voice.source, voice.next));
|
|
116
|
+
}
|
|
117
|
+
return steps;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A slide's HTML under one id, and whether the author wrote it rather than dek. */
|
|
121
|
+
type SlideFile = { path: string; html: string; authored: boolean };
|
|
122
|
+
|
|
123
|
+
/** A slide's own `data-slug`, pointed at its new id. */
|
|
124
|
+
function relabel(html: string, from: string, to: string): string {
|
|
125
|
+
return html.replaceAll(`data-slug="${from}"`, `data-slug="${to}"`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function bothHold(fromPath: string, toPath: string, from: string, to: string): DekError {
|
|
129
|
+
const fromName = `slides/${basename(fromPath)}`;
|
|
130
|
+
const toName = `slides/${basename(toPath)}`;
|
|
131
|
+
const what = fromPath.endsWith(".html") ? "hold a slide you wrote" : "exist";
|
|
132
|
+
return new DekError(`${fromName} and ${toName} both ${what}`, {
|
|
133
|
+
path: fromPath,
|
|
134
|
+
hint: `merge them into ${toName} and remove ${fromName}, then run \`dekc mv ${from} ${to}\` again`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One file change `dekc mv` makes, with how to take it back. */
|
|
139
|
+
type FileStep = { path: string; apply: () => void; undo: () => void };
|
|
140
|
+
|
|
141
|
+
function writeStep(path: string, before: string, after: string): FileStep {
|
|
142
|
+
return {
|
|
143
|
+
path,
|
|
144
|
+
apply: () => writeFileSync(path, after),
|
|
145
|
+
undo: () => writeFileSync(path, before),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function removeStep(path: string, before: string): FileStep {
|
|
150
|
+
return { path, apply: () => unlinkSync(path), undo: () => writeFileSync(path, before) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renameStep(from: string, to: string): FileStep {
|
|
154
|
+
return { path: to, apply: () => renameSync(from, to), undo: () => renameSync(to, from) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Applies every step, or none: a failure takes back the steps already applied. */
|
|
158
|
+
function applySteps(steps: FileStep[]): void {
|
|
159
|
+
const done: FileStep[] = [];
|
|
160
|
+
try {
|
|
161
|
+
for (const step of steps) {
|
|
162
|
+
step.apply();
|
|
163
|
+
done.push(step);
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const stuck: string[] = [];
|
|
167
|
+
for (const step of done.reverse()) {
|
|
168
|
+
try {
|
|
169
|
+
step.undo();
|
|
170
|
+
} catch {
|
|
171
|
+
stuck.push(step.path);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (stuck.length > 0) {
|
|
175
|
+
throw new DekError(`dekc mv failed and could not restore ${stuck.join(", ")}`, {
|
|
176
|
+
cause: error,
|
|
177
|
+
hint: "check these files by hand",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The voice.toml with its `[beats]` keys (`slug`, `"slug/beat"`) pointing at
|
|
186
|
+
* the renamed slide, or undefined when nothing changes. The rewrite is checked
|
|
187
|
+
* against the parsed file, so a layout it cannot rewrite stops the rename.
|
|
188
|
+
*/
|
|
189
|
+
function planVoiceBeatKeys(
|
|
190
|
+
deckDir: string,
|
|
191
|
+
from: string,
|
|
192
|
+
to: string,
|
|
193
|
+
): { path: string; source: string; next: string } | undefined {
|
|
194
|
+
const path = deckPaths(deckDir).voiceToml;
|
|
195
|
+
if (!existsSync(path)) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
const source = readFileSync(path, "utf8");
|
|
199
|
+
let parsed: Record<string, unknown>;
|
|
200
|
+
try {
|
|
201
|
+
parsed = Bun.TOML.parse(source) as Record<string, unknown>;
|
|
202
|
+
} catch {
|
|
203
|
+
// An unreadable voice.toml is the voice loader's to report, not mv's.
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
const beats = parsed.beats;
|
|
207
|
+
if (beats === null || typeof beats !== "object") {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
const rename = (key: string): string | undefined => renameBeatKey(key, from, to);
|
|
211
|
+
const expected = {
|
|
212
|
+
...parsed,
|
|
213
|
+
beats: Object.fromEntries(
|
|
214
|
+
Object.entries(beats).map(([key, value]) => [rename(key) ?? key, value]),
|
|
215
|
+
),
|
|
216
|
+
};
|
|
217
|
+
const next = renameTableKeys(source, "beats", rename);
|
|
218
|
+
let actual: unknown;
|
|
219
|
+
try {
|
|
220
|
+
actual = Bun.TOML.parse(next);
|
|
221
|
+
} catch {
|
|
222
|
+
actual = undefined;
|
|
223
|
+
}
|
|
224
|
+
if (!Bun.deepEquals(actual, expected, true)) {
|
|
225
|
+
throw new DekError(`cannot rewrite the [beats] keys for "${from}" in voice.toml`, {
|
|
226
|
+
path,
|
|
227
|
+
hint: `write them as [beats."${from}/…"] tables or dotted keys, or rename them to "${to}" by hand`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return next === source ? undefined : { path, source, next };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** `slug` or `slug/beat` for the renamed slide, else undefined. */
|
|
234
|
+
function renameBeatKey(key: string, from: string, to: string): string | undefined {
|
|
235
|
+
const slash = key.indexOf("/");
|
|
236
|
+
const slug = slash < 0 ? key : key.slice(0, slash);
|
|
237
|
+
return slug === from ? `${to}${slash < 0 ? "" : key.slice(slash)}` : undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Where a moved section lands: right before one section, or right after it. */
|
|
241
|
+
export type SectionPlace = { before: string } | { after: string };
|
|
242
|
+
|
|
243
|
+
export function reorderSection(dir: string, slug: string, place: SectionPlace): void;
|
|
244
|
+
export function reorderSection(source: ResolvedDeck, slug: string, place: SectionPlace): void;
|
|
245
|
+
export function reorderSection(
|
|
246
|
+
input: string | ResolvedDeck,
|
|
247
|
+
slug: string,
|
|
248
|
+
place: SectionPlace,
|
|
249
|
+
): void {
|
|
250
|
+
const target = "before" in place ? place.before : place.after;
|
|
251
|
+
const { deck } = asResolvedDeck(input);
|
|
252
|
+
const sections = deck.deck.sections;
|
|
253
|
+
const fromIndex = sections.findIndex((entry) => entry.slug === slug);
|
|
254
|
+
if (fromIndex < 0) {
|
|
255
|
+
requireSection(deck, slug);
|
|
256
|
+
}
|
|
257
|
+
const targetIndex = sections.findIndex((entry) => entry.slug === target);
|
|
258
|
+
if (targetIndex < 0) {
|
|
259
|
+
requireSection(deck, target);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const source = readFileSync(deck.scriptPath, "utf8");
|
|
263
|
+
const lines = splitLines(source);
|
|
264
|
+
const firstLine = sections[0]?.line;
|
|
265
|
+
if (firstLine === undefined) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const head = lines.slice(0, firstLine - 1);
|
|
269
|
+
const chunks = sectionChunks(
|
|
270
|
+
lines,
|
|
271
|
+
sections.map((section) => section.line),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const moved = chunks.splice(fromIndex, 1)[0];
|
|
275
|
+
if (!moved) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const remainingTarget = chunks.findIndex((_, index) => {
|
|
279
|
+
const original = index < fromIndex ? index : index + 1;
|
|
280
|
+
return original === targetIndex;
|
|
281
|
+
});
|
|
282
|
+
const insertAt = "before" in place ? remainingTarget : remainingTarget + 1;
|
|
283
|
+
chunks.splice(insertAt, 0, moved);
|
|
284
|
+
writeFileSync(deck.scriptPath, joinLines(source, [...head, ...chunks.flat()]));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function rewriteHeadingId(heading: string, from: string, to: string): string {
|
|
288
|
+
const attr = new RegExp(`\\{#${escapeRegExp(from)}\\}`);
|
|
289
|
+
if (attr.test(heading)) {
|
|
290
|
+
return heading.replace(attr, `{#${to}}`);
|
|
291
|
+
}
|
|
292
|
+
return `${heading} {#${to}}`;
|
|
293
|
+
}
|
package/src/core/ogp.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { escapeAttr } from "./escape.ts";
|
|
2
|
+
|
|
3
|
+
export type OgpImage = {
|
|
4
|
+
url: string;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type OgpInput = {
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
event?: string;
|
|
13
|
+
date?: string;
|
|
14
|
+
lang: string;
|
|
15
|
+
/** The page's own absolute URL; left out, the page has no og:url. */
|
|
16
|
+
url?: string;
|
|
17
|
+
image?: OgpImage;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The region Open Graph pairs with a bare language, as `og:locale` wants `ll_CC`. */
|
|
21
|
+
const USUAL_REGION: Record<string, string> = {
|
|
22
|
+
ja: "JP",
|
|
23
|
+
en: "US",
|
|
24
|
+
zh: "CN",
|
|
25
|
+
ko: "KR",
|
|
26
|
+
de: "DE",
|
|
27
|
+
fr: "FR",
|
|
28
|
+
es: "ES",
|
|
29
|
+
it: "IT",
|
|
30
|
+
pt: "PT",
|
|
31
|
+
nl: "NL",
|
|
32
|
+
ru: "RU",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function ogLocale(lang: string): string | undefined {
|
|
36
|
+
const [language = "", ...rest] = lang.split("-");
|
|
37
|
+
const region = rest.find((part) => /^[A-Za-z]{2}$/.test(part));
|
|
38
|
+
const code = language.toLowerCase();
|
|
39
|
+
const country = region?.toUpperCase() ?? USUAL_REGION[code];
|
|
40
|
+
return country ? `${code}_${country}` : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a link to the page shows when someone shares it: title, description, and, once the
|
|
45
|
+
* page knows where it is served from, its URL and a picture of the first slide. Crawlers only
|
|
46
|
+
* fetch an absolute og:image, so without a URL there is no image at all.
|
|
47
|
+
*/
|
|
48
|
+
export function ogpHead(input: OgpInput): string {
|
|
49
|
+
const description =
|
|
50
|
+
input.description ?? ([input.event, input.date].filter(Boolean).join(" · ") || undefined);
|
|
51
|
+
const locale = ogLocale(input.lang);
|
|
52
|
+
const tags: Array<[kind: "name" | "property", key: string, value: string | number]> = [];
|
|
53
|
+
if (description) {
|
|
54
|
+
tags.push(["name", "description", description]);
|
|
55
|
+
}
|
|
56
|
+
tags.push(["property", "og:type", "website"], ["property", "og:title", input.title]);
|
|
57
|
+
if (description) {
|
|
58
|
+
tags.push(["property", "og:description", description]);
|
|
59
|
+
}
|
|
60
|
+
if (locale) {
|
|
61
|
+
tags.push(["property", "og:locale", locale]);
|
|
62
|
+
}
|
|
63
|
+
if (input.url) {
|
|
64
|
+
tags.push(["property", "og:url", input.url]);
|
|
65
|
+
}
|
|
66
|
+
if (input.image) {
|
|
67
|
+
tags.push(
|
|
68
|
+
["property", "og:image", input.image.url],
|
|
69
|
+
["property", "og:image:width", input.image.width],
|
|
70
|
+
["property", "og:image:height", input.image.height],
|
|
71
|
+
["property", "og:image:alt", input.title],
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
tags.push(["name", "twitter:card", input.image ? "summary_large_image" : "summary"]);
|
|
75
|
+
const lines = tags.map(
|
|
76
|
+
([kind, key, value]) => `<meta ${kind}="${key}" content="${escapeAttr(String(value))}">`,
|
|
77
|
+
);
|
|
78
|
+
if (input.url) {
|
|
79
|
+
lines.push(`<link rel="canonical" href="${escapeAttr(input.url)}">`);
|
|
80
|
+
}
|
|
81
|
+
return lines.join("\n ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The URL dist/ is served from, ending in a slash so a deck's file name joins onto it, or
|
|
86
|
+
* undefined when a crawler could not fetch it: not absolute http(s), or carrying a query.
|
|
87
|
+
*/
|
|
88
|
+
export function parsePublicUrl(value: string): string | undefined {
|
|
89
|
+
let url: URL;
|
|
90
|
+
try {
|
|
91
|
+
url = new URL(value);
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.search || url.hash) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return url.href.endsWith("/") ? url.href : `${url.href}/`;
|
|
99
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function walkUp<T>(startDir: string, visit: (dir: string) => T | undefined): T | undefined {
|
|
5
|
+
let dir = resolve(startDir);
|
|
6
|
+
while (true) {
|
|
7
|
+
const hit = visit(dir);
|
|
8
|
+
if (hit !== undefined) {
|
|
9
|
+
return hit;
|
|
10
|
+
}
|
|
11
|
+
const parent = dirname(dir);
|
|
12
|
+
if (parent === dir) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
dir = parent;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveBinFromAncestors(name: string, startDir: string): string | undefined {
|
|
20
|
+
return walkUp(startDir, (dir) => {
|
|
21
|
+
const bin = join(dir, "node_modules", ".bin", name);
|
|
22
|
+
return existsSync(bin) ? bin : undefined;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { Box, MeasuredElement } from "./slide-measure.ts";
|
|
2
|
+
|
|
3
|
+
export type Edge = "top" | "right" | "bottom" | "left";
|
|
4
|
+
|
|
5
|
+
export const EDGES: Edge[] = ["top", "right", "bottom", "left"];
|
|
6
|
+
|
|
7
|
+
export type Overflow = {
|
|
8
|
+
box: string;
|
|
9
|
+
text?: string;
|
|
10
|
+
/** Pixels past each edge it crosses. */
|
|
11
|
+
by: Partial<Record<Edge, number>>;
|
|
12
|
+
origin?: OverflowOrigin;
|
|
13
|
+
/** The ancestor that cuts the text off, when the edge is that ancestor's and not the frame's. */
|
|
14
|
+
clip?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** An overflow as measured: with the index of its element, which finds it again in a second measure. */
|
|
18
|
+
type LocatedOverflow = Overflow & { element: number };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What put an overflow past the edge, found by taking the page apart: the slide script's draw,
|
|
22
|
+
* the slide's own CSS, or neither, which leaves the content itself too big for the theme.
|
|
23
|
+
*/
|
|
24
|
+
export type OverflowOrigin = "script" | "slide" | "content";
|
|
25
|
+
|
|
26
|
+
/** Whether `element` crosses an edge of the slide at all, whatever its parent does. */
|
|
27
|
+
export function crossesEdge(
|
|
28
|
+
slide: Box,
|
|
29
|
+
element: Pick<MeasuredElement, "rect"> & {
|
|
30
|
+
opacity?: number;
|
|
31
|
+
decorative?: boolean;
|
|
32
|
+
clip?: MeasuredElement["clip"];
|
|
33
|
+
},
|
|
34
|
+
): boolean {
|
|
35
|
+
return (
|
|
36
|
+
(element.opacity ?? 1) >= 0.01 &&
|
|
37
|
+
element.decorative !== true &&
|
|
38
|
+
Object.keys(overflowAmounts(slide, visibleRect(element))).length > 0
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** An element's box, less what an ancestor inside the slide cuts off. */
|
|
43
|
+
function visibleRect({ rect, clip }: Pick<MeasuredElement, "rect" | "clip">): Box {
|
|
44
|
+
return clip
|
|
45
|
+
? {
|
|
46
|
+
left: Math.max(rect.left, clip.rect.left),
|
|
47
|
+
top: Math.max(rect.top, clip.rect.top),
|
|
48
|
+
right: Math.min(rect.right, clip.rect.right),
|
|
49
|
+
bottom: Math.min(rect.bottom, clip.rect.bottom),
|
|
50
|
+
}
|
|
51
|
+
: rect;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function overflowAmounts(slide: Box, rect: Box): Partial<Record<Edge, number>> {
|
|
55
|
+
if (rect.right <= rect.left || rect.bottom <= rect.top) {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
const past: Record<Edge, number> = {
|
|
59
|
+
top: slide.top - rect.top,
|
|
60
|
+
right: rect.right - slide.right,
|
|
61
|
+
bottom: rect.bottom - slide.bottom,
|
|
62
|
+
left: slide.left - rect.left,
|
|
63
|
+
};
|
|
64
|
+
const by: Partial<Record<Edge, number>> = {};
|
|
65
|
+
for (const edge of EDGES) {
|
|
66
|
+
// Sub-pixel layout rounding is not an overflow anyone can see.
|
|
67
|
+
if (past[edge] >= 1) {
|
|
68
|
+
by[edge] = Math.round(past[edge]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return by;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The elements that cross an edge of the slide, each reported only for the
|
|
76
|
+
* edges its parent stays inside: a list that runs off the bottom is one
|
|
77
|
+
* finding, not one per item.
|
|
78
|
+
*/
|
|
79
|
+
export function findOverflows(
|
|
80
|
+
slide: Box,
|
|
81
|
+
elements: Array<
|
|
82
|
+
Pick<MeasuredElement, "box" | "parent" | "rect" | "text"> & {
|
|
83
|
+
opacity?: number;
|
|
84
|
+
decorative?: boolean;
|
|
85
|
+
clip?: MeasuredElement["clip"];
|
|
86
|
+
}
|
|
87
|
+
>,
|
|
88
|
+
): LocatedOverflow[] {
|
|
89
|
+
// An invisible element, such as a beat before it shows, overflows nothing anyone sees, and
|
|
90
|
+
// decoration, such as a glow, may bleed off the slide as it likes. What an ancestor inside the
|
|
91
|
+
// slide cuts off is not past the frame; findClippedText says when that is text.
|
|
92
|
+
const amounts = elements.map((element) =>
|
|
93
|
+
(element.opacity ?? 1) < 0.01 || element.decorative === true
|
|
94
|
+
? {}
|
|
95
|
+
: overflowAmounts(slide, visibleRect(element)),
|
|
96
|
+
);
|
|
97
|
+
const found: LocatedOverflow[] = [];
|
|
98
|
+
elements.forEach((element, index) => {
|
|
99
|
+
const parent = amounts[element.parent] ?? {};
|
|
100
|
+
const by: Partial<Record<Edge, number>> = {};
|
|
101
|
+
for (const edge of EDGES) {
|
|
102
|
+
const amount = amounts[index]?.[edge];
|
|
103
|
+
if (amount !== undefined && parent[edge] === undefined) {
|
|
104
|
+
by[edge] = amount;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (Object.keys(by).length > 0) {
|
|
108
|
+
found.push({
|
|
109
|
+
box: element.box,
|
|
110
|
+
...(element.text ? { text: element.text } : {}),
|
|
111
|
+
by,
|
|
112
|
+
element: index,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The texts an ancestor inside the slide cuts off, with `overflow: hidden` or the like: each by how
|
|
121
|
+
* far its lines run past what the ancestor shows. A truncation the author asked for, an ellipsis
|
|
122
|
+
* or a line clamp, is left alone, and so are decoration and text nobody sees.
|
|
123
|
+
*/
|
|
124
|
+
export function findClippedText(
|
|
125
|
+
elements: Array<
|
|
126
|
+
Pick<MeasuredElement, "box" | "text" | "textRects" | "opacity" | "decorative" | "clip">
|
|
127
|
+
>,
|
|
128
|
+
): LocatedOverflow[] {
|
|
129
|
+
return elements.flatMap((element, index) => {
|
|
130
|
+
const { clip } = element;
|
|
131
|
+
if (!clip || clip.intended || element.decorative || element.opacity < 0.01) {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
const lines = element.textRects.filter(
|
|
135
|
+
(rect) => rect.right > rect.left && rect.bottom > rect.top,
|
|
136
|
+
);
|
|
137
|
+
if (lines.length === 0) {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
const text = {
|
|
141
|
+
left: Math.min(...lines.map((rect) => rect.left)),
|
|
142
|
+
top: Math.min(...lines.map((rect) => rect.top)),
|
|
143
|
+
right: Math.max(...lines.map((rect) => rect.right)),
|
|
144
|
+
bottom: Math.max(...lines.map((rect) => rect.bottom)),
|
|
145
|
+
};
|
|
146
|
+
const by = overflowAmounts(clip.rect, text);
|
|
147
|
+
return Object.keys(by).length === 0
|
|
148
|
+
? []
|
|
149
|
+
: [
|
|
150
|
+
{
|
|
151
|
+
box: element.box,
|
|
152
|
+
...(element.text ? { text: element.text } : {}),
|
|
153
|
+
by,
|
|
154
|
+
clip: clip.box,
|
|
155
|
+
element: index,
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** One text as the page draws it, for finding which texts are drawn over which. */
|
|
162
|
+
type TextBox = { rects: Box[]; opacity: number };
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The pairs of texts whose lines are drawn over each other, a folio or running head a
|
|
166
|
+
* pseudo-element draws included, first in the page first. Lines that only touch, as the words of
|
|
167
|
+
* one sentence set in two elements do, are not a collision: the lines must cross by a few pixels
|
|
168
|
+
* each way and share a fifth of the smaller one.
|
|
169
|
+
*/
|
|
170
|
+
export function findCollisions<T extends TextBox>(texts: T[]): Array<[T, T]> {
|
|
171
|
+
const MIN_CROSS = 4;
|
|
172
|
+
const MIN_SHARE = 0.2;
|
|
173
|
+
const area = (rect: Box): number => (rect.right - rect.left) * (rect.bottom - rect.top);
|
|
174
|
+
const crosses = (a: Box, b: Box): boolean => {
|
|
175
|
+
const width = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
|
176
|
+
const height = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
|
177
|
+
return (
|
|
178
|
+
width >= MIN_CROSS &&
|
|
179
|
+
height >= MIN_CROSS &&
|
|
180
|
+
width * height >= MIN_SHARE * Math.min(area(a), area(b))
|
|
181
|
+
);
|
|
182
|
+
};
|
|
183
|
+
const shown = texts.filter((text) => text.opacity >= 0.01 && text.rects.length > 0);
|
|
184
|
+
const found: Array<[T, T]> = [];
|
|
185
|
+
for (const [at, one] of shown.entries()) {
|
|
186
|
+
for (const other of shown.slice(at + 1)) {
|
|
187
|
+
if (one.rects.some((a) => other.rects.some((b) => crosses(a, b)))) {
|
|
188
|
+
found.push([one, other]);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Whether two texts take one place, as the numbers a count-up stacks in one cell do: the box their
|
|
197
|
+
* lines span shares at least half of each. A callout laid over a label is far larger than the
|
|
198
|
+
* label, and takes no place of the label's.
|
|
199
|
+
*/
|
|
200
|
+
export function samePlace(a: Pick<TextBox, "rects">, b: Pick<TextBox, "rects">): boolean {
|
|
201
|
+
const span = ({ rects }: Pick<TextBox, "rects">): Box => ({
|
|
202
|
+
left: Math.min(...rects.map((rect) => rect.left)),
|
|
203
|
+
top: Math.min(...rects.map((rect) => rect.top)),
|
|
204
|
+
right: Math.max(...rects.map((rect) => rect.right)),
|
|
205
|
+
bottom: Math.max(...rects.map((rect) => rect.bottom)),
|
|
206
|
+
});
|
|
207
|
+
const area = (box: Box): number =>
|
|
208
|
+
Math.max(0, box.right - box.left) * Math.max(0, box.bottom - box.top);
|
|
209
|
+
const [one, other] = [span(a), span(b)];
|
|
210
|
+
const shared = area({
|
|
211
|
+
left: Math.max(one.left, other.left),
|
|
212
|
+
top: Math.max(one.top, other.top),
|
|
213
|
+
right: Math.min(one.right, other.right),
|
|
214
|
+
bottom: Math.min(one.bottom, other.bottom),
|
|
215
|
+
});
|
|
216
|
+
return shared >= 0.5 * Math.max(area(one), area(other));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Two texts drawn over each other: the one first in the page, and the one it collides with. */
|
|
220
|
+
export type Collision = { box: string; text?: string; other: string; otherText?: string };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The part of a Playwright page the pool drives. */
|
|
2
|
+
type PoolPage = {
|
|
3
|
+
goto(url: string): Promise<unknown>;
|
|
4
|
+
close(): Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How many pages a worker renders at once. A new page costs Chromium about
|
|
9
|
+
* as much as rendering a slide on it, so pages are kept and reused; a few at
|
|
10
|
+
* a time keep the renderer busy without keeping a long deck alive at once.
|
|
11
|
+
*/
|
|
12
|
+
const PAGE_LANES = 4;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Visits every item on a small set of reused pages and returns the results
|
|
16
|
+
* in item order. Each visit starts from about:blank, so what one slide's
|
|
17
|
+
* scripts left on `window` never reaches the next. After a visit fails, no
|
|
18
|
+
* lane takes another item; every page is closed and the first failure thrown.
|
|
19
|
+
*/
|
|
20
|
+
export async function visitInPages<P extends PoolPage, T, R>(
|
|
21
|
+
items: readonly T[],
|
|
22
|
+
openPage: () => Promise<P>,
|
|
23
|
+
visit: (page: P, item: T) => Promise<R>,
|
|
24
|
+
{ lanes = PAGE_LANES }: { lanes?: number } = {},
|
|
25
|
+
): Promise<R[]> {
|
|
26
|
+
const results = new Array<R>(items.length);
|
|
27
|
+
let next = 0;
|
|
28
|
+
let failed = false;
|
|
29
|
+
let failure: unknown;
|
|
30
|
+
const lane = async (): Promise<void> => {
|
|
31
|
+
let page: P | undefined;
|
|
32
|
+
try {
|
|
33
|
+
page = await openPage();
|
|
34
|
+
while (!failed && next < items.length) {
|
|
35
|
+
const index = next++;
|
|
36
|
+
await page.goto("about:blank");
|
|
37
|
+
results[index] = await visit(page, items[index] as T);
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (!failed) {
|
|
41
|
+
failed = true;
|
|
42
|
+
failure = error;
|
|
43
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
await page?.close();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
await Promise.all(Array.from({ length: Math.min(lanes, items.length) }, lane));
|
|
49
|
+
if (failed) {
|
|
50
|
+
throw failure;
|
|
51
|
+
}
|
|
52
|
+
return results;
|
|
53
|
+
}
|