@hajimism/dek 0.3.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +136 -0
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/package.json +5 -3
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +95 -18
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +57 -19
- package/src/cli/contract.ts +58 -4
- package/src/cli/files.ts +2 -5
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +5 -4
- package/src/cli/goto.ts +44 -11
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +36 -16
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +51 -14
- package/src/cli/new.ts +28 -8
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +92 -9
- package/src/cli/result.ts +47 -6
- package/src/cli/scope.ts +57 -29
- package/src/cli/shot.ts +78 -6
- package/src/cli/show.ts +15 -2
- package/src/cli/text.ts +88 -11
- package/src/cli/theme.ts +58 -10
- package/src/cli/tty.ts +16 -2
- package/src/cli/voice.ts +15 -9
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- package/src/core/assets.ts +18 -10
- package/src/core/attribute-value.ts +188 -0
- package/src/core/build.ts +3 -3
- package/src/core/config.ts +61 -12
- package/src/core/css-scan.ts +173 -39
- package/src/core/css-transform.ts +57 -11
- package/src/core/css.ts +19 -13
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/foreign-text.ts +138 -0
- package/src/core/github.ts +1 -0
- package/src/core/html-entities.ts +318 -0
- package/src/core/html-scan.ts +49 -27
- package/src/core/html.ts +21 -15
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +50 -8
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +24 -19
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +57 -27
- package/src/core/order.ts +14 -0
- package/src/core/pack.ts +2 -2
- package/src/core/path.ts +8 -2
- package/src/core/playwright-module.d.ts +13 -1
- package/src/core/playwright-motion.ts +1 -0
- package/src/core/playwright-sheet.ts +1 -0
- package/src/core/playwright-visual.ts +207 -30
- package/src/core/playwright.ts +279 -44
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +56 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +52 -8
- package/src/core/ref.ts +308 -54
- package/src/core/resolve.ts +23 -11
- package/src/core/rumdl.ts +51 -25
- package/src/core/safe-fs.ts +19 -2
- package/src/core/sandbox.ts +217 -0
- package/src/core/shot/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +26 -17
- package/src/core/slide-eval-worker.ts +1 -0
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +12 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/spawn.ts +131 -32
- package/src/core/sync.ts +5 -14
- package/src/core/tar.ts +315 -0
- package/src/core/text-contrast.ts +2 -0
- package/src/core/toml-keys.ts +116 -51
- package/src/core/url-attributes.ts +27 -9
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +182 -24
- package/src/core/voice.ts +5 -6
- package/src/core/zod.ts +9 -1
- package/src/server/auth.ts +21 -0
- package/src/server/dev.ts +26 -7
- package/src/server/lock.ts +50 -7
- package/src/server/routes.ts +7 -2
- package/src/server/serial.ts +8 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
- package/src/video/mux.ts +27 -3
- package/src/video/recorder.ts +3 -2
- package/src/video/worker.ts +1 -0
- package/src/voice/engine.ts +81 -18
package/src/core/visual.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { EDGES, type Edge } from "./overflow.ts";
|
|
|
4
4
|
import { renderPdfHtml } from "./pdf.ts";
|
|
5
5
|
import {
|
|
6
6
|
askPlaywright,
|
|
7
|
-
type ContrastOrigin,
|
|
8
7
|
type FillFinding,
|
|
9
8
|
type PageAction,
|
|
10
9
|
type PagesResponse,
|
|
@@ -83,7 +82,7 @@ async function visualDeck(
|
|
|
83
82
|
return { diagnostics: [], fills: [] };
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
const actions: PageAction[] = ["overflow", "contrast", "fill"];
|
|
85
|
+
const actions: PageAction[] = ["overflow", "contrast", "fill", "breaks", "alignment"];
|
|
87
86
|
const viewport = logicalSize(deck.deck.ratio);
|
|
88
87
|
const cache = visualCache(deck.dir, { viewport, actions });
|
|
89
88
|
const keyed = pages.map((page) => {
|
|
@@ -137,6 +136,8 @@ async function visualDeck(
|
|
|
137
136
|
contrasts: all.flatMap((page) => page.contrasts),
|
|
138
137
|
drawErrors: all.flatMap((page) => page.drawErrors),
|
|
139
138
|
collisions: all.flatMap((page) => page.collisions ?? []),
|
|
139
|
+
lineBreaks: all.flatMap((page) => page.lineBreaks ?? []),
|
|
140
|
+
misalignments: all.flatMap((page) => page.misalignments ?? []),
|
|
140
141
|
},
|
|
141
142
|
deck.dir,
|
|
142
143
|
),
|
|
@@ -147,13 +148,20 @@ async function visualDeck(
|
|
|
147
148
|
};
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
151
|
+
/** A page's findings before any is found, each list its own to push to. */
|
|
152
|
+
function noFindings(): PageFindings {
|
|
153
|
+
return {
|
|
154
|
+
overflows: [],
|
|
155
|
+
contrasts: [],
|
|
156
|
+
drawErrors: [],
|
|
157
|
+
collisions: [],
|
|
158
|
+
fills: [],
|
|
159
|
+
lineBreaks: [],
|
|
160
|
+
misalignments: [],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const NOTHING_FOUND: PageFindings = noFindings();
|
|
157
165
|
|
|
158
166
|
/** A page's findings name its slide and step, which no two pages of one run share. */
|
|
159
167
|
function pageId(page: { slug: string; step: string }): string {
|
|
@@ -165,13 +173,7 @@ function findingsByPage(response: PagesResponse): Map<string, PageFindings> {
|
|
|
165
173
|
const pages = new Map<string, PageFindings>();
|
|
166
174
|
const of = (finding: { slug: string; step: string }): PageFindings => {
|
|
167
175
|
const id = pageId(finding);
|
|
168
|
-
const found = pages.get(id) ??
|
|
169
|
-
overflows: [],
|
|
170
|
-
contrasts: [],
|
|
171
|
-
drawErrors: [],
|
|
172
|
-
collisions: [],
|
|
173
|
-
fills: [],
|
|
174
|
-
};
|
|
176
|
+
const found = pages.get(id) ?? noFindings();
|
|
175
177
|
pages.set(id, found);
|
|
176
178
|
return found;
|
|
177
179
|
};
|
|
@@ -190,6 +192,12 @@ function findingsByPage(response: PagesResponse): Map<string, PageFindings> {
|
|
|
190
192
|
for (const finding of response.fills ?? []) {
|
|
191
193
|
of(finding).fills.push(finding);
|
|
192
194
|
}
|
|
195
|
+
for (const finding of response.lineBreaks ?? []) {
|
|
196
|
+
of(finding).lineBreaks.push(finding);
|
|
197
|
+
}
|
|
198
|
+
for (const finding of response.misalignments ?? []) {
|
|
199
|
+
of(finding).misalignments.push(finding);
|
|
200
|
+
}
|
|
193
201
|
return pages;
|
|
194
202
|
}
|
|
195
203
|
|
|
@@ -306,9 +314,14 @@ function groupBySteps<T extends { step: string }>(items: T[], key: (item: T) =>
|
|
|
306
314
|
/**
|
|
307
315
|
* Where to fix low contrast. Text the theme alone draws too faint is fixed once in theme.css
|
|
308
316
|
* for every slide that uses the pair; a slide that fixed it in its own CSS would leave the rest.
|
|
317
|
+
* Text only the slide's CSS shows was never measured on the theme alone, so both are named.
|
|
309
318
|
*/
|
|
310
|
-
function contrastHint(
|
|
319
|
+
function contrastHint(sample: ContrastSample, threshold: number): string {
|
|
320
|
+
const { origin, slug } = sample;
|
|
311
321
|
const own = `slides/${slug}.css`;
|
|
322
|
+
if (sample.onlyWithSlideCss) {
|
|
323
|
+
return `without ${own} it does not show, so dek cannot tell whether theme.css alone draws it below ${threshold}:1: fix the pair in ${own}, or in theme.css if the colors are the theme's`;
|
|
324
|
+
}
|
|
312
325
|
switch (origin) {
|
|
313
326
|
case "theme":
|
|
314
327
|
return `theme.css alone draws it below ${threshold}:1: fix the pair in theme.css, where one change reaches every slide that uses it`;
|
|
@@ -324,8 +337,17 @@ function contrastHint(origin: ContrastOrigin | undefined, threshold: number, slu
|
|
|
324
337
|
}
|
|
325
338
|
}
|
|
326
339
|
|
|
327
|
-
/**
|
|
328
|
-
|
|
340
|
+
/**
|
|
341
|
+
* The file to change: the one the origin names, or the slide itself when it is not known. Text
|
|
342
|
+
* only the slide's CSS shows goes to that CSS, which the hint names first.
|
|
343
|
+
*/
|
|
344
|
+
function contrastPath(
|
|
345
|
+
{ origin, slug, onlyWithSlideCss }: ContrastSample,
|
|
346
|
+
paths: DeckPaths,
|
|
347
|
+
): string {
|
|
348
|
+
if (onlyWithSlideCss) {
|
|
349
|
+
return paths.slide(slug, ".css");
|
|
350
|
+
}
|
|
329
351
|
switch (origin) {
|
|
330
352
|
case "theme":
|
|
331
353
|
return paths.theme;
|
|
@@ -352,9 +374,139 @@ function visualDiagnostics(response: PagesResponse, deckDir: string): Diagnostic
|
|
|
352
374
|
...groupBySteps(response.collisions ?? [], collisionKey).map((group) =>
|
|
353
375
|
collisionDiagnostic(group, paths),
|
|
354
376
|
),
|
|
377
|
+
...groupBySteps(response.lineBreaks ?? [], lineBreakKey).map((group) =>
|
|
378
|
+
lineBreakDiagnostic(group, paths),
|
|
379
|
+
),
|
|
380
|
+
...groupBySteps(response.misalignments ?? [], misalignmentKey).map((group) =>
|
|
381
|
+
misalignmentDiagnostic(group, paths),
|
|
382
|
+
),
|
|
355
383
|
];
|
|
356
384
|
}
|
|
357
385
|
|
|
386
|
+
type MisalignmentSample = NonNullable<PagesResponse["misalignments"]>[number];
|
|
387
|
+
|
|
388
|
+
function misalignmentKey(m: MisalignmentSample): string {
|
|
389
|
+
return [m.slug, m.box, m.text ?? "", m.other, m.otherText ?? "", m.edge, m.of].join("\0");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** How the message names an edge two items share. */
|
|
393
|
+
const EDGE_NAMES: Record<MisalignmentSample["edge"], string> = {
|
|
394
|
+
top: "tops",
|
|
395
|
+
middle: "middles",
|
|
396
|
+
bottom: "bottoms",
|
|
397
|
+
left: "left edges",
|
|
398
|
+
center: "centers",
|
|
399
|
+
right: "right edges",
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Two items that nearly line up and do not. The fix depends on the edge: a start lines up with
|
|
404
|
+
* the same space before it, an end with one size, a middle with one way of centering, and text in
|
|
405
|
+
* cards that line up with one padding.
|
|
406
|
+
*/
|
|
407
|
+
function misalignmentDiagnostic(
|
|
408
|
+
{ first, items, steps }: StepGroup<MisalignmentSample>,
|
|
409
|
+
paths: DeckPaths,
|
|
410
|
+
): Diagnostic {
|
|
411
|
+
// By the most they miss at any step, so the fix covers the worst beat.
|
|
412
|
+
const by = Math.max(...items.map((item) => item.by));
|
|
413
|
+
const pair = `${describeTarget(first.box, first.text)}and ${describeTarget(first.other, first.otherText).trimEnd()}`;
|
|
414
|
+
const where =
|
|
415
|
+
first.of === "text"
|
|
416
|
+
? `almost start their text ${first.edge === "left" ? "at one left edge" : "on one line"}`
|
|
417
|
+
: `almost line up at their ${EDGE_NAMES[first.edge]}`;
|
|
418
|
+
const css = `slides/${first.slug}.css`;
|
|
419
|
+
const both = `${first.box} and ${first.other}`;
|
|
420
|
+
const why = "an edge a few pixels off reads as a mistake, not a choice";
|
|
421
|
+
const hint =
|
|
422
|
+
first.of === "text"
|
|
423
|
+
? `give ${both} the same padding in ${css}, so their text starts on one line`
|
|
424
|
+
: first.edge === "top" || first.edge === "left"
|
|
425
|
+
? `line ${first.box} up with ${first.other} in ${css}, with the same margin, padding, or alignment: ${why}`
|
|
426
|
+
: first.edge === "bottom" || first.edge === "right"
|
|
427
|
+
? `give ${both} one ${first.edge === "bottom" ? "height" : "width"} in ${css}, with align-items: stretch or the same size: ${why}`
|
|
428
|
+
: `center ${both} on one line in ${css}, with align-items: center or the same margins: ${why}`;
|
|
429
|
+
return diag("DEK036", {
|
|
430
|
+
message: `${pair} ${where}, ${by}px apart, ${atSteps(steps)}`,
|
|
431
|
+
path: paths.slide(first.slug, ".html"),
|
|
432
|
+
slug: first.slug,
|
|
433
|
+
hint,
|
|
434
|
+
data: {
|
|
435
|
+
box: first.box,
|
|
436
|
+
...(first.text ? { text: first.text } : {}),
|
|
437
|
+
other: first.other,
|
|
438
|
+
...(first.otherText ? { otherText: first.otherText } : {}),
|
|
439
|
+
edge: first.edge,
|
|
440
|
+
of: first.of,
|
|
441
|
+
by,
|
|
442
|
+
steps,
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
type LineBreakSample = NonNullable<PagesResponse["lineBreaks"]>[number];
|
|
448
|
+
|
|
449
|
+
function lineBreakKey(b: LineBreakSample): string {
|
|
450
|
+
return [b.slug, b.box, b.text ?? "", b.kind, ...b.lines].join("\0");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Where a line ends: the end of one line and the start of the next, as far as either reads. */
|
|
454
|
+
function breakAt(lines: string[], phrased: string[]): string {
|
|
455
|
+
const differs = lines.findIndex((line, at) => line !== phrased[at]);
|
|
456
|
+
const at = Math.max(0, Math.min(differs, lines.length - 2));
|
|
457
|
+
const end = [...(lines[at] ?? "")];
|
|
458
|
+
const start = [...(lines[at + 1] ?? "")];
|
|
459
|
+
const half = SNIPPET_CHARS / 2;
|
|
460
|
+
const before = end.length > half ? `…${end.slice(-half).join("")}` : end.join("");
|
|
461
|
+
const after = start.length > half ? `${start.slice(0, half).join("")}…` : start.join("");
|
|
462
|
+
return `${before} / ${after}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* A line break the audience trips over. One inside a phrase is fixed where Japanese is told to
|
|
467
|
+
* break by phrase: on `.slide` in theme.css, for every slide, unless a rule takes it away from
|
|
468
|
+
* this text. A remnant on a last line is the text's own to fix, or the theme's text-wrap.
|
|
469
|
+
*/
|
|
470
|
+
function lineBreakDiagnostic(
|
|
471
|
+
{ first, steps }: StepGroup<LineBreakSample>,
|
|
472
|
+
paths: DeckPaths,
|
|
473
|
+
): Diagnostic {
|
|
474
|
+
const target = describeTarget(first.box, first.text);
|
|
475
|
+
const css = `slides/${first.slug}.css`;
|
|
476
|
+
const yourself = "break the line yourself with <br>";
|
|
477
|
+
const common = {
|
|
478
|
+
slug: first.slug,
|
|
479
|
+
data: {
|
|
480
|
+
kind: first.kind,
|
|
481
|
+
box: first.box,
|
|
482
|
+
...(first.text ? { text: first.text } : {}),
|
|
483
|
+
lines: first.lines,
|
|
484
|
+
...(first.kind === "split" ? { phrased: first.phrased } : { tail: first.tail }),
|
|
485
|
+
steps,
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
if (first.kind === "split") {
|
|
489
|
+
const theme = first.wordBreak !== "auto-phrase";
|
|
490
|
+
return diag("DEK035", {
|
|
491
|
+
message: `${target}breaks a line inside a phrase, at "${breakAt(first.lines, first.phrased)}", ${atSteps(steps)}`,
|
|
492
|
+
path: theme ? paths.theme : paths.slide(first.slug, ".html"),
|
|
493
|
+
hint: theme
|
|
494
|
+
? `add word-break: auto-phrase to .slide in theme.css, so Japanese breaks between phrases on every slide; or ${yourself} where its sense breaks`
|
|
495
|
+
: `a rule gives it word-break: normal where .slide breaks by phrase: remove it from ${css} or theme.css, or ${yourself} where its sense breaks`,
|
|
496
|
+
...common,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
const evened = first.textWrap === "pretty" || first.textWrap === "balance";
|
|
500
|
+
return diag("DEK035", {
|
|
501
|
+
message: `${target}leaves "${first.tail}" alone on its last line ${atSteps(steps)}`,
|
|
502
|
+
path: paths.slide(first.slug, ".html"),
|
|
503
|
+
hint: evened
|
|
504
|
+
? `reword it so its last line holds more, or ${yourself} earlier`
|
|
505
|
+
: `even out its lines with text-wrap: pretty, or balance for a heading, in ${css} or for every slide in theme.css; or reword it, or ${yourself}`,
|
|
506
|
+
...common,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
358
510
|
type CollisionSample = NonNullable<PagesResponse["collisions"]>[number];
|
|
359
511
|
|
|
360
512
|
function collisionKey(c: CollisionSample): string {
|
|
@@ -444,9 +596,15 @@ function shownRatio(sample: ContrastSample): number {
|
|
|
444
596
|
}
|
|
445
597
|
|
|
446
598
|
function contrastKey(c: ContrastSample): string {
|
|
447
|
-
return [
|
|
448
|
-
|
|
449
|
-
|
|
599
|
+
return [
|
|
600
|
+
c.slug,
|
|
601
|
+
c.box,
|
|
602
|
+
c.text ?? "",
|
|
603
|
+
shownRatio(c),
|
|
604
|
+
contrastThreshold(c),
|
|
605
|
+
c.origin ?? "",
|
|
606
|
+
c.onlyWithSlideCss ?? "",
|
|
607
|
+
].join("\0");
|
|
450
608
|
}
|
|
451
609
|
|
|
452
610
|
function overflowWhere(amounts: Partial<Record<Edge, number>>): string {
|
|
@@ -542,9 +700,9 @@ function contrastDiagnostic(
|
|
|
542
700
|
const colors = fg && bg ? ` (${fg} on ${bg})` : "";
|
|
543
701
|
return diag("DEK031", {
|
|
544
702
|
message: `${describeTarget(first.box, first.text)}has contrast ${ratio}${colors}, below ${threshold}:1${size} ${atSteps(steps)}`,
|
|
545
|
-
path: contrastPath(first
|
|
703
|
+
path: contrastPath(first, paths),
|
|
546
704
|
slug: first.slug,
|
|
547
|
-
hint: contrastHint(first
|
|
705
|
+
hint: contrastHint(first, threshold),
|
|
548
706
|
data: {
|
|
549
707
|
box: first.box,
|
|
550
708
|
...(first.text ? { text: first.text } : {}),
|
package/src/core/voice.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import type { VoiceDict } from "./cue.ts";
|
|
6
6
|
import { deckPaths } from "./deck-paths.ts";
|
|
7
7
|
import { DekError } from "./error.ts";
|
|
8
|
+
import { compareCodeUnits } from "./order.ts";
|
|
8
9
|
import { cacheDir, deckProjectRoot } from "./path.ts";
|
|
9
10
|
import { readDeckFile } from "./resolve.ts";
|
|
10
11
|
import { writeInside } from "./safe-fs.ts";
|
|
@@ -184,7 +185,7 @@ export function loadVoiceDict(deckDir: string): VoiceDict {
|
|
|
184
185
|
export function writeVoiceDict(deckDir: string, dict: VoiceDict): string {
|
|
185
186
|
const path = deckPaths(deckDir).voiceDict;
|
|
186
187
|
const body = Object.entries(dict)
|
|
187
|
-
.sort(([a], [b]) => a
|
|
188
|
+
.sort(([a], [b]) => compareCodeUnits(a, b))
|
|
188
189
|
.map(([key, entry]) => {
|
|
189
190
|
const accent = entry.accent !== undefined ? `\naccent = ${entry.accent}` : "";
|
|
190
191
|
return `[${escapeTomlKey(key)}]\nkana = ${JSON.stringify(entry.kana)}${accent}\n`;
|
|
@@ -269,10 +270,8 @@ export function parseTimelineJson(text: string, path?: string): Timeline {
|
|
|
269
270
|
|
|
270
271
|
export function loadCachedTimeline(deckDir: string): Timeline | undefined {
|
|
271
272
|
const path = voiceCacheFile(deckDir, "timeline.json");
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
return parseTimelineJson(readFileSync(path, "utf8"), path);
|
|
273
|
+
const source = readDeckFile(deckDir, path);
|
|
274
|
+
return source === undefined ? undefined : parseTimelineJson(source, path);
|
|
276
275
|
}
|
|
277
276
|
|
|
278
277
|
export function tryLoadCachedTimeline(deckDir: string): Timeline | undefined {
|
package/src/core/zod.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
import { DekError } from "./error.ts";
|
|
3
|
+
import { isPlainWord } from "./escape.ts";
|
|
3
4
|
|
|
4
5
|
const REFERENCE = "https://hajimism.github.io/dek/reference/config.html";
|
|
5
6
|
|
|
@@ -21,10 +22,17 @@ export function parseFailure(error: unknown): string {
|
|
|
21
22
|
);
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Each issue as `path: message`. A key in the path is the file's text, which a cloned project or
|
|
27
|
+
* a pack brings along, so one that is no plain word shows as `<key>`: repeated, it could close a
|
|
28
|
+
* code span or start a line of its own.
|
|
29
|
+
*/
|
|
24
30
|
export function formatZodIssues(error: z.ZodError): string {
|
|
25
31
|
return error.issues
|
|
26
32
|
.map((issue) => {
|
|
27
|
-
const path = issue.path
|
|
33
|
+
const path = issue.path
|
|
34
|
+
.map((key) => (isPlainWord(String(key)) ? String(key) : "<key>"))
|
|
35
|
+
.join(".");
|
|
28
36
|
// "expected string, received undefined" says less than "required".
|
|
29
37
|
const message =
|
|
30
38
|
issue.code === "invalid_type" && /received undefined$/.test(issue.message)
|
package/src/server/auth.ts
CHANGED
|
@@ -78,6 +78,27 @@ function forbidden(reason: string): Response {
|
|
|
78
78
|
return new Response(`Forbidden: ${reason}\n`, { status: 403 });
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* What every answer tells the browser: take its type as given, hand it to no other origin's
|
|
83
|
+
* page, and show it in no other origin's frame. Another site can still make the browser ask, but
|
|
84
|
+
* gets nothing to play, apply as a stylesheet, tell apart by whether it loaded, or frame. The
|
|
85
|
+
* frame rule goes on every answer, not only pages: the voice or a listing opened in a frame is
|
|
86
|
+
* a page too.
|
|
87
|
+
*/
|
|
88
|
+
const SAME_ORIGIN_ONLY = {
|
|
89
|
+
"x-content-type-options": "nosniff",
|
|
90
|
+
"cross-origin-resource-policy": "same-origin",
|
|
91
|
+
"content-security-policy": "frame-ancestors 'self'",
|
|
92
|
+
} as const;
|
|
93
|
+
|
|
94
|
+
/** `response` with SAME_ORIGIN_ONLY set; the server passes every answer through it. */
|
|
95
|
+
export function keepToOwnOrigin(response: Response): Response {
|
|
96
|
+
for (const [name, value] of Object.entries(SAME_ORIGIN_ONLY)) {
|
|
97
|
+
response.headers.set(name, value);
|
|
98
|
+
}
|
|
99
|
+
return response;
|
|
100
|
+
}
|
|
101
|
+
|
|
81
102
|
/**
|
|
82
103
|
* Who may read a response. The audience sees the slides as they are shown; the presenter also
|
|
83
104
|
* sees what they are made from, and may move the deck.
|
package/src/server/dev.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { DekError } from "../core/error.ts";
|
|
3
3
|
import type { PlaywrightRunner } from "../core/playwright.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { isRefName, refReadOnlyError, requireProject } from "../core/ref.ts";
|
|
5
|
+
import { locateDeck, type Project } from "../core/resolve.ts";
|
|
5
6
|
import { annotateScript, livePlayerScript, liveReloadScript } from "../runtime/player.ts";
|
|
6
7
|
import {
|
|
7
8
|
clearance,
|
|
9
|
+
keepToOwnOrigin,
|
|
8
10
|
mayRead,
|
|
9
11
|
pairingResponse,
|
|
10
12
|
ROUTE_EXPOSURE,
|
|
@@ -13,7 +15,12 @@ import {
|
|
|
13
15
|
} from "./auth.ts";
|
|
14
16
|
import type { EventFeed } from "./hub.ts";
|
|
15
17
|
import { generateRemotePassword, lanUrls } from "./lan.ts";
|
|
16
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
assertNoDevServer,
|
|
20
|
+
devServerUrl,
|
|
21
|
+
removeDevServerLock,
|
|
22
|
+
writeDevServerLock,
|
|
23
|
+
} from "./lock.ts";
|
|
17
24
|
import { createDevPages } from "./pages.ts";
|
|
18
25
|
import { createPairings, type Pairings } from "./pairing.ts";
|
|
19
26
|
import { createProjectWatch } from "./project-watch.ts";
|
|
@@ -63,7 +70,12 @@ type Access = {
|
|
|
63
70
|
type ServedRoutes = Omit<RouteContext, "upgrade">;
|
|
64
71
|
|
|
65
72
|
export async function startDevServer(options: DevServerOptions): Promise<DevServer> {
|
|
66
|
-
|
|
73
|
+
// A ref is read where it is, never served: the server syncs into what it serves, and runs its
|
|
74
|
+
// slides' scripts in the browser at its own origin.
|
|
75
|
+
const project = requireProject(options.cwd);
|
|
76
|
+
if (options.deck !== undefined && isRefName(options.deck)) {
|
|
77
|
+
throw refReadOnlyError(options.deck);
|
|
78
|
+
}
|
|
67
79
|
// Before the port and the watchers; writing the lock checks again, for a server that raced us.
|
|
68
80
|
assertNoDevServer(project.root);
|
|
69
81
|
const { scopedDeckName, deckDir } = serverScope(project, options);
|
|
@@ -113,7 +125,7 @@ export async function startDevServer(options: DevServerOptions): Promise<DevServ
|
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
const port = server.port ?? 0;
|
|
116
|
-
const url =
|
|
128
|
+
const url = devServerUrl(port);
|
|
117
129
|
const remoteUrls = [...new Set([url, ...(access.remote ? lanUrls(port) : [])])];
|
|
118
130
|
try {
|
|
119
131
|
writeDevServerLock(project.root, url, access.password, scopedDeckName);
|
|
@@ -188,10 +200,17 @@ function listen(port: number | undefined, access: Access, routes: ServedRoutes)
|
|
|
188
200
|
port: port ?? 0,
|
|
189
201
|
// Never Bun's development error page: it shows the source and absolute paths to anyone.
|
|
190
202
|
development: false,
|
|
191
|
-
|
|
203
|
+
// Every answer dek writes leaves through one of these two, so none goes without
|
|
204
|
+
// keepToOwnOrigin. Bun answers a few requests itself before either runs: one its parser
|
|
205
|
+
// refuses (a bad request line, no Host, a body or headers too large) and an upgrade it
|
|
206
|
+
// cannot take; none of those has a body a page could use.
|
|
207
|
+
fetch: async (req, server) => {
|
|
208
|
+
const response = await answer(req, server, access, routes);
|
|
209
|
+
return response && keepToOwnOrigin(response);
|
|
210
|
+
},
|
|
192
211
|
error(error) {
|
|
193
212
|
console.error(error);
|
|
194
|
-
return new Response("Internal Server Error\n", { status: 500 });
|
|
213
|
+
return keepToOwnOrigin(new Response("Internal Server Error\n", { status: 500 }));
|
|
195
214
|
},
|
|
196
215
|
websocket: roomSockets(routes.rooms),
|
|
197
216
|
});
|
|
@@ -222,7 +241,7 @@ function answer(req: Request, server: Bun.Server<WsData>, access: Access, routes
|
|
|
222
241
|
}
|
|
223
242
|
|
|
224
243
|
/** Each socket joins its deck's room; only a presenter's socket may move it or point in it. */
|
|
225
|
-
function roomSockets(rooms: RouteContext["rooms"]): Bun.WebSocketHandler<WsData> {
|
|
244
|
+
export function roomSockets(rooms: RouteContext["rooms"]): Bun.WebSocketHandler<WsData> {
|
|
226
245
|
return {
|
|
227
246
|
// A position or a point is a few dozen bytes; anything near this is neither.
|
|
228
247
|
maxPayloadLength: 4 * 1024,
|
package/src/server/lock.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { DekError } from "../core/error.ts";
|
|
4
|
-
import { writeInside } from "../core/safe-fs.ts";
|
|
4
|
+
import { readSourceIfExists, removeInside, writeInside } from "../core/safe-fs.ts";
|
|
5
5
|
|
|
6
6
|
export type DevServerLock = {
|
|
7
7
|
url: string;
|
|
@@ -15,7 +15,14 @@ function serverLockPath(root: string): string {
|
|
|
15
15
|
return join(root, ".dek", "server.json");
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Whether a process with this pid runs. Only a positive whole number names one process: 0 and a
|
|
20
|
+
* negative pid signal a whole group, which `kill` reports as alive.
|
|
21
|
+
*/
|
|
18
22
|
export function isPidAlive(pid: number): boolean {
|
|
23
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
19
26
|
try {
|
|
20
27
|
process.kill(pid, 0);
|
|
21
28
|
return true;
|
|
@@ -24,16 +31,52 @@ export function isPidAlive(pid: number): boolean {
|
|
|
24
31
|
}
|
|
25
32
|
}
|
|
26
33
|
|
|
34
|
+
/** The hosts a dev server's URL in its lock names: it writes this machine's loopback address. */
|
|
35
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
36
|
+
|
|
37
|
+
/** The URL a dev server on `port` writes in its lock, as `URL` spells it: port 80 takes none. */
|
|
38
|
+
export function devServerUrl(port: number): string {
|
|
39
|
+
return new URL(`http://127.0.0.1:${port}/`).href;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The lock's URL in the form the dev server writes it, `http://<loopback>:<port>/`, or nothing
|
|
44
|
+
* for any other. A server.json a repository brings along is its text, and the URL goes into
|
|
45
|
+
* hints and into the requests `dekc goto` sends. Port 80 is read with or without `:80`.
|
|
46
|
+
*/
|
|
47
|
+
function serverUrl(value: unknown): string | undefined {
|
|
48
|
+
if (typeof value !== "string") {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
let url: URL;
|
|
52
|
+
try {
|
|
53
|
+
url = new URL(value);
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const served =
|
|
58
|
+
url.protocol === "http:" &&
|
|
59
|
+
LOOPBACK_HOSTS.has(url.hostname) &&
|
|
60
|
+
url.pathname === "/" &&
|
|
61
|
+
url.search === "" &&
|
|
62
|
+
url.hash === "" &&
|
|
63
|
+
url.username === "" &&
|
|
64
|
+
url.password === "";
|
|
65
|
+
const written = url.port === "" ? [url.href, `http://${url.hostname}:80/`] : [url.href];
|
|
66
|
+
return served && written.includes(value) ? url.href : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
27
69
|
export function readDevServerLock(root: string): DevServerLock | undefined {
|
|
28
70
|
const path = serverLockPath(root);
|
|
29
71
|
if (!existsSync(path)) {
|
|
30
72
|
return undefined;
|
|
31
73
|
}
|
|
32
74
|
try {
|
|
33
|
-
const parsed = JSON.parse(
|
|
34
|
-
|
|
75
|
+
const parsed = JSON.parse(readSourceIfExists(path, root) ?? "") as DevServerLock;
|
|
76
|
+
const url = serverUrl(parsed.url);
|
|
77
|
+
if (url !== undefined && typeof parsed.pid === "number") {
|
|
35
78
|
return {
|
|
36
|
-
url
|
|
79
|
+
url,
|
|
37
80
|
pid: parsed.pid,
|
|
38
81
|
...(typeof parsed.password === "string" ? { password: parsed.password } : {}),
|
|
39
82
|
...(typeof parsed.deck === "string" ? { deck: parsed.deck } : {}),
|
|
@@ -90,8 +133,8 @@ export function removeDevServerLock(root: string): void {
|
|
|
90
133
|
return;
|
|
91
134
|
}
|
|
92
135
|
try {
|
|
93
|
-
|
|
136
|
+
removeInside(serverLockPath(root), root);
|
|
94
137
|
} catch {
|
|
95
|
-
/*
|
|
138
|
+
/* a lock left behind names a process that is gone, which the next server sees */
|
|
96
139
|
}
|
|
97
140
|
}
|
package/src/server/routes.ts
CHANGED
|
@@ -8,7 +8,9 @@ import { escapeAttr, escapeHtml } from "../core/escape.ts";
|
|
|
8
8
|
import { deckSlides, htmlShell, slidePlace, stampSlide } from "../core/html.ts";
|
|
9
9
|
import { decodePosition } from "../core/live-protocol.ts";
|
|
10
10
|
import { markedPositions, toggleMark } from "../core/marks.ts";
|
|
11
|
+
import { deckProjectRoot } from "../core/path.ts";
|
|
11
12
|
import type { Project, ProjectDeck } from "../core/resolve.ts";
|
|
13
|
+
import { followInside } from "../core/safe-fs.ts";
|
|
12
14
|
import { voiceCacheFile } from "../core/voice.ts";
|
|
13
15
|
import { deckRoutePath, parseDeckRoute, splitDeckPath, type VoiceFile } from "../runtime/routes.ts";
|
|
14
16
|
import { EVENT_EXPOSURE, type Exposure, mayRead, type Route } from "./auth.ts";
|
|
@@ -149,7 +151,8 @@ export function routeRequest(req: Request, ctx: RouteContext): Routed {
|
|
|
149
151
|
const MISSING: Routed = { route: "missing", respond: notFound };
|
|
150
152
|
|
|
151
153
|
function voiceResponse(deck: ProjectDeck | undefined, file: VoiceFile): Response {
|
|
152
|
-
|
|
154
|
+
// A link out of the project is no voice of the deck's, and is not served.
|
|
155
|
+
const path = deck && followInside(voiceCacheFile(deck.dir, file), deckProjectRoot(deck.dir));
|
|
153
156
|
if (!path || !existsSync(path)) {
|
|
154
157
|
return notFound();
|
|
155
158
|
}
|
|
@@ -291,7 +294,9 @@ function jsonNotFound(deckName: string): Response {
|
|
|
291
294
|
function safeDeckAsset(deckDir: string, relative: string): string | undefined {
|
|
292
295
|
const root = join(deckDir, "assets");
|
|
293
296
|
const found = fileInside(join(root, relative), root);
|
|
294
|
-
|
|
297
|
+
// assets/ may itself be a link, and then everything under what it points to is "inside" it:
|
|
298
|
+
// what is served must also be in the project, as every file dek reads is.
|
|
299
|
+
return found.kind === "file" ? followInside(found.path, deckProjectRoot(deckDir)) : undefined;
|
|
295
300
|
}
|
|
296
301
|
|
|
297
302
|
function jsonResponse(body: unknown, status = 200): Response {
|
package/src/server/serial.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs `run` one at a time: a call while it runs queues one more run after it, however many
|
|
3
|
+
* calls come. Each caller's `run` reports its own failures, as diagnostics or a line on stderr;
|
|
4
|
+
* one that still throws is a bug, logged to the dev server's stderr as Bun.serve's error handler
|
|
5
|
+
* logs one, and the queued run still runs.
|
|
6
|
+
*/
|
|
1
7
|
export function createSerialTask(run: () => Promise<void>): () => void {
|
|
2
8
|
let running = false;
|
|
3
9
|
let queued = false;
|
|
@@ -13,8 +19,8 @@ export function createSerialTask(run: () => Promise<void>): () => void {
|
|
|
13
19
|
queued = false;
|
|
14
20
|
try {
|
|
15
21
|
await run();
|
|
16
|
-
} catch {
|
|
17
|
-
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error(error);
|
|
18
24
|
}
|
|
19
25
|
} while (queued);
|
|
20
26
|
} finally {
|
package/src/theme/default.css
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
background: var(--bg);
|
|
21
21
|
color: var(--fg);
|
|
22
22
|
font-family: var(--font-body);
|
|
23
|
+
/* Japanese breaks between phrases, and no paragraph ends on a word left alone. */
|
|
24
|
+
word-break: auto-phrase;
|
|
25
|
+
text-wrap: pretty;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
.slide .slide-title {
|
|
@@ -27,6 +30,7 @@
|
|
|
27
30
|
font-family: var(--font-title);
|
|
28
31
|
font-size: var(--size-title);
|
|
29
32
|
line-height: 1.2;
|
|
33
|
+
text-wrap: balance;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
/* @layout title
|
package/src/video/bake.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type ResolvedDeck,
|
|
10
10
|
requireSection,
|
|
11
11
|
} from "../core/resolve.ts";
|
|
12
|
-
import { dropLinks, outputDir, outputPath, replaceFile } from "../core/safe-fs.ts";
|
|
12
|
+
import { dropLinks, outputDir, outputPath, replaceFile, requireInside } from "../core/safe-fs.ts";
|
|
13
13
|
import { logicalSize } from "../core/size.ts";
|
|
14
14
|
import { sliceTimeline, slideTimeRange, type Timeline } from "../core/timeline.ts";
|
|
15
15
|
import { videoDocument } from "../core/video-document.ts";
|
|
@@ -69,7 +69,11 @@ async function bakeProjectDeck(
|
|
|
69
69
|
hint: "run `dekc voice`",
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
// ffmpeg opens the audio itself, so it is resolved here: a link out of the project is an error.
|
|
73
|
+
let timeline = {
|
|
74
|
+
...loaded,
|
|
75
|
+
audio: requireInside(resolveTimelineAudio(loaded, timelinePath), project.root),
|
|
76
|
+
};
|
|
73
77
|
const fps = options.fps ?? 30;
|
|
74
78
|
const size = logicalSize(deck.deck.ratio);
|
|
75
79
|
const html = videoDocument({ project, deck }, await playerScript());
|