@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/html.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { assetInliner } from "./assets.ts";
|
|
2
|
+
import { deckPaths } from "./deck-paths.ts";
|
|
3
|
+
import { escapeAttr, escapeHtml } from "./escape.ts";
|
|
4
|
+
import { isInside } from "./path.ts";
|
|
5
|
+
import { type ProjectDeck, readDeckFile } from "./resolve.ts";
|
|
6
|
+
import { FALLBACK_LANG, type Section } from "./schema.ts";
|
|
7
|
+
import { deckLayouts, type SkeletonLayouts, skeletonHtml } from "./skeleton.ts";
|
|
8
|
+
|
|
9
|
+
export function htmlShell(options: {
|
|
10
|
+
lang?: string;
|
|
11
|
+
title?: string;
|
|
12
|
+
head?: string;
|
|
13
|
+
body: string;
|
|
14
|
+
bodyAttrs?: string;
|
|
15
|
+
}): string {
|
|
16
|
+
const lang = escapeAttr(options.lang ?? FALLBACK_LANG);
|
|
17
|
+
const title =
|
|
18
|
+
options.title === undefined ? "" : `\n <title>${escapeHtml(options.title)}</title>`;
|
|
19
|
+
const head = options.head ? `\n ${options.head}` : "";
|
|
20
|
+
return `<!DOCTYPE html>
|
|
21
|
+
<html lang="${lang}">
|
|
22
|
+
<head>
|
|
23
|
+
<meta charset="utf-8">
|
|
24
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">${title}${head}
|
|
25
|
+
</head>
|
|
26
|
+
<body${options.bodyAttrs ?? ""}>
|
|
27
|
+
${options.body}
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function hasSlideClass(className: string | null | undefined): boolean {
|
|
34
|
+
return (className ?? "").split(/\s+/).some((token) => token.toLowerCase() === "slide");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The first `<section class="slide">`, as written, read by the same parser the lint scan uses: a
|
|
39
|
+
* slide inside a comment or a `data-class` attribute is not one. A marker goes before its start
|
|
40
|
+
* tag and after its end tag; where the markers land, less their own length, is where it was
|
|
41
|
+
* written. A slide that never closes runs to `</body>`, or to the end.
|
|
42
|
+
*/
|
|
43
|
+
export function extractSlideSection(html: string): string | undefined {
|
|
44
|
+
const mark = uniqueMark(html);
|
|
45
|
+
let found = false;
|
|
46
|
+
let closed = false;
|
|
47
|
+
const marked = rewriteHtml(html, (rewriter) => {
|
|
48
|
+
rewriter.on("section", {
|
|
49
|
+
element(el) {
|
|
50
|
+
if (found || !hasSlideClass(el.getAttribute("class"))) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
found = true;
|
|
54
|
+
el.before(mark, { html: true });
|
|
55
|
+
el.onEndTag((end) => {
|
|
56
|
+
closed = true;
|
|
57
|
+
end.after(mark, { html: true });
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
if (!found) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const start = marked.indexOf(mark);
|
|
66
|
+
const slide = closed
|
|
67
|
+
? html.slice(start, marked.indexOf(mark, start + mark.length) - mark.length)
|
|
68
|
+
: undefined;
|
|
69
|
+
// The parser closes an unclosed slide at the end of its parent, past `</body>`.
|
|
70
|
+
if (slide !== undefined && /<\/section\s*>$/i.test(slide)) {
|
|
71
|
+
return slide;
|
|
72
|
+
}
|
|
73
|
+
const bodyClose = html.slice(start).search(/<\/body>/i);
|
|
74
|
+
return bodyClose >= 0 ? html.slice(start, start + bodyClose) : html.slice(start);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A deck's slide markup, each file read at most once. */
|
|
78
|
+
export type DeckSlides = {
|
|
79
|
+
/** Whether `slides/<slug>.html` holds a slide section. */
|
|
80
|
+
has(slug: string): boolean;
|
|
81
|
+
/** The slide section as `slides/<slug>.html` writes it, or undefined when it holds none. */
|
|
82
|
+
written(slug: string): string | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* The slide section to show: the written one, or the skeleton `dekc sync` would write when the
|
|
85
|
+
* file is missing or holds no slide. Lint reports those as DEK001 and DEK007; rendering never
|
|
86
|
+
* stops on them, because at the venue a deck that shows beats one that does not.
|
|
87
|
+
*/
|
|
88
|
+
section(slug: string): string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function deckSlides(deck: ProjectDeck): DeckSlides {
|
|
92
|
+
const paths = deckPaths(deck.dir);
|
|
93
|
+
const read = new Map<string, string | undefined>();
|
|
94
|
+
let layouts: SkeletonLayouts | undefined;
|
|
95
|
+
const skeleton = (slug: string): string | undefined => {
|
|
96
|
+
layouts ??= deckLayouts(deck.dir);
|
|
97
|
+
return skeletonHtml(deck.deck, slug, layouts);
|
|
98
|
+
};
|
|
99
|
+
const written = (slug: string): string | undefined => {
|
|
100
|
+
if (!read.has(slug)) {
|
|
101
|
+
const path = paths.slide(slug, ".html");
|
|
102
|
+
// A slug from a URL may try to climb out of slides/.
|
|
103
|
+
const source =
|
|
104
|
+
isSafeSlideSlug(slug) && isInside(path, paths.slides)
|
|
105
|
+
? readDeckFile(deck.dir, path)
|
|
106
|
+
: undefined;
|
|
107
|
+
read.set(slug, source === undefined ? undefined : extractSlideSection(source));
|
|
108
|
+
}
|
|
109
|
+
return read.get(slug);
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
has: (slug) => written(slug) !== undefined,
|
|
113
|
+
written,
|
|
114
|
+
section: (slug) => written(slug) ?? extractSlideSection(skeleton(slug) ?? "") ?? "",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isSafeSlideSlug(slug: string): boolean {
|
|
119
|
+
return slug.length > 0 && !slug.includes("/") && !slug.includes("\\") && !slug.includes("\0");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Where the script puts a slide: its 1-based number and how many slides the deck has. Every
|
|
124
|
+
* page shows it the same, so a theme can print a folio that follows the script's order.
|
|
125
|
+
*/
|
|
126
|
+
export type SlidePlace = { number: number; count: number };
|
|
127
|
+
|
|
128
|
+
/** The place of the script's `index`-th section. */
|
|
129
|
+
export function slidePlace(sections: readonly Section[], index: number): SlidePlace {
|
|
130
|
+
return { number: index + 1, count: sections.length };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What a slide's section carries on a page; everything but the slug and place is for still pages. */
|
|
134
|
+
export type SlideStamp = {
|
|
135
|
+
slug: string;
|
|
136
|
+
place: SlidePlace;
|
|
137
|
+
/** The `data-step` values shown; the slide is the current one, as the player would mark it. */
|
|
138
|
+
shown?: Set<string>;
|
|
139
|
+
/** The beat a still page shows, for its `stillDrawScript`. */
|
|
140
|
+
beat?: { index: number; step: string };
|
|
141
|
+
/** Inline the deck's files as data: URIs, for a page that stands alone. */
|
|
142
|
+
inline?: { deckDir: string };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One slide section with what the page needs on it, in one parser pass: its `data-slug` unless
|
|
147
|
+
* it names its own, its place as `--dek-slide-number` and `--dek-slide-count`, and for a still
|
|
148
|
+
* page the classes and beat the player would have set.
|
|
149
|
+
*/
|
|
150
|
+
export function stampSlide(html: string, stamp: SlideStamp): string {
|
|
151
|
+
let done = false;
|
|
152
|
+
return rewriteHtml(html, (rewriter) => {
|
|
153
|
+
rewriter.on("section", {
|
|
154
|
+
element(el) {
|
|
155
|
+
if (done || !hasSlideClass(el.getAttribute("class"))) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
done = true;
|
|
159
|
+
if (el.getAttribute("data-slug") === null) {
|
|
160
|
+
el.setAttribute("data-slug", stamp.slug);
|
|
161
|
+
}
|
|
162
|
+
// Ahead of the author's own style, so a slide can still set its own.
|
|
163
|
+
const place = `--dek-slide-number: ${stamp.place.number}; --dek-slide-count: ${stamp.place.count}`;
|
|
164
|
+
const own = el.getAttribute("style")?.trim();
|
|
165
|
+
el.setAttribute("style", own ? `${place}; ${own}` : place);
|
|
166
|
+
if (stamp.shown) {
|
|
167
|
+
addClass(el, "is-current");
|
|
168
|
+
}
|
|
169
|
+
if (stamp.beat) {
|
|
170
|
+
el.setAttribute("data-dek-beat", String(stamp.beat.index));
|
|
171
|
+
el.setAttribute("data-dek-step", stamp.beat.step);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
const { shown } = stamp;
|
|
176
|
+
if (shown) {
|
|
177
|
+
rewriter.on("[data-step]", {
|
|
178
|
+
element(el) {
|
|
179
|
+
const step = el.getAttribute("data-step");
|
|
180
|
+
if (step !== null && shown.has(step)) {
|
|
181
|
+
addClass(el, "is-shown");
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (stamp.inline) {
|
|
187
|
+
rewriter.on("*", assetInliner(stamp.inline.deckDir));
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Every slide of the deck in script order, as the player pages hold them. */
|
|
193
|
+
export function collectSlidesHtml(deck: ProjectDeck, options: { inline: boolean }): string {
|
|
194
|
+
const slides = deckSlides(deck);
|
|
195
|
+
const { sections } = deck.deck;
|
|
196
|
+
return sections
|
|
197
|
+
.map((section, index) =>
|
|
198
|
+
stampSlide(slides.section(section.slug), {
|
|
199
|
+
slug: section.slug,
|
|
200
|
+
place: slidePlace(sections, index),
|
|
201
|
+
...(options.inline ? { inline: { deckDir: deck.dir } } : {}),
|
|
202
|
+
}),
|
|
203
|
+
)
|
|
204
|
+
.join("");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function addClass(el: HTMLRewriterTypes.Element, className: string): void {
|
|
208
|
+
const current = el.getAttribute("class");
|
|
209
|
+
const tokens = current ? current.split(/\s+/).filter(Boolean) : [];
|
|
210
|
+
if (tokens.includes(className)) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
tokens.push(className);
|
|
214
|
+
el.setAttribute("class", tokens.join(" "));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function rewriteHtml(html: string, configure: (rewriter: HTMLRewriter) => void): string {
|
|
218
|
+
const rewriter = new HTMLRewriter();
|
|
219
|
+
configure(rewriter);
|
|
220
|
+
const output = rewriter.transform(html);
|
|
221
|
+
if (typeof output !== "string") {
|
|
222
|
+
throw new Error("HTMLRewriter.transform expected a string");
|
|
223
|
+
}
|
|
224
|
+
return output;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A marker the file does not contain, so splitting on it finds only the ones inserted. */
|
|
228
|
+
export function uniqueMark(html: string): string {
|
|
229
|
+
let mark = "\u{F8FF}";
|
|
230
|
+
while (html.includes(mark)) {
|
|
231
|
+
mark += "\u{F8FF}";
|
|
232
|
+
}
|
|
233
|
+
return mark;
|
|
234
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Position } from "./step.ts";
|
|
2
|
+
|
|
3
|
+
/** The part of a Playwright page these in-page functions are shipped through. */
|
|
4
|
+
export type EvaluatingPage = {
|
|
5
|
+
evaluate<T, A>(fn: (arg: A) => T | Promise<T>, arg?: A): Promise<T>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
10
|
+
* Every animation `go` started runs on one clock, so all of them stop at the same moment:
|
|
11
|
+
* `at` of the view transition's span, the longest end among its pseudo-elements (or among
|
|
12
|
+
* every animation, when the transition animates nothing). A short fade is then over at
|
|
13
|
+
* `at` of a long morph, as it is when the talk plays.
|
|
14
|
+
*/
|
|
15
|
+
export function freezeAt(at: number): void {
|
|
16
|
+
const animations = window.__dekStarted ?? document.getAnimations();
|
|
17
|
+
const endOf = (animation: Animation): number => {
|
|
18
|
+
const end = animation.effect?.getComputedTiming().endTime;
|
|
19
|
+
return typeof end === "number" && Number.isFinite(end) ? end : 0;
|
|
20
|
+
};
|
|
21
|
+
const isTransition = (animation: Animation): boolean => {
|
|
22
|
+
const effect = animation.effect as { pseudoElement?: string | null } | null;
|
|
23
|
+
return (effect?.pseudoElement ?? "").startsWith("::view-transition");
|
|
24
|
+
};
|
|
25
|
+
const transition = animations.filter(isTransition);
|
|
26
|
+
const span = Math.max(0, ...(transition.length > 0 ? transition : animations).map(endOf));
|
|
27
|
+
const moment = at * span;
|
|
28
|
+
for (const animation of animations) {
|
|
29
|
+
animation.pause();
|
|
30
|
+
animation.currentTime = moment;
|
|
31
|
+
}
|
|
32
|
+
// The script started with the same go, so it is as many ms in as the morph.
|
|
33
|
+
window.dekMotion?.seek(moment);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
38
|
+
* Pauses the animations the last `startGoPaused` began, and returns how long they run: the
|
|
39
|
+
* longest finite end among them, or the slide script's motion when that is longer. An animation
|
|
40
|
+
* that repeats forever has no end and adds nothing. With no go recorded, every animation on the
|
|
41
|
+
* page counts, as on a page that has just loaded.
|
|
42
|
+
*/
|
|
43
|
+
export function holdStarted(): number {
|
|
44
|
+
const started = window.__dekStarted ?? document.getAnimations();
|
|
45
|
+
let span = window.dekMotion?.duration() ?? 0;
|
|
46
|
+
for (const animation of started) {
|
|
47
|
+
animation.pause();
|
|
48
|
+
const end = animation.effect?.getComputedTiming().endTime;
|
|
49
|
+
if (typeof end === "number" && Number.isFinite(end)) {
|
|
50
|
+
span = Math.max(span, end);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return span;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
58
|
+
* Seeks the animations the last go began, and the slide script, to `ms` into that go. Nothing
|
|
59
|
+
* older moves: a finished animation still answers `getAnimations()`, and seeking it would play
|
|
60
|
+
* an earlier beat again.
|
|
61
|
+
*/
|
|
62
|
+
export function seekStarted(ms: number): void {
|
|
63
|
+
for (const animation of window.__dekStarted ?? document.getAnimations()) {
|
|
64
|
+
animation.currentTime = ms;
|
|
65
|
+
}
|
|
66
|
+
window.dekMotion?.seek(ms);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing
|
|
71
|
+
* outside itself. Starts the player's `go(position)`, keeps it unawaited on
|
|
72
|
+
* `window.__dekPendingGo`, and resolves once the view transition it opened
|
|
73
|
+
* is ready to seek (true), or after one frame when it opened none or the
|
|
74
|
+
* browser skipped it (false),
|
|
75
|
+
* and every animation the go began has started.
|
|
76
|
+
* `startViewTransition` is wrapped only to get hold of the transition and is
|
|
77
|
+
* put back before this returns; the runtime is not modified.
|
|
78
|
+
*/
|
|
79
|
+
export async function startGoPaused(position: Position): Promise<boolean> {
|
|
80
|
+
const go = window.dekGo;
|
|
81
|
+
if (!go) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
// Older engines have no view transitions, whatever lib.dom says.
|
|
85
|
+
const unwrapped = document.startViewTransition as typeof document.startViewTransition | undefined;
|
|
86
|
+
const original = unwrapped?.bind(document);
|
|
87
|
+
let captured: ViewTransition | undefined;
|
|
88
|
+
let pending: Promise<void>;
|
|
89
|
+
// Whatever the last finished beat left belongs to it; on a page that has just loaded, nothing
|
|
90
|
+
// does, so the first slide's entrance counts as this go's.
|
|
91
|
+
const settled = window.__dekSettled;
|
|
92
|
+
try {
|
|
93
|
+
if (original) {
|
|
94
|
+
const wrapped: typeof document.startViewTransition = (update) => {
|
|
95
|
+
captured = original(update);
|
|
96
|
+
return captured;
|
|
97
|
+
};
|
|
98
|
+
document.startViewTransition = wrapped;
|
|
99
|
+
}
|
|
100
|
+
pending = go(position);
|
|
101
|
+
} finally {
|
|
102
|
+
if (unwrapped) {
|
|
103
|
+
document.startViewTransition = unwrapped;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
window.__dekPendingGo = pending;
|
|
107
|
+
void pending.catch(() => undefined);
|
|
108
|
+
// A transition the browser skips, as it does for a duplicate name, still runs its update;
|
|
109
|
+
// the talk carries on without the animation, and so does this, as a go that opened none.
|
|
110
|
+
const transitioned = captured
|
|
111
|
+
? await captured.ready.then(
|
|
112
|
+
() => true,
|
|
113
|
+
() => false,
|
|
114
|
+
)
|
|
115
|
+
: false;
|
|
116
|
+
if (captured && !transitioned) {
|
|
117
|
+
await captured.updateCallbackDone.catch(() => undefined);
|
|
118
|
+
}
|
|
119
|
+
if (!transitioned) {
|
|
120
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
121
|
+
}
|
|
122
|
+
const started = document.getAnimations().filter((animation) => !settled?.has(animation));
|
|
123
|
+
window.__dekStarted = started;
|
|
124
|
+
// A pending animation may already run a frame ahead on the compositor while its main-thread
|
|
125
|
+
// time still reads 0, where a seek to 0 changes nothing. Once started, the two agree.
|
|
126
|
+
await Promise.allSettled(started.map((animation) => animation.ready));
|
|
127
|
+
return transitioned;
|
|
128
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function splitLines(source: string): string[] {
|
|
2
|
+
return source.split(/\r?\n/);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function joinLines(source: string, lines: string[]): string {
|
|
6
|
+
const newline = source.includes("\r\n") ? "\r\n" : "\n";
|
|
7
|
+
return lines.join(newline);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The lines of each section, as script.md has them: from its heading up to the next section's,
|
|
12
|
+
* the last one to the end. `lines` is the whole file and `starts` the 1-based heading lines, in
|
|
13
|
+
* order.
|
|
14
|
+
*/
|
|
15
|
+
export function sectionChunks(lines: string[], starts: number[]): string[][] {
|
|
16
|
+
return starts.map((start, index) =>
|
|
17
|
+
lines.slice(start - 1, (starts[index + 1] ?? lines.length + 1) - 1),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Where an offset into a source is written: 1-based line, and 1-based column in UTF-16 units. */
|
|
22
|
+
export type LineColumn = { line: number; column: number };
|
|
23
|
+
|
|
24
|
+
/** Turns offsets into `source` into lines and columns, from one pass over its newlines. */
|
|
25
|
+
export function lineLocator(source: string): (offset: number) => LineColumn {
|
|
26
|
+
const starts = [0];
|
|
27
|
+
for (let index = source.indexOf("\n"); index !== -1; index = source.indexOf("\n", index + 1)) {
|
|
28
|
+
starts.push(index + 1);
|
|
29
|
+
}
|
|
30
|
+
return (offset) => {
|
|
31
|
+
let low = 0;
|
|
32
|
+
let high = starts.length - 1;
|
|
33
|
+
while (low < high) {
|
|
34
|
+
const mid = (low + high + 1) >> 1;
|
|
35
|
+
if ((starts[mid] ?? 0) <= offset) {
|
|
36
|
+
low = mid;
|
|
37
|
+
} else {
|
|
38
|
+
high = mid - 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { line: low + 1, column: offset - (starts[low] ?? 0) + 1 };
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, posix, resolve } from "node:path";
|
|
3
|
+
import { classifyAssetRef, isCanonicalAssetPath } from "../assets.ts";
|
|
4
|
+
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
5
|
+
import type { HtmlRef } from "../html-scan.ts";
|
|
6
|
+
import { isInside } from "../path.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* DEK020 to DEK023 for one reference, whether it comes from a slide's markup
|
|
10
|
+
* or a stylesheet's `url()`. A resource the page loads must exist and be written
|
|
11
|
+
* as `assets/...`; a link must only stay local and inside the deck.
|
|
12
|
+
*/
|
|
13
|
+
export function assetRefDiagnostics(
|
|
14
|
+
ref: Pick<HtmlRef, "value" | "use"> & { tag?: string },
|
|
15
|
+
where: { path: string; line?: number; column?: number; slug?: string; deckDir: string },
|
|
16
|
+
): Diagnostic[] {
|
|
17
|
+
const { deckDir, ...location } = where;
|
|
18
|
+
const from = dirname(where.path);
|
|
19
|
+
const classified = classifyAssetRef(ref.value, { deckDir, from }).kind;
|
|
20
|
+
// A link someone follows, `<a>` or `<area>`, leaves from wherever the slide is read, the deck
|
|
21
|
+
// or its slides/, and must stay in the deck from both. A resource is found from either, and a
|
|
22
|
+
// full document's `<link>` sits in the head the deck never shows.
|
|
23
|
+
const followed = ref.use === "link" && ref.tag !== "link";
|
|
24
|
+
const kind =
|
|
25
|
+
followed &&
|
|
26
|
+
(classified === "missing" || classified === "file") &&
|
|
27
|
+
leavesFromEither(ref.value, deckDir, from)
|
|
28
|
+
? "escape"
|
|
29
|
+
: classified;
|
|
30
|
+
if (kind === "skip") {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
if (kind === "remote") {
|
|
34
|
+
return [
|
|
35
|
+
diag("DEK020", {
|
|
36
|
+
message: `remote URL "${ref.value}"`,
|
|
37
|
+
...location,
|
|
38
|
+
hint: remoteHint(ref.value),
|
|
39
|
+
data: { url: ref.value },
|
|
40
|
+
}),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
if (kind === "escape") {
|
|
44
|
+
return [
|
|
45
|
+
diag("DEK022", {
|
|
46
|
+
message: `path "${ref.value}" is outside the deck directory`,
|
|
47
|
+
...location,
|
|
48
|
+
data: { path: ref.value },
|
|
49
|
+
}),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
if (ref.use === "link") {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
if (kind === "missing") {
|
|
56
|
+
return [
|
|
57
|
+
diag("DEK021", {
|
|
58
|
+
message: `missing ${ref.tag === "img" ? "image" : "file"} "${ref.value}"`,
|
|
59
|
+
...location,
|
|
60
|
+
...missingImageHint(ref.value, deckDir),
|
|
61
|
+
data: { src: ref.value },
|
|
62
|
+
}),
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
if (!isCanonicalAssetPath(ref.value)) {
|
|
66
|
+
return [
|
|
67
|
+
diag("DEK023", {
|
|
68
|
+
message: `asset "${ref.value}" must be referenced as assets/${posix.basename(ref.value.trim())}`,
|
|
69
|
+
...location,
|
|
70
|
+
data: { src: ref.value },
|
|
71
|
+
}),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function leavesFromEither(value: string, deckDir: string, from: string): boolean {
|
|
78
|
+
const path = value.trim().split(/[?#]/)[0] ?? "";
|
|
79
|
+
return (
|
|
80
|
+
path !== "" &&
|
|
81
|
+
[resolve(deckDir, path), resolve(from, path)].some((target) => !isInside(target, deckDir))
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Decks copy what they use from the project's `assets/` (so a deck stays whole when moved);
|
|
87
|
+
* when the project has the file, the fix is that copy.
|
|
88
|
+
*/
|
|
89
|
+
function missingImageHint(value: string, deckDir: string): { hint?: string } {
|
|
90
|
+
const name = value.trim().split(/[?#]/)[0] ?? "";
|
|
91
|
+
if (!name.startsWith("assets/")) {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
const projectFile = join(dirname(dirname(deckDir)), name);
|
|
95
|
+
if (!existsSync(projectFile)) {
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
return { hint: `the project has it: copy ../../${name} into the deck's assets/` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function remoteHint(value: string): string {
|
|
102
|
+
let name = "";
|
|
103
|
+
try {
|
|
104
|
+
name = posix.basename(new URL(value.trim(), "https://dek.invalid").pathname);
|
|
105
|
+
} catch {}
|
|
106
|
+
return name
|
|
107
|
+
? `download it into assets/ and use assets/${name}`
|
|
108
|
+
: "download it into assets/ and reference it from there";
|
|
109
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { type DekConfig, loadConfig } from "../config.ts";
|
|
2
|
+
import { cssLayoutNames, parseCss, type Stylesheet } from "../css.ts";
|
|
3
|
+
import { deckPaths } from "../deck-paths.ts";
|
|
4
|
+
import type { Diagnostic } from "../diagnostic.ts";
|
|
5
|
+
import { type HtmlScan, scanSlideHtml } from "../html-scan.ts";
|
|
6
|
+
import { type ScriptParts, splitFrontmatter } from "../parse.ts";
|
|
7
|
+
import {
|
|
8
|
+
listSlideFiles,
|
|
9
|
+
listSlides,
|
|
10
|
+
type Project,
|
|
11
|
+
type ProjectDeck,
|
|
12
|
+
readDeckFile,
|
|
13
|
+
} from "../resolve.ts";
|
|
14
|
+
import type { Section } from "../schema.ts";
|
|
15
|
+
import { skeletonHtml } from "../skeleton.ts";
|
|
16
|
+
import { type ThemeFacts, themeFacts } from "../theme-facts.ts";
|
|
17
|
+
|
|
18
|
+
type SlideFile = { slug: string; path: string };
|
|
19
|
+
|
|
20
|
+
type SlideSource = { html: string; scan: HtmlScan; skeleton: boolean };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What every rule reads: the deck, its config, and the files beside script.md. Rules see every
|
|
24
|
+
* slide; `lintDeck` narrows their findings to one slide when asked.
|
|
25
|
+
*/
|
|
26
|
+
export type LintContext = DeckFiles & {
|
|
27
|
+
/** DEK016 and DEK017 of each slide script, by slug: evaluated before the rules run. */
|
|
28
|
+
scripts: Map<string, Diagnostic[]>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The deck as read from disk, each file once. */
|
|
32
|
+
export type DeckFiles = {
|
|
33
|
+
project: Project;
|
|
34
|
+
deck: ProjectDeck;
|
|
35
|
+
config: DekConfig;
|
|
36
|
+
/** script.md, read once and cut at its frontmatter; none when it cannot be cut. */
|
|
37
|
+
script?: ScriptParts;
|
|
38
|
+
/** The first section of each slug, in script order; a second one is DEK004. */
|
|
39
|
+
sectionsBySlug: Map<string, Section>;
|
|
40
|
+
slidesBySlug: Map<string, SlideFile>;
|
|
41
|
+
stylesBySlug: Map<string, SlideFile>;
|
|
42
|
+
/** theme.css, parsed once for every rule that reads it. */
|
|
43
|
+
theme?: ThemeFacts & { path: string; sheet: Stylesheet };
|
|
44
|
+
/** A slide's HTML, read and scanned once, and whether it is still the skeleton `dekc sync` wrote. */
|
|
45
|
+
slideSource(slug: string): SlideSource | undefined;
|
|
46
|
+
/** A slide's own stylesheet, read and parsed once for every rule that reads it. */
|
|
47
|
+
slideStyle(slug: string): { path: string; sheet: Stylesheet } | undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function readDeckFiles(project: Project, deck: ProjectDeck): DeckFiles {
|
|
51
|
+
const themePath = deckPaths(deck.dir).theme;
|
|
52
|
+
const themeCss = readDeckFile(deck.dir, themePath);
|
|
53
|
+
const sectionsBySlug = new Map<string, Section>();
|
|
54
|
+
for (const section of deck.deck.sections) {
|
|
55
|
+
if (!sectionsBySlug.has(section.slug)) {
|
|
56
|
+
sectionsBySlug.set(section.slug, section);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const slidesBySlug = new Map(listSlides(deck.dir).map((slide) => [slide.slug, slide]));
|
|
60
|
+
const sources = new Map<string, SlideSource | undefined>();
|
|
61
|
+
const stylesBySlug = new Map(listSlideFiles(deck.dir, ".css").map((file) => [file.slug, file]));
|
|
62
|
+
const styles = new Map<string, { path: string; sheet: Stylesheet } | undefined>();
|
|
63
|
+
const sheet = themeCss === undefined ? undefined : parseCss(themeCss);
|
|
64
|
+
const layouts = sheet === undefined ? new Set<string>() : cssLayoutNames(sheet);
|
|
65
|
+
const script = scriptParts(deck);
|
|
66
|
+
return {
|
|
67
|
+
project,
|
|
68
|
+
deck,
|
|
69
|
+
config: loadConfig(project.configPath),
|
|
70
|
+
...(script ? { script } : {}),
|
|
71
|
+
sectionsBySlug,
|
|
72
|
+
slidesBySlug,
|
|
73
|
+
stylesBySlug,
|
|
74
|
+
...(sheet === undefined
|
|
75
|
+
? {}
|
|
76
|
+
: {
|
|
77
|
+
theme: { path: themePath, sheet, ...themeFacts(sheet) },
|
|
78
|
+
}),
|
|
79
|
+
slideSource(slug) {
|
|
80
|
+
if (!sources.has(slug)) {
|
|
81
|
+
const slide = slidesBySlug.get(slug);
|
|
82
|
+
const html = slide && readDeckFile(deck.dir, slide.path);
|
|
83
|
+
sources.set(
|
|
84
|
+
slug,
|
|
85
|
+
html === undefined
|
|
86
|
+
? undefined
|
|
87
|
+
: {
|
|
88
|
+
html,
|
|
89
|
+
scan: scanSlideHtml(html),
|
|
90
|
+
skeleton: html === skeletonHtml(deck.deck, slug, layouts),
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return sources.get(slug);
|
|
95
|
+
},
|
|
96
|
+
slideStyle(slug) {
|
|
97
|
+
if (!styles.has(slug)) {
|
|
98
|
+
const style = stylesBySlug.get(slug);
|
|
99
|
+
const css = style && readDeckFile(deck.dir, style.path);
|
|
100
|
+
styles.set(
|
|
101
|
+
slug,
|
|
102
|
+
style && css !== undefined ? { path: style.path, sheet: parseCss(css) } : undefined,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return styles.get(slug);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scriptParts(deck: ProjectDeck): ScriptParts | undefined {
|
|
111
|
+
try {
|
|
112
|
+
return splitFrontmatter(readDeckFile(deck.dir, deck.scriptPath) ?? "", deck.scriptPath);
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|