@hajimism/dek 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ja.md +75 -0
- package/README.md +75 -0
- package/package.json +65 -0
- package/src/cli/address.ts +53 -0
- package/src/cli/build.ts +85 -0
- package/src/cli/check.ts +97 -0
- package/src/cli/commands.ts +851 -0
- package/src/cli/contract.ts +444 -0
- package/src/cli/cues.ts +18 -0
- package/src/cli/files.ts +309 -0
- package/src/cli/flags.ts +295 -0
- package/src/cli/format.ts +156 -0
- package/src/cli/goto.ts +96 -0
- package/src/cli/init.ts +136 -0
- package/src/cli/keep-alive.ts +72 -0
- package/src/cli/lint.ts +84 -0
- package/src/cli/ls.ts +119 -0
- package/src/cli/main.ts +231 -0
- package/src/cli/marks.ts +16 -0
- package/src/cli/mv.ts +37 -0
- package/src/cli/new.ts +64 -0
- package/src/cli/pdf.ts +16 -0
- package/src/cli/qr.ts +466 -0
- package/src/cli/ref.ts +165 -0
- package/src/cli/rehearse.ts +42 -0
- package/src/cli/result.ts +161 -0
- package/src/cli/scope.ts +308 -0
- package/src/cli/serve.ts +125 -0
- package/src/cli/shot.ts +134 -0
- package/src/cli/show.ts +103 -0
- package/src/cli/sync.ts +37 -0
- package/src/cli/text.ts +479 -0
- package/src/cli/theme.ts +56 -0
- package/src/cli/tty.ts +81 -0
- package/src/cli/usage.ts +198 -0
- package/src/cli/video.ts +17 -0
- package/src/cli/voice.ts +113 -0
- package/src/cli.ts +4 -0
- package/src/core/assets.ts +239 -0
- package/src/core/build.ts +77 -0
- package/src/core/bundled-theme.ts +15 -0
- package/src/core/capture-go.ts +72 -0
- package/src/core/chrome.ts +128 -0
- package/src/core/config-keys.ts +82 -0
- package/src/core/config.ts +95 -0
- package/src/core/css-scan.ts +158 -0
- package/src/core/css-transform.ts +140 -0
- package/src/core/css-value.ts +127 -0
- package/src/core/css.ts +545 -0
- package/src/core/cue.ts +187 -0
- package/src/core/deck-paths.ts +34 -0
- package/src/core/diagnostic.ts +114 -0
- package/src/core/document.ts +218 -0
- package/src/core/error.ts +31 -0
- package/src/core/escape.ts +16 -0
- package/src/core/fill.ts +100 -0
- package/src/core/finish-beat.ts +30 -0
- package/src/core/github.ts +234 -0
- package/src/core/html-scan.ts +253 -0
- package/src/core/html.ts +234 -0
- package/src/core/in-page-go.ts +128 -0
- package/src/core/lines.ts +43 -0
- package/src/core/lint/asset-refs.ts +109 -0
- package/src/core/lint/context.ts +116 -0
- package/src/core/lint/files.ts +117 -0
- package/src/core/lint/keys.ts +48 -0
- package/src/core/lint/morph.ts +91 -0
- package/src/core/lint/script.ts +102 -0
- package/src/core/lint/shared-styles.ts +165 -0
- package/src/core/lint/slide-html.ts +466 -0
- package/src/core/lint/slide-script.ts +179 -0
- package/src/core/lint/theme-css.ts +295 -0
- package/src/core/lint/timing.ts +66 -0
- package/src/core/lint/tokens.ts +520 -0
- package/src/core/lint/voice.ts +76 -0
- package/src/core/lint.ts +137 -0
- package/src/core/live-protocol.ts +130 -0
- package/src/core/marks.ts +198 -0
- package/src/core/mv.ts +293 -0
- package/src/core/ogp.ts +99 -0
- package/src/core/optional.ts +24 -0
- package/src/core/overflow.ts +220 -0
- package/src/core/page-pool.ts +53 -0
- package/src/core/page.ts +66 -0
- package/src/core/parse.ts +307 -0
- package/src/core/path.ts +58 -0
- package/src/core/pdf.ts +71 -0
- package/src/core/playwright-module.d.ts +43 -0
- package/src/core/playwright-motion.ts +42 -0
- package/src/core/playwright-sheet.ts +32 -0
- package/src/core/playwright-visual.ts +391 -0
- package/src/core/playwright-worker.ts +5 -0
- package/src/core/playwright.ts +431 -0
- package/src/core/position.ts +146 -0
- package/src/core/presenter-state.ts +43 -0
- package/src/core/presenter.ts +131 -0
- package/src/core/prose.ts +16 -0
- package/src/core/pseudo-text.ts +67 -0
- package/src/core/rail-width.ts +4 -0
- package/src/core/ref-name.ts +110 -0
- package/src/core/ref.ts +238 -0
- package/src/core/resolve.ts +228 -0
- package/src/core/rumdl.ts +99 -0
- package/src/core/safe-fs.ts +196 -0
- package/src/core/sarif.ts +135 -0
- package/src/core/schema.ts +89 -0
- package/src/core/script-lines.ts +53 -0
- package/src/core/sheet.ts +223 -0
- package/src/core/shot/morph.ts +80 -0
- package/src/core/shot/motion.ts +187 -0
- package/src/core/shot/still.ts +216 -0
- package/src/core/shot-cache.ts +140 -0
- package/src/core/size.ts +11 -0
- package/src/core/skeleton-record.ts +51 -0
- package/src/core/skeleton.ts +89 -0
- package/src/core/slide-draw.ts +165 -0
- package/src/core/slide-eval-worker.ts +58 -0
- package/src/core/slide-measure.ts +272 -0
- package/src/core/slide-script-eval.ts +258 -0
- package/src/core/slide-script.ts +252 -0
- package/src/core/spawn.ts +128 -0
- package/src/core/step.ts +94 -0
- package/src/core/still-page.ts +70 -0
- package/src/core/suggest.ts +33 -0
- package/src/core/sync.ts +316 -0
- package/src/core/text-contrast.ts +466 -0
- package/src/core/theme-excerpt.ts +187 -0
- package/src/core/theme-facts.ts +51 -0
- package/src/core/timeline.ts +216 -0
- package/src/core/timing.ts +88 -0
- package/src/core/toml-keys.ts +349 -0
- package/src/core/url-attributes.ts +103 -0
- package/src/core/video-document.ts +15 -0
- package/src/core/visual-cache.ts +100 -0
- package/src/core/visual.ts +558 -0
- package/src/core/voice.ts +321 -0
- package/src/core/zod.ts +79 -0
- package/src/runtime/browser.ts +203 -0
- package/src/runtime/clone.ts +52 -0
- package/src/runtime/deck-control.ts +43 -0
- package/src/runtime/fit.ts +32 -0
- package/src/runtime/go.ts +56 -0
- package/src/runtime/input.ts +128 -0
- package/src/runtime/laser-view.ts +110 -0
- package/src/runtime/laser.ts +145 -0
- package/src/runtime/live-host.ts +33 -0
- package/src/runtime/live-reload.ts +29 -0
- package/src/runtime/live.ts +88 -0
- package/src/runtime/marks-view.ts +74 -0
- package/src/runtime/motion.ts +113 -0
- package/src/runtime/navigator.ts +189 -0
- package/src/runtime/player.ts +28 -0
- package/src/runtime/presenter-view.ts +289 -0
- package/src/runtime/presenter.ts +52 -0
- package/src/runtime/rail-view.ts +229 -0
- package/src/runtime/rail.ts +52 -0
- package/src/runtime/rehearse.ts +264 -0
- package/src/runtime/routes.ts +142 -0
- package/src/runtime/settle.ts +45 -0
- package/src/runtime/slide.d.ts +28 -0
- package/src/runtime/socket.ts +143 -0
- package/src/runtime/stage.ts +259 -0
- package/src/runtime/step.ts +139 -0
- package/src/runtime/window.ts +38 -0
- package/src/server/auth.ts +210 -0
- package/src/server/deck-snapshot.ts +146 -0
- package/src/server/dev.ts +251 -0
- package/src/server/hub.ts +104 -0
- package/src/server/lan.ts +51 -0
- package/src/server/lock.ts +97 -0
- package/src/server/pages.ts +71 -0
- package/src/server/pairing.ts +40 -0
- package/src/server/project-watch.ts +142 -0
- package/src/server/rooms.ts +133 -0
- package/src/server/routes.ts +286 -0
- package/src/server/serial.ts +25 -0
- package/src/server/watch.ts +308 -0
- package/src/theme/default.css +175 -0
- package/src/video/bake.ts +152 -0
- package/src/video/mux.ts +103 -0
- package/src/video/recorder.ts +115 -0
- package/src/video/sidecar.ts +60 -0
- package/src/video/worker.ts +51 -0
- package/src/voice/engine.ts +177 -0
- package/src/voice/synth.ts +127 -0
- package/src/voice/wav.ts +115 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text contrast measured from pixels. The page is drawn four ways: as shown,
|
|
3
|
+
* with every glyph transparent, and with every glyph filled white and black.
|
|
4
|
+
* White minus black is where glyphs cover, whatever their color and whatever
|
|
5
|
+
* sits above them; within that, the shown pixel is the text as the audience
|
|
6
|
+
* sees it and the bare pixel is what it sits on. Gradients, images, glows,
|
|
7
|
+
* opacity, blend modes, and colors no parser reads are all measured as drawn.
|
|
8
|
+
*
|
|
9
|
+
* A text's shadow can set it off, as a halo does, but does not hide it: where
|
|
10
|
+
* a text casts one, the page is drawn once more with no shadows, and each
|
|
11
|
+
* pixel reads against its shadow or what is behind it, whichever it stands out
|
|
12
|
+
* from. An offset shadow is decoration; a glow is what the text is read on.
|
|
13
|
+
*
|
|
14
|
+
* The pure functions below run in Node for tests and in the page as source,
|
|
15
|
+
* so each references nothing outside this file.
|
|
16
|
+
*/
|
|
17
|
+
import type { Box } from "./slide-measure.ts";
|
|
18
|
+
|
|
19
|
+
export type Rgb = [number, number, number];
|
|
20
|
+
|
|
21
|
+
/** RGBA bytes, row by row, as `ImageData` holds them. */
|
|
22
|
+
export type Pixels = { width: number; height: number; data: ArrayLike<number> };
|
|
23
|
+
|
|
24
|
+
export type TextLayers = {
|
|
25
|
+
/** The page as the audience sees it. */
|
|
26
|
+
shown: Pixels;
|
|
27
|
+
/** Every glyph transparent: what the text sits on. */
|
|
28
|
+
bare: Pixels;
|
|
29
|
+
/** The bare layer with no text casting a shadow; drawn only when some text casts one. */
|
|
30
|
+
shadowless?: Pixels;
|
|
31
|
+
/** Every glyph filled white. */
|
|
32
|
+
white: Pixels;
|
|
33
|
+
/** Every glyph filled black. */
|
|
34
|
+
black: Pixels;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The layers the page is redrawn as; `shown` needs no change. */
|
|
38
|
+
type TextLayer = Exclude<keyof TextLayers, "shown">;
|
|
39
|
+
|
|
40
|
+
type TextContrast = {
|
|
41
|
+
ratio: number;
|
|
42
|
+
/** The text's color at the pixel that reads worst. */
|
|
43
|
+
fg: Rgb;
|
|
44
|
+
/** What that pixel sits on. */
|
|
45
|
+
bg: Rgb;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function relativeLuminance([r, g, b]: Rgb): number {
|
|
49
|
+
const linear = (channel: number): number => {
|
|
50
|
+
const value = channel / 255;
|
|
51
|
+
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
52
|
+
};
|
|
53
|
+
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function contrastRatio(foreground: Rgb, background: Rgb): number {
|
|
57
|
+
const first = relativeLuminance(foreground);
|
|
58
|
+
const second = relativeLuminance(background);
|
|
59
|
+
const [hi, lo] = first > second ? [first, second] : [second, first];
|
|
60
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type TextSample = {
|
|
64
|
+
fontSize?: number;
|
|
65
|
+
fontWeight?: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** WCAG large text: 24px (18pt) regular, or 18.66px (14pt) bold. */
|
|
69
|
+
const LARGE_TEXT_PX = 24;
|
|
70
|
+
const LARGE_BOLD_TEXT_PX = 18.66;
|
|
71
|
+
const BOLD_WEIGHT = 700;
|
|
72
|
+
|
|
73
|
+
function isLargeText(sample: TextSample): boolean {
|
|
74
|
+
if (sample.fontSize === undefined) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (sample.fontSize >= LARGE_TEXT_PX) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return sample.fontSize >= LARGE_BOLD_TEXT_PX && (sample.fontWeight ?? 400) >= BOLD_WEIGHT;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 3:1 for large text, 4.5:1 otherwise. Unknown size falls back to 4.5:1. */
|
|
84
|
+
export function contrastThreshold(sample: TextSample): 3 | 4.5 {
|
|
85
|
+
return isLargeText(sample) ? 3 : 4.5;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parseCssRgb(color: string): Rgb | undefined {
|
|
89
|
+
const match = color.match(/rgba?\(\s*([\d.]+)(?:\s*,\s*|\s+)([\d.]+)(?:\s*,\s*|\s+)([\d.]+)/i);
|
|
90
|
+
if (!match) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One text to measure. */
|
|
97
|
+
type TextBoxes = {
|
|
98
|
+
/** The line boxes of its own text. */
|
|
99
|
+
rects: Box[];
|
|
100
|
+
/** The boxes of other texts that cross them. */
|
|
101
|
+
overlaps?: Box[];
|
|
102
|
+
/** Its opacity with every ancestor's multiplied in; 1 when left out. */
|
|
103
|
+
opacity?: number;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The contrast of one text, or undefined when none of its glyphs shows.
|
|
108
|
+
*
|
|
109
|
+
* Only the pixels its glyphs cover most are read, and each is taken back to
|
|
110
|
+
* the color a glyph covering the whole pixel would draw: the blend is undone
|
|
111
|
+
* by the coverage the masks show, less the text's own opacity, which the
|
|
112
|
+
* audience does see. A solid color measures exactly, and a hyphen too thin to
|
|
113
|
+
* cover any pixel still reads at its own color. The ratio is the one all but
|
|
114
|
+
* the worst 2% of those pixels reach: a gradient is judged by the part that
|
|
115
|
+
* reads worst, a stray pixel does not decide the verdict.
|
|
116
|
+
*
|
|
117
|
+
* The masks cannot tell one text's glyphs from another's, so pixels inside
|
|
118
|
+
* `overlaps` are read only when nothing else is.
|
|
119
|
+
*/
|
|
120
|
+
export function measureTextContrast(
|
|
121
|
+
layers: TextLayers,
|
|
122
|
+
{ rects, overlaps = [], opacity = 1 }: TextBoxes,
|
|
123
|
+
): TextContrast | undefined {
|
|
124
|
+
// One step of an 8-bit channel.
|
|
125
|
+
const COVERAGE_SLACK = 1 / 255;
|
|
126
|
+
const WORST_SHARE = 0.02;
|
|
127
|
+
// Fully transparent text draws nothing; any glyph pixel under it belongs to another text.
|
|
128
|
+
if (opacity <= 0) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const { shown, bare, shadowless, white, black } = layers;
|
|
132
|
+
const behind = shadowless ? [bare, shadowless] : [bare];
|
|
133
|
+
const at = (image: Pixels, i: number): Rgb => [
|
|
134
|
+
image.data[i] ?? 0,
|
|
135
|
+
image.data[i + 1] ?? 0,
|
|
136
|
+
image.data[i + 2] ?? 0,
|
|
137
|
+
];
|
|
138
|
+
const inside = (x: number, y: number, box: Box): boolean =>
|
|
139
|
+
x >= box.left && x < box.right && y >= box.top && y < box.bottom;
|
|
140
|
+
const covered: Array<{ i: number; coverage: number; shared: boolean }> = [];
|
|
141
|
+
for (const rect of rects) {
|
|
142
|
+
const top = Math.max(0, Math.floor(rect.top));
|
|
143
|
+
const bottom = Math.min(shown.height, Math.ceil(rect.bottom));
|
|
144
|
+
const left = Math.max(0, Math.floor(rect.left));
|
|
145
|
+
const right = Math.min(shown.width, Math.ceil(rect.right));
|
|
146
|
+
for (let y = top; y < bottom; y++) {
|
|
147
|
+
for (let x = left; x < right; x++) {
|
|
148
|
+
const i = (y * shown.width + x) * 4;
|
|
149
|
+
const [wr, wg, wb] = at(white, i);
|
|
150
|
+
const [kr, kg, kb] = at(black, i);
|
|
151
|
+
const coverage = (wr - kr + wg - kg + wb - kb) / (3 * 255);
|
|
152
|
+
if (coverage > 0) {
|
|
153
|
+
const shared = overlaps.some((box) => inside(x + 0.5, y + 0.5, box));
|
|
154
|
+
covered.push({ i, coverage, shared });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const own = covered.filter((pixel) => !pixel.shared);
|
|
160
|
+
const read = own.length > 0 ? own : covered;
|
|
161
|
+
let most = 0;
|
|
162
|
+
for (const pixel of read) {
|
|
163
|
+
most = Math.max(most, pixel.coverage);
|
|
164
|
+
}
|
|
165
|
+
const samples = read
|
|
166
|
+
.filter((pixel) => pixel.coverage >= most - COVERAGE_SLACK)
|
|
167
|
+
.map(({ i, coverage }) => {
|
|
168
|
+
// The glyph was drawn over its shadow, so the blend is undone against the bare layer.
|
|
169
|
+
const under = at(bare, i);
|
|
170
|
+
const [r, g, b] = at(shown, i);
|
|
171
|
+
// The blend a glyph covering the whole pixel would make, at the text's own opacity.
|
|
172
|
+
const whole = Math.min(1, opacity) / coverage;
|
|
173
|
+
const unblend = (drawn: number, below: number): number =>
|
|
174
|
+
Math.round(Math.min(255, Math.max(0, below + (drawn - below) * whole)));
|
|
175
|
+
const fg: Rgb = [unblend(r, under[0]), unblend(g, under[1]), unblend(b, under[2])];
|
|
176
|
+
return behind
|
|
177
|
+
.map((layer) => {
|
|
178
|
+
const bg = at(layer, i);
|
|
179
|
+
return { ratio: contrastRatio(fg, bg), fg, bg };
|
|
180
|
+
})
|
|
181
|
+
.reduce((best, reading) => (reading.ratio > best.ratio ? reading : best));
|
|
182
|
+
})
|
|
183
|
+
.sort((a, b) => a.ratio - b.ratio);
|
|
184
|
+
return samples[Math.floor(samples.length * WORST_SHARE)];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The stylesheet that redraws the page as one layer. Transitions stop so the
|
|
189
|
+
* change lands at once. SVG text is filled rather than text-filled, so its fill
|
|
190
|
+
* changes with the layer, and its stroke goes. A pseudo-element keeps its own fill, since what it
|
|
191
|
+
* draws is decoration and belongs to the background in every layer, unless it
|
|
192
|
+
* was marked as drawing text (`PseudoText`), which is measured as text is.
|
|
193
|
+
*
|
|
194
|
+
* The white and black layers only find where glyphs are, so text is drawn unblended there: a
|
|
195
|
+
* blend mode would scale white and black by what is under them. The other layers keep it.
|
|
196
|
+
*/
|
|
197
|
+
export function textLayerCss(layer: TextLayer): string {
|
|
198
|
+
const glyphless = layer === "bare" || layer === "shadowless";
|
|
199
|
+
const fill = glyphless ? "transparent" : { white: "#fff", black: "#000" }[layer];
|
|
200
|
+
const clipped = glyphless ? "\n[data-dek-clip-text] { background: none !important; }" : "";
|
|
201
|
+
const unblended = glyphless ? "" : "\n[data-dek-blend] { mix-blend-mode: normal !important; }";
|
|
202
|
+
const unshadowed =
|
|
203
|
+
layer === "shadowless" ? "\n*, *::before, *::after { text-shadow: none !important; }" : "";
|
|
204
|
+
return `*, *::before, *::after { transition: none !important; }
|
|
205
|
+
* { -webkit-text-fill-color: ${fill} !important; }
|
|
206
|
+
*::before, *::after { -webkit-text-fill-color: initial !important; }
|
|
207
|
+
[data-dek-text-before]::before, [data-dek-text-after]::after { -webkit-text-fill-color: ${fill} !important; }
|
|
208
|
+
svg text, svg tspan, svg textPath { fill: ${fill} !important; stroke: transparent !important; }${clipped}${unblended}${unshadowed}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Runs in the page: marks text whose background is clipped to its glyphs, so the bare layer can
|
|
213
|
+
* take that background away with the glyphs, and each element that blends text into what is under
|
|
214
|
+
* it, so the masks can draw it unblended. Says whether any text casts a shadow.
|
|
215
|
+
*/
|
|
216
|
+
function markTextLayers(): { shadowed: boolean } {
|
|
217
|
+
let shadowed = false;
|
|
218
|
+
for (const el of document.querySelectorAll("*")) {
|
|
219
|
+
const style = getComputedStyle(el);
|
|
220
|
+
if (style.backgroundClip === "text") {
|
|
221
|
+
el.setAttribute("data-dek-clip-text", "");
|
|
222
|
+
}
|
|
223
|
+
if (style.mixBlendMode !== "normal" && el.textContent?.trim()) {
|
|
224
|
+
el.setAttribute("data-dek-blend", "");
|
|
225
|
+
}
|
|
226
|
+
shadowed ||= [style, getComputedStyle(el, "::before"), getComputedStyle(el, "::after")].some(
|
|
227
|
+
(drawn) => drawn.textShadow !== "none",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return { shadowed };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Each layer as a base64 PNG; the shadowless one only when it was drawn. */
|
|
234
|
+
type EncodedLayers = { [layer in keyof TextLayers]: string };
|
|
235
|
+
|
|
236
|
+
type SamplerInput = { layers: EncodedLayers; texts: TextBoxes[] };
|
|
237
|
+
|
|
238
|
+
/** Each text, with the boxes of every other text that crosses one of its own. */
|
|
239
|
+
export function withOverlaps(texts: TextBoxes[]): TextBoxes[] {
|
|
240
|
+
const crosses = (a: Box, b: Box): boolean =>
|
|
241
|
+
a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
|
242
|
+
return texts.map((text, index) => ({
|
|
243
|
+
...text,
|
|
244
|
+
overlaps: texts.flatMap((other, at) =>
|
|
245
|
+
at === index
|
|
246
|
+
? []
|
|
247
|
+
: other.rects.filter((box) => text.rects.some((rect) => crosses(rect, box))),
|
|
248
|
+
),
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Runs in the page, with `measureTextContrast` in scope: decodes the layers and measures each text. */
|
|
253
|
+
async function sampleTextContrasts({
|
|
254
|
+
layers,
|
|
255
|
+
texts,
|
|
256
|
+
}: SamplerInput): Promise<Array<TextContrast | null>> {
|
|
257
|
+
const decode = async (base64: string): Promise<Pixels> => {
|
|
258
|
+
const blob = await (await fetch(`data:image/png;base64,${base64}`)).blob();
|
|
259
|
+
const bitmap = await createImageBitmap(blob);
|
|
260
|
+
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
261
|
+
const context = canvas.getContext("2d");
|
|
262
|
+
if (!context) {
|
|
263
|
+
throw new Error("no 2d context");
|
|
264
|
+
}
|
|
265
|
+
context.drawImage(bitmap, 0, 0);
|
|
266
|
+
return context.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
267
|
+
};
|
|
268
|
+
const decoded: TextLayers = {
|
|
269
|
+
shown: await decode(layers.shown),
|
|
270
|
+
bare: await decode(layers.bare),
|
|
271
|
+
...(layers.shadowless ? { shadowless: await decode(layers.shadowless) } : {}),
|
|
272
|
+
white: await decode(layers.white),
|
|
273
|
+
black: await decode(layers.black),
|
|
274
|
+
};
|
|
275
|
+
return texts.map((text) => measureTextContrast(decoded, text) ?? null);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Defines `window.__dekTextContrast`, the in-page sampler, from this file's own source. */
|
|
279
|
+
export function textContrastScript(): string {
|
|
280
|
+
return `window.__dekTextContrast = (function () {
|
|
281
|
+
${relativeLuminance}
|
|
282
|
+
${contrastRatio}
|
|
283
|
+
${measureTextContrast}
|
|
284
|
+
return ${sampleTextContrasts};
|
|
285
|
+
})();
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
type LayerPage = {
|
|
290
|
+
screenshot(options: { type: "png" }): Promise<Uint8Array>;
|
|
291
|
+
evaluate<T, A>(fn: (arg: A) => T | Promise<T>, arg?: A): Promise<T>;
|
|
292
|
+
addScriptTag(options: { content: string }): Promise<unknown>;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Draws the page as each layer, measures each text, and takes the layer's
|
|
297
|
+
* stylesheet away again, so the page renders as shown for whatever runs next.
|
|
298
|
+
* Entries are null for text none of whose glyphs shows.
|
|
299
|
+
*/
|
|
300
|
+
export async function measurePageTextContrasts(
|
|
301
|
+
page: LayerPage,
|
|
302
|
+
texts: TextBoxes[],
|
|
303
|
+
): Promise<Array<TextContrast | null>> {
|
|
304
|
+
if (texts.length === 0) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
const { shot, setLayer } = layerDrawer(page);
|
|
308
|
+
const shown = await shot();
|
|
309
|
+
const { shadowed } = await page.evaluate(markTextLayers);
|
|
310
|
+
const layers = { shown } as EncodedLayers;
|
|
311
|
+
const drawn = (["bare", "shadowless", "white", "black"] as const).filter(
|
|
312
|
+
(layer) => shadowed || layer !== "shadowless",
|
|
313
|
+
);
|
|
314
|
+
for (const layer of drawn) {
|
|
315
|
+
await setLayer(textLayerCss(layer));
|
|
316
|
+
layers[layer] = await shot();
|
|
317
|
+
}
|
|
318
|
+
await setLayer(null);
|
|
319
|
+
await page.addScriptTag({ content: textContrastScript() });
|
|
320
|
+
return page.evaluate(
|
|
321
|
+
(input) =>
|
|
322
|
+
(
|
|
323
|
+
window as unknown as {
|
|
324
|
+
__dekTextContrast: (arg: SamplerInput) => Promise<Array<TextContrast | null>>;
|
|
325
|
+
}
|
|
326
|
+
).__dekTextContrast(input),
|
|
327
|
+
{ layers, texts: withOverlaps(texts) },
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Shots of the page and a stylesheet to redraw it with. `setLayer(null)` takes the layer away
|
|
333
|
+
* again: the glyphs go back first, with transitions still off, so they do not fade back from the
|
|
334
|
+
* last layer's fill in whatever is drawn next.
|
|
335
|
+
*/
|
|
336
|
+
function layerDrawer(page: LayerPage) {
|
|
337
|
+
const shot = async (): Promise<string> =>
|
|
338
|
+
Buffer.from(await page.screenshot({ type: "png" })).toString("base64");
|
|
339
|
+
const setLayer = (css: string | null): Promise<void> =>
|
|
340
|
+
page.evaluate((text) => {
|
|
341
|
+
let style = document.getElementById("dek-text-layer");
|
|
342
|
+
if (text === null) {
|
|
343
|
+
if (style) {
|
|
344
|
+
style.textContent = "*, *::before, *::after { transition: none !important; }";
|
|
345
|
+
// Reading layout applies the change now, while transitions are still off.
|
|
346
|
+
void document.body.offsetWidth;
|
|
347
|
+
style.remove();
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (!style) {
|
|
352
|
+
style = document.createElement("style");
|
|
353
|
+
style.id = "dek-text-layer";
|
|
354
|
+
document.head.append(style);
|
|
355
|
+
}
|
|
356
|
+
style.textContent = text;
|
|
357
|
+
}, css);
|
|
358
|
+
return { shot, setLayer };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* A text to draw alone, with the boxes to look for its glyphs in: an element's own text, by the
|
|
363
|
+
* element's place among the slide's elements as `measureSlideInPage` lists them, or the text a
|
|
364
|
+
* pseudo-element draws, by the mark on its host.
|
|
365
|
+
*/
|
|
366
|
+
export type GlyphTarget = { rects: Box[] } & (
|
|
367
|
+
| { element: number }
|
|
368
|
+
| { host: string; pseudo: "before" | "after" }
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
/** The attribute that marks an element whose own text is drawn alone. */
|
|
372
|
+
const GLYPHS_MARK = "data-dek-glyphs";
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The box each text's glyphs show in, inside its own boxes. Each is drawn alone, every other glyph
|
|
376
|
+
* transparent, in white and in black; where the two differ is where its glyphs show. That can be
|
|
377
|
+
* far smaller than the box it is laid out in: a running head is often a full-width block holding a
|
|
378
|
+
* few words. A text whose every glyph lies under a box drawn above it shows none, as a number is
|
|
379
|
+
* when the next beat's paints its box over it: undefined.
|
|
380
|
+
*/
|
|
381
|
+
export async function measureGlyphBoxes(
|
|
382
|
+
page: LayerPage,
|
|
383
|
+
targets: GlyphTarget[],
|
|
384
|
+
): Promise<Array<Box | undefined>> {
|
|
385
|
+
if (targets.length === 0) {
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
const { shot, setLayer } = layerDrawer(page);
|
|
389
|
+
const elements = targets.flatMap((target) => ("element" in target ? [target.element] : []));
|
|
390
|
+
await page.evaluate(
|
|
391
|
+
({ mark, indices }) => {
|
|
392
|
+
const all = [...(document.querySelector(".slide")?.querySelectorAll("*") ?? [])];
|
|
393
|
+
for (const index of indices) {
|
|
394
|
+
all[index]?.setAttribute(mark, String(index));
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
{ mark: GLYPHS_MARK, indices: elements },
|
|
398
|
+
);
|
|
399
|
+
const alone = (target: GlyphTarget, fill: string): string =>
|
|
400
|
+
`*, *::before, *::after { transition: none !important; -webkit-text-fill-color: transparent !important; }
|
|
401
|
+
svg text, svg tspan, svg textPath { fill: transparent !important; stroke: transparent !important; }
|
|
402
|
+
${
|
|
403
|
+
"element" in target
|
|
404
|
+
? `[${GLYPHS_MARK}="${target.element}"] { -webkit-text-fill-color: ${fill} !important; fill: ${fill} !important; }`
|
|
405
|
+
: `[data-dek-text="${target.host}"][data-dek-text-${target.pseudo}]::${target.pseudo} { -webkit-text-fill-color: ${fill} !important; }`
|
|
406
|
+
}`;
|
|
407
|
+
const pairs: Array<{ white: string; black: string; rects: Box[] }> = [];
|
|
408
|
+
for (const target of targets) {
|
|
409
|
+
await setLayer(alone(target, "#fff"));
|
|
410
|
+
const white = await shot();
|
|
411
|
+
await setLayer(alone(target, "#000"));
|
|
412
|
+
pairs.push({ white, black: await shot(), rects: target.rects });
|
|
413
|
+
}
|
|
414
|
+
await setLayer(null);
|
|
415
|
+
await page.evaluate((mark) => {
|
|
416
|
+
for (const el of document.querySelectorAll(`[${mark}]`)) {
|
|
417
|
+
el.removeAttribute(mark);
|
|
418
|
+
}
|
|
419
|
+
}, GLYPHS_MARK);
|
|
420
|
+
const found = await page.evaluate(async (input) => {
|
|
421
|
+
const decode = async (base64: string) => {
|
|
422
|
+
const blob = await (await fetch(`data:image/png;base64,${base64}`)).blob();
|
|
423
|
+
const bitmap = await createImageBitmap(blob);
|
|
424
|
+
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
425
|
+
const context = canvas.getContext("2d");
|
|
426
|
+
if (!context) {
|
|
427
|
+
throw new Error("no 2d context");
|
|
428
|
+
}
|
|
429
|
+
context.drawImage(bitmap, 0, 0);
|
|
430
|
+
return context.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
431
|
+
};
|
|
432
|
+
const boxes: Array<Box | null> = [];
|
|
433
|
+
for (const { white, black, rects } of input) {
|
|
434
|
+
const [w, k] = [await decode(white), await decode(black)];
|
|
435
|
+
let box: Box | null = null;
|
|
436
|
+
for (const rect of rects) {
|
|
437
|
+
const top = Math.max(0, Math.floor(rect.top));
|
|
438
|
+
const bottom = Math.min(w.height, Math.ceil(rect.bottom));
|
|
439
|
+
const left = Math.max(0, Math.floor(rect.left));
|
|
440
|
+
const right = Math.min(w.width, Math.ceil(rect.right));
|
|
441
|
+
for (let y = top; y < bottom; y++) {
|
|
442
|
+
for (let x = left; x < right; x++) {
|
|
443
|
+
const i = (y * w.width + x) * 4;
|
|
444
|
+
const differs =
|
|
445
|
+
(w.data[i] ?? 0) !== (k.data[i] ?? 0) ||
|
|
446
|
+
(w.data[i + 1] ?? 0) !== (k.data[i + 1] ?? 0) ||
|
|
447
|
+
(w.data[i + 2] ?? 0) !== (k.data[i + 2] ?? 0);
|
|
448
|
+
if (differs) {
|
|
449
|
+
box = box
|
|
450
|
+
? {
|
|
451
|
+
left: Math.min(box.left, x),
|
|
452
|
+
top: Math.min(box.top, y),
|
|
453
|
+
right: Math.max(box.right, x + 1),
|
|
454
|
+
bottom: Math.max(box.bottom, y + 1),
|
|
455
|
+
}
|
|
456
|
+
: { left: x, top: y, right: x + 1, bottom: y + 1 };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
boxes.push(box);
|
|
462
|
+
}
|
|
463
|
+
return boxes;
|
|
464
|
+
}, pairs);
|
|
465
|
+
return found.map((box) => box ?? undefined);
|
|
466
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CssDeclaration,
|
|
3
|
+
isKeyframesPrelude,
|
|
4
|
+
type Stylesheet,
|
|
5
|
+
selectorClasses,
|
|
6
|
+
selectorLayouts,
|
|
7
|
+
splitSelectorList,
|
|
8
|
+
} from "./css.ts";
|
|
9
|
+
import { escapeRegExp } from "./escape.ts";
|
|
10
|
+
|
|
11
|
+
/** Classes the player adds at runtime, so a slide's markup never names them. */
|
|
12
|
+
const RUNTIME_CLASSES = ["is-current", "is-shown"];
|
|
13
|
+
|
|
14
|
+
export type SlideUsage = {
|
|
15
|
+
/** Classes the slide's markup uses. */
|
|
16
|
+
classes: Iterable<string>;
|
|
17
|
+
/** The slide's `data-layout`. */
|
|
18
|
+
layout?: string;
|
|
19
|
+
/** The slide's own stylesheet, whose `var()` and animations reach into the theme too. */
|
|
20
|
+
css?: Stylesheet;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type ExcerptEntry = {
|
|
24
|
+
atPath: string[];
|
|
25
|
+
selector: string;
|
|
26
|
+
decls: CssDeclaration[];
|
|
27
|
+
keyframes?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The part of a theme one slide depends on, as CSS that reads on its own: the
|
|
32
|
+
* rules its classes and layout select, element and state rules under `.slide`,
|
|
33
|
+
* the keyframes those rules animate with, and only the tokens they reach
|
|
34
|
+
* through `var()`. It errs toward keeping a rule, since a rule left out misleads
|
|
35
|
+
* a reader silently while an extra one only costs a line.
|
|
36
|
+
*/
|
|
37
|
+
export function themeExcerpt(sheet: Stylesheet, usage: SlideUsage): string {
|
|
38
|
+
const entries = excerptEntries(sheet, usage);
|
|
39
|
+
const own = usage.css?.decls ?? [];
|
|
40
|
+
const styled = [
|
|
41
|
+
...entries.filter((entry) => !entry.keyframes).flatMap((entry) => entry.decls),
|
|
42
|
+
...own,
|
|
43
|
+
].filter((decl) => !decl.property.startsWith("--"));
|
|
44
|
+
const keyframes = animatedKeyframes(styled, entries);
|
|
45
|
+
const kept = entries.filter((entry) => !entry.keyframes || keyframes.has(entry.keyframes));
|
|
46
|
+
const tokens = reachedTokens(
|
|
47
|
+
[...styled, ...kept.filter((entry) => entry.keyframes).flatMap((entry) => entry.decls)],
|
|
48
|
+
sheet.decls,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return renderExcerpt(
|
|
52
|
+
kept
|
|
53
|
+
.map((entry) => ({
|
|
54
|
+
...entry,
|
|
55
|
+
decls: entry.decls.filter(
|
|
56
|
+
(decl) => !decl.property.startsWith("--") || tokens.has(decl.property),
|
|
57
|
+
),
|
|
58
|
+
}))
|
|
59
|
+
.filter((entry) => entry.decls.length > 0),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every keyframe stop, and every block of declarations with at least one selector part the slide
|
|
65
|
+
* selects. A nested rule reads with its selector resolved, so the excerpt stands on its own.
|
|
66
|
+
*/
|
|
67
|
+
function excerptEntries(sheet: Stylesheet, usage: SlideUsage): ExcerptEntry[] {
|
|
68
|
+
const used = new Set([...usage.classes, "slide", ...RUNTIME_CLASSES]);
|
|
69
|
+
const entries: ExcerptEntry[] = [];
|
|
70
|
+
for (const rule of sheet.rules) {
|
|
71
|
+
// An at-rule's block reads here only where it styles its parent's elements.
|
|
72
|
+
const parent = rule.kind === "at" ? rule.parent : undefined;
|
|
73
|
+
if (rule.kind === "at" && (rule.descriptors || !parent)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const selector = rule.kind === "style" ? rule.selector : (parent?.selector ?? "");
|
|
77
|
+
const atPath = rule.kind === "at" ? [...rule.atPath, rule.prelude] : rule.atPath;
|
|
78
|
+
const keyframes = rule.keyframe ? atPath.find(isKeyframesPrelude) : undefined;
|
|
79
|
+
if (keyframes) {
|
|
80
|
+
entries.push({ atPath, selector, decls: rule.decls, keyframes: keyframesName(keyframes) });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const parts = splitSelectorList(selector)
|
|
84
|
+
.map((part) => part.trim())
|
|
85
|
+
.filter((part) => part && selectorPartApplies(part, used, usage.layout));
|
|
86
|
+
if (parts.length > 0) {
|
|
87
|
+
entries.push({ atPath, selector: parts.join(", "), decls: rule.decls });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return entries;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The names of the keyframes the kept declarations animate with. */
|
|
94
|
+
function animatedKeyframes(styled: CssDeclaration[], entries: ExcerptEntry[]): Set<string> {
|
|
95
|
+
const animations = styled
|
|
96
|
+
.filter((decl) => /^animation(-name)?$/.test(decl.property))
|
|
97
|
+
.map((decl) => decl.value);
|
|
98
|
+
return new Set(
|
|
99
|
+
entries.flatMap((entry) =>
|
|
100
|
+
entry.keyframes && animations.some((value) => mentionsName(value, entry.keyframes ?? ""))
|
|
101
|
+
? [entry.keyframes]
|
|
102
|
+
: [],
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function selectorPartApplies(part: string, used: Set<string>, layout?: string): boolean {
|
|
108
|
+
if (part.startsWith("::view-transition")) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return (
|
|
112
|
+
selectorClasses(part).every((name) => used.has(name)) &&
|
|
113
|
+
selectorLayouts(part).every((name) => name === layout)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function keyframesName(at: string): string {
|
|
118
|
+
return at
|
|
119
|
+
.replace(/^@(-\w+-)?keyframes\b/i, "")
|
|
120
|
+
.trim()
|
|
121
|
+
.replace(/^(["'])(.*)\1$/, "$2");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function mentionsName(value: string, name: string): boolean {
|
|
125
|
+
return new RegExp(`(^|[\\s,])${escapeRegExp(name)}($|[\\s,])`).test(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function varNames(value: string): string[] {
|
|
129
|
+
return [...value.matchAll(/var\(\s*(--[-\w]+)/g)].map((match) => match[1] ?? "");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The tokens `decls` use, following each token's own `var()` to the ones it is built from. */
|
|
133
|
+
function reachedTokens(decls: CssDeclaration[], all: CssDeclaration[]): Set<string> {
|
|
134
|
+
const definitions = new Map<string, string[]>();
|
|
135
|
+
for (const decl of all) {
|
|
136
|
+
if (decl.property.startsWith("--")) {
|
|
137
|
+
definitions.set(decl.property, [...(definitions.get(decl.property) ?? []), decl.value]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const reached = new Set<string>();
|
|
141
|
+
const queue = decls.flatMap((decl) => varNames(decl.value));
|
|
142
|
+
for (let name = queue.pop(); name !== undefined; name = queue.pop()) {
|
|
143
|
+
if (reached.has(name)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
reached.add(name);
|
|
147
|
+
for (const value of definitions.get(name) ?? []) {
|
|
148
|
+
queue.push(...varNames(value));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return reached;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function renderExcerpt(entries: ExcerptEntry[]): string {
|
|
155
|
+
const lines: string[] = [];
|
|
156
|
+
const open: string[] = [];
|
|
157
|
+
const pad = (depth: number) => " ".repeat(depth);
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
let common = 0;
|
|
160
|
+
while (
|
|
161
|
+
common < open.length &&
|
|
162
|
+
common < entry.atPath.length &&
|
|
163
|
+
open[common] === entry.atPath[common]
|
|
164
|
+
) {
|
|
165
|
+
common++;
|
|
166
|
+
}
|
|
167
|
+
while (open.length > common) {
|
|
168
|
+
open.pop();
|
|
169
|
+
lines.push(`${pad(open.length)}}`);
|
|
170
|
+
}
|
|
171
|
+
for (const at of entry.atPath.slice(open.length)) {
|
|
172
|
+
lines.push(`${pad(open.length)}${at} {`);
|
|
173
|
+
open.push(at);
|
|
174
|
+
}
|
|
175
|
+
const indent = pad(open.length);
|
|
176
|
+
lines.push(
|
|
177
|
+
`${indent}${entry.selector} {`,
|
|
178
|
+
...entry.decls.map((decl) => `${indent} ${decl.property}: ${decl.value};`),
|
|
179
|
+
`${indent}}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
while (open.length > 0) {
|
|
183
|
+
open.pop();
|
|
184
|
+
lines.push(`${pad(open.length)}}`);
|
|
185
|
+
}
|
|
186
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
187
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CssToken,
|
|
3
|
+
cssClassNames,
|
|
4
|
+
cssLayoutNames,
|
|
5
|
+
publishedTokens,
|
|
6
|
+
type Stylesheet,
|
|
7
|
+
} from "./css.ts";
|
|
8
|
+
|
|
9
|
+
export type ThemeLayout = { name: string; example?: string };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a theme offers a slide, read once: AGENTS.md, `dekc theme`, and lint all say the same
|
|
13
|
+
* thing about it because they all read it here.
|
|
14
|
+
*/
|
|
15
|
+
export type ThemeFacts = {
|
|
16
|
+
/** Every class the theme's selectors name. */
|
|
17
|
+
classes: Set<string>;
|
|
18
|
+
/** The layouts it styles, by name, each with its example markup when it has one. */
|
|
19
|
+
layouts: ThemeLayout[];
|
|
20
|
+
/** The tokens every slide can `var()`, with their values, in source order. */
|
|
21
|
+
tokens: CssToken[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function themeFacts(sheet: Stylesheet): ThemeFacts {
|
|
25
|
+
return {
|
|
26
|
+
classes: cssClassNames(sheet),
|
|
27
|
+
layouts: themeLayouts(sheet),
|
|
28
|
+
tokens: publishedTokens(sheet),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const LAYOUT_EXAMPLE_RE = /\/\*\s*@layout\s+([A-Za-z0-9_-]+)[ \t]*\r?\n([\s\S]*?)\*\//g;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The layouts a theme defines, sorted by name, each with the markup its
|
|
36
|
+
* `/* @layout <name> ... *\/` comment gives. A theme documents how a layout
|
|
37
|
+
* expects to be filled that way, and the example travels with the theme.
|
|
38
|
+
*/
|
|
39
|
+
function themeLayouts(sheet: Stylesheet): ThemeLayout[] {
|
|
40
|
+
const examples = new Map<string, string>();
|
|
41
|
+
for (const match of sheet.source.matchAll(LAYOUT_EXAMPLE_RE)) {
|
|
42
|
+
const [, name, body] = match;
|
|
43
|
+
if (name && body !== undefined && !examples.has(name)) {
|
|
44
|
+
examples.set(name, body.trim());
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return [...cssLayoutNames(sheet)].sort().map((name) => {
|
|
48
|
+
const example = examples.get(name);
|
|
49
|
+
return example === undefined ? { name } : { name, example };
|
|
50
|
+
});
|
|
51
|
+
}
|