@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
|
+
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
2
|
+
import type { HtmlAttribute, HtmlScan, SourceSpot } from "../html-scan.ts";
|
|
3
|
+
import type { Section } from "../schema.ts";
|
|
4
|
+
import { beatAt, formatStepChoices, resolveStep, stepChoices } from "../step.ts";
|
|
5
|
+
import { suggest } from "../suggest.ts";
|
|
6
|
+
import { isUrlAttribute } from "../url-attributes.ts";
|
|
7
|
+
import { assetRefDiagnostics } from "./asset-refs.ts";
|
|
8
|
+
import { RESERVED_MORPHS } from "./morph.ts";
|
|
9
|
+
import { isRawThemeValue } from "./tokens.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* view-transition-names a data-morph may not take. The player names the slide box "slide",
|
|
13
|
+
* the browser names the page "root", and the rest are keywords of the property itself.
|
|
14
|
+
*/
|
|
15
|
+
/** One slide's markup, scanned once; every finding in it is located by the scan. */
|
|
16
|
+
type SlideHtml = {
|
|
17
|
+
section: Section;
|
|
18
|
+
path: string;
|
|
19
|
+
scan: HtmlScan;
|
|
20
|
+
/** Whether the file is still the skeleton `dekc sync` wrote, which sync keeps in step. */
|
|
21
|
+
skeleton: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function lintSlideHtml(
|
|
25
|
+
section: Section,
|
|
26
|
+
path: string,
|
|
27
|
+
scan: HtmlScan,
|
|
28
|
+
options: {
|
|
29
|
+
deckDir: string;
|
|
30
|
+
skeleton?: boolean;
|
|
31
|
+
classes?: Set<string>;
|
|
32
|
+
layouts?: Set<string>;
|
|
33
|
+
hasScript?: boolean;
|
|
34
|
+
},
|
|
35
|
+
): Diagnostic[] {
|
|
36
|
+
const slide: SlideHtml = {
|
|
37
|
+
section,
|
|
38
|
+
path,
|
|
39
|
+
scan,
|
|
40
|
+
skeleton: options.skeleton === true,
|
|
41
|
+
};
|
|
42
|
+
if (slide.scan.slides.length === 0) {
|
|
43
|
+
return [missingSectionDiagnostic(slide)];
|
|
44
|
+
}
|
|
45
|
+
return [
|
|
46
|
+
...extraSectionDiagnostics(slide),
|
|
47
|
+
...(options.layouts ? layoutDiagnostics(slide, options.layouts) : []),
|
|
48
|
+
...slugDiagnostics(slide),
|
|
49
|
+
...stepDiagnostics(slide),
|
|
50
|
+
...morphDiagnostics(slide),
|
|
51
|
+
...inlineCodeDiagnostics(slide),
|
|
52
|
+
...presentationDiagnostics(slide),
|
|
53
|
+
...emptyHeadingDiagnostics(slide),
|
|
54
|
+
...hiddenTextDiagnostics(slide),
|
|
55
|
+
...(options.classes
|
|
56
|
+
? unknownClassDiagnostics(slide, options.classes, options.hasScript === true)
|
|
57
|
+
: []),
|
|
58
|
+
...slide.scan.refs.flatMap((ref) =>
|
|
59
|
+
assetRefDiagnostics(ref, {
|
|
60
|
+
path,
|
|
61
|
+
...spotOf(ref),
|
|
62
|
+
slug: section.slug,
|
|
63
|
+
deckDir: options.deckDir,
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every `name` attribute in the markup, in document order. */
|
|
70
|
+
function attributesNamed(scan: HtmlScan, name: string): HtmlAttribute[] {
|
|
71
|
+
return scan.elements.flatMap((element) =>
|
|
72
|
+
element.attributes.filter((attribute) => attribute.name === name),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The location fields of a diagnostic, from wherever the scan found the thing. */
|
|
77
|
+
function spotOf({ line, column }: SourceSpot): { line: number; column: number } {
|
|
78
|
+
return { line, column };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The attributes that style an element as a property would, and the property each stands for:
|
|
83
|
+
* SVG's presentation attributes and the HTML ones that outlived CSS. Only colors and families are
|
|
84
|
+
* read; SVG's unitless lengths are its own coordinates.
|
|
85
|
+
*/
|
|
86
|
+
const PRESENTATION_ATTRIBUTES: Record<string, string> = {
|
|
87
|
+
fill: "fill",
|
|
88
|
+
stroke: "stroke",
|
|
89
|
+
color: "color",
|
|
90
|
+
"stop-color": "stop-color",
|
|
91
|
+
"flood-color": "flood-color",
|
|
92
|
+
"lighting-color": "lighting-color",
|
|
93
|
+
"font-family": "font-family",
|
|
94
|
+
face: "font-family",
|
|
95
|
+
bgcolor: "background-color",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* DEK014: a raw color or font family in a presentation attribute, which styles the element where
|
|
100
|
+
* no var() can reach. `fill="none"` and `currentColor` take nothing from the theme and pass.
|
|
101
|
+
*/
|
|
102
|
+
function presentationDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[] {
|
|
103
|
+
const css = `slides/${section.slug}.css`;
|
|
104
|
+
return scan.elements.flatMap((element) =>
|
|
105
|
+
element.attributes.flatMap((attribute) => {
|
|
106
|
+
const property = PRESENTATION_ATTRIBUTES[attribute.name];
|
|
107
|
+
if (property === undefined || !isRawThemeValue(property, attribute.value)) {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
const token = property === "font-family" ? "var(--font-body)" : "var(--accent)";
|
|
111
|
+
return [
|
|
112
|
+
diag("DEK014", {
|
|
113
|
+
message: `raw value in ${attribute.name}="${attribute.value}"; use a theme token`,
|
|
114
|
+
path,
|
|
115
|
+
...spotOf(attribute),
|
|
116
|
+
slug: section.slug,
|
|
117
|
+
hint: `set ${property} in ${css} with a theme token instead, such as ${property}: ${token}; an attribute cannot take var()`,
|
|
118
|
+
data: { attribute: attribute.name, value: attribute.value },
|
|
119
|
+
}),
|
|
120
|
+
];
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** DEK007: the file is there, but nothing in it is a slide, so build and show draw nothing for it. */
|
|
126
|
+
function missingSectionDiagnostic({ section, path }: SlideHtml): Diagnostic {
|
|
127
|
+
return diag("DEK007", {
|
|
128
|
+
message: `slides/${section.slug}.html has no <section class="slide">`,
|
|
129
|
+
path,
|
|
130
|
+
slug: section.slug,
|
|
131
|
+
hint: 'wrap the slide markup in <section class="slide">…</section>',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** DEK009: more than one slide in a file; the player shows the first and drops the rest. */
|
|
136
|
+
function extraSectionDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[] {
|
|
137
|
+
const [, second] = scan.slides;
|
|
138
|
+
if (!second) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
return [
|
|
142
|
+
diag("DEK009", {
|
|
143
|
+
message: `slides/${section.slug}.html has ${scan.slides.length} <section class="slide">; only the first is shown`,
|
|
144
|
+
path,
|
|
145
|
+
...spotOf(second),
|
|
146
|
+
slug: section.slug,
|
|
147
|
+
hint: "one file is one slide: add a ## section to script.md and move the rest into its file",
|
|
148
|
+
data: { sections: scan.slides.length },
|
|
149
|
+
}),
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** DEK019: a data-layout that neither the theme nor the slide's stylesheet lays out. */
|
|
154
|
+
function layoutDiagnostics({ section, path, scan }: SlideHtml, layouts: Set<string>): Diagnostic[] {
|
|
155
|
+
const attribute = scan.slides[0]?.attributes.find((entry) => entry.name === "data-layout");
|
|
156
|
+
if (scan.layout === undefined || !attribute || layouts.has(scan.layout)) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
const known = [...layouts].sort();
|
|
160
|
+
const guess = suggest(scan.layout, known);
|
|
161
|
+
return [
|
|
162
|
+
diag("DEK019", {
|
|
163
|
+
message: `data-layout "${scan.layout}" is not a layout of the theme`,
|
|
164
|
+
path,
|
|
165
|
+
...spotOf(attribute),
|
|
166
|
+
slug: section.slug,
|
|
167
|
+
hint: `${guess ? `did you mean ${guess}? ` : ""}run \`dekc theme\` to see the layouts`,
|
|
168
|
+
data: { layout: scan.layout, layouts: known },
|
|
169
|
+
}),
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** DEK006: the slide's data-slug names another section. */
|
|
174
|
+
function slugDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[] {
|
|
175
|
+
const found = scan.slides
|
|
176
|
+
.flatMap((element) => element.attributes)
|
|
177
|
+
.find((attribute) => attribute.name === "data-slug");
|
|
178
|
+
if (!found || found.value === section.slug) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
return [
|
|
182
|
+
diag("DEK006", {
|
|
183
|
+
message: `data-slug "${found.value}" does not match section "${section.slug}"`,
|
|
184
|
+
path,
|
|
185
|
+
...spotOf(found),
|
|
186
|
+
slug: section.slug,
|
|
187
|
+
data: { slug: found.value, expected: section.slug },
|
|
188
|
+
}),
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* DEK003: a data-step that is neither a beat id nor a beat index. DEK025: a beat index where
|
|
194
|
+
* that beat has an id, which silently binds the next beat over once one is inserted before it.
|
|
195
|
+
*/
|
|
196
|
+
function stepDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[] {
|
|
197
|
+
return attributesNamed(scan, "data-step").flatMap((attribute) => {
|
|
198
|
+
const step = attribute.value;
|
|
199
|
+
const beatIndex = resolveStep(section.beats, step);
|
|
200
|
+
if (beatIndex === undefined) {
|
|
201
|
+
return [
|
|
202
|
+
diag("DEK003", {
|
|
203
|
+
message: `data-step "${step}" is not a beat id or index in "${section.slug}"`,
|
|
204
|
+
path,
|
|
205
|
+
...spotOf(attribute),
|
|
206
|
+
slug: section.slug,
|
|
207
|
+
hint: stepHint(section),
|
|
208
|
+
data: { step, choices: stepChoices(section.beats) },
|
|
209
|
+
}),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
const id = beatAt(section.beats, beatIndex)?.id;
|
|
213
|
+
if (id === undefined || id === step) {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
return [
|
|
217
|
+
diag("DEK025", {
|
|
218
|
+
message: `data-step "${step}" is beat "${id}" by position; it moves if a beat is inserted before it`,
|
|
219
|
+
path,
|
|
220
|
+
...spotOf(attribute),
|
|
221
|
+
slug: section.slug,
|
|
222
|
+
hint: `use data-step="${id}"`,
|
|
223
|
+
data: { step, id },
|
|
224
|
+
}),
|
|
225
|
+
];
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** DEK005: a data-morph used twice, or one the player or CSS already means something by. */
|
|
230
|
+
function morphDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[] {
|
|
231
|
+
const diagnostics: Diagnostic[] = [];
|
|
232
|
+
const first = new Map<string, HtmlAttribute>();
|
|
233
|
+
const reported = new Set<string>();
|
|
234
|
+
for (const attribute of attributesNamed(scan, "data-morph")) {
|
|
235
|
+
const morph = attribute.value;
|
|
236
|
+
if (!first.has(morph)) {
|
|
237
|
+
first.set(morph, attribute);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (reported.has(morph)) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
reported.add(morph);
|
|
244
|
+
diagnostics.push(
|
|
245
|
+
diag("DEK005", {
|
|
246
|
+
message: `duplicate data-morph "${morph}"`,
|
|
247
|
+
path,
|
|
248
|
+
...spotOf(attribute),
|
|
249
|
+
slug: section.slug,
|
|
250
|
+
data: { morph },
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
for (const [morph, attribute] of first) {
|
|
255
|
+
if (!RESERVED_MORPHS.has(morph)) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
diagnostics.push(
|
|
259
|
+
diag("DEK005", {
|
|
260
|
+
message: `data-morph "${morph}" is reserved`,
|
|
261
|
+
path,
|
|
262
|
+
...spotOf(attribute),
|
|
263
|
+
slug: section.slug,
|
|
264
|
+
hint: `rename it; the player uses "slide" and "root" for the page itself, and "none", "auto", and "match-element" are CSS keywords`,
|
|
265
|
+
data: { morph },
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return diagnostics;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* DEK011: style or script inside the markup, which belongs in the slide's own .css or .ts. Every
|
|
274
|
+
* occurrence is its own finding: a `<style>` or `<script>` element, a `style` attribute, an event
|
|
275
|
+
* handler attribute, and a `javascript:` URL.
|
|
276
|
+
*/
|
|
277
|
+
function inlineCodeDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[] {
|
|
278
|
+
return scan.elements
|
|
279
|
+
.flatMap((element) => [
|
|
280
|
+
{
|
|
281
|
+
spot: element as SourceSpot,
|
|
282
|
+
found: inlineElement(element, section.slug),
|
|
283
|
+
data: { kind: "element", name: element.tag },
|
|
284
|
+
},
|
|
285
|
+
...element.attributes.map((attribute) => ({
|
|
286
|
+
spot: attribute as SourceSpot,
|
|
287
|
+
found: inlineAttribute(element.tag, attribute, section.slug),
|
|
288
|
+
data: { kind: "attribute", name: attribute.name, value: attribute.value },
|
|
289
|
+
})),
|
|
290
|
+
])
|
|
291
|
+
.flatMap(({ spot, found, data }) =>
|
|
292
|
+
found
|
|
293
|
+
? [
|
|
294
|
+
diag("DEK011", {
|
|
295
|
+
message: found.message,
|
|
296
|
+
path,
|
|
297
|
+
...spotOf(spot),
|
|
298
|
+
slug: section.slug,
|
|
299
|
+
hint: found.hint,
|
|
300
|
+
data,
|
|
301
|
+
}),
|
|
302
|
+
]
|
|
303
|
+
: [],
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
type InlineCode = { message: string; hint: string };
|
|
308
|
+
|
|
309
|
+
function inlineElement(
|
|
310
|
+
{ tag, inSlide }: { tag: string; inSlide: boolean },
|
|
311
|
+
slug: string,
|
|
312
|
+
): InlineCode | undefined {
|
|
313
|
+
// In a full document's head it is dropped with the head; in the slide it loads for every slide.
|
|
314
|
+
if (tag === "link" && inSlide) {
|
|
315
|
+
return {
|
|
316
|
+
message: "slide contains a <link>, which loads its stylesheet for every slide",
|
|
317
|
+
hint: `move what it loads into theme.css, or into slides/${slug}.css for this slide alone; a font goes in assets/ with @font-face`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (tag === "style") {
|
|
321
|
+
return {
|
|
322
|
+
message: "slide contains a <style> element",
|
|
323
|
+
hint: `move the rules to slides/${slug}.css`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (tag === "script") {
|
|
327
|
+
return { message: "slide contains a <script> element", hint: motionHint(slug) };
|
|
328
|
+
}
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function inlineAttribute(
|
|
333
|
+
tag: string,
|
|
334
|
+
attribute: HtmlAttribute,
|
|
335
|
+
slug: string,
|
|
336
|
+
): InlineCode | undefined {
|
|
337
|
+
if (attribute.name === "style") {
|
|
338
|
+
return {
|
|
339
|
+
message: "slide contains a style attribute",
|
|
340
|
+
hint: `move it to a class in slides/${slug}.css, using token var()`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (EVENT_HANDLER_RE.test(attribute.name)) {
|
|
344
|
+
return {
|
|
345
|
+
message: `slide contains an ${attribute.name} attribute`,
|
|
346
|
+
hint: `remove it; a slide takes no input, and motion goes in slides/${slug}.ts as a draw(t) function`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (isUrlAttribute(tag, attribute.name) && isJavascriptUrl(attribute.value)) {
|
|
350
|
+
return { message: "slide contains a javascript: URL", hint: motionHint(slug) };
|
|
351
|
+
}
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function motionHint(slug: string): string {
|
|
356
|
+
return `move motion to slides/${slug}.ts as a draw(t) function`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** `onclick`, `onload`, and every other attribute the browser runs as script. */
|
|
360
|
+
const EVENT_HANDLER_RE = /^on[a-z]+$/;
|
|
361
|
+
/** Read the way the URL parser reads it: tabs and newlines dropped, leading space trimmed, any case. */
|
|
362
|
+
function isJavascriptUrl(value: string): boolean {
|
|
363
|
+
return value
|
|
364
|
+
.replace(/[\t\n\r]/g, "")
|
|
365
|
+
.trimStart()
|
|
366
|
+
.toLowerCase()
|
|
367
|
+
.startsWith("javascript:");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* DEK024: a heading with nothing in it, which the audience sees as a gap where a title should
|
|
372
|
+
* be. An id-only `##` heading after the first slide makes one: sync never puts the id on a slide.
|
|
373
|
+
* Such a heading may also mean the slide has no title, as a quote's does, so its skeleton's hint
|
|
374
|
+
* offers both: a title for sync to write, or the element gone, which sync then leaves alone.
|
|
375
|
+
*/
|
|
376
|
+
function emptyHeadingDiagnostics({ section, path, scan, skeleton }: SlideHtml): Diagnostic[] {
|
|
377
|
+
const hint = skeleton
|
|
378
|
+
? `give the slide a title in script.md, like \`## Your title {#${section.slug}}\`, then run \`dekc sync\`; for a slide with no title, such as a quote, remove the element from slides/${section.slug}.html`
|
|
379
|
+
: `write the heading's text in slides/${section.slug}.html, or remove the element`;
|
|
380
|
+
return scan.emptyHeadings.map((element) =>
|
|
381
|
+
diag("DEK024", {
|
|
382
|
+
message: `<${element.tag}> is empty, so the slide shows no heading`,
|
|
383
|
+
path,
|
|
384
|
+
...spotOf(element),
|
|
385
|
+
slug: section.slug,
|
|
386
|
+
hint,
|
|
387
|
+
data: { tag: element.tag },
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** How much of a hidden text a message quotes. */
|
|
393
|
+
const HIDDEN_SNIPPET_CHARS = 24;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* DEK029: text under `aria-hidden="true"`. That is how a slide marks decoration, and lint
|
|
397
|
+
* measures neither the contrast nor the overflow of anything under it, so a labeled figure
|
|
398
|
+
* marked that way is text the audience reads and no check sees. Right only on a sample the talk
|
|
399
|
+
* shows as unreadable, so it warns.
|
|
400
|
+
*/
|
|
401
|
+
function hiddenTextDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[] {
|
|
402
|
+
return scan.hiddenTexts.map(({ element, text }) => {
|
|
403
|
+
const chars = [...text];
|
|
404
|
+
const quoted =
|
|
405
|
+
chars.length > HIDDEN_SNIPPET_CHARS
|
|
406
|
+
? `${chars.slice(0, HIDDEN_SNIPPET_CHARS).join("")}…`
|
|
407
|
+
: text;
|
|
408
|
+
const svg =
|
|
409
|
+
element.tag === "svg" ? '; an SVG can take role="img" and an aria-label instead' : "";
|
|
410
|
+
return diag("DEK029", {
|
|
411
|
+
message: `<${element.tag} aria-hidden="true"> holds text the audience reads, "${quoted}", which lint does not measure`,
|
|
412
|
+
path,
|
|
413
|
+
...spotOf(element),
|
|
414
|
+
slug: section.slug,
|
|
415
|
+
hint: `if the audience should read it, remove aria-hidden="true" from it so lint measures its contrast and overflow${svg}. Keep aria-hidden only on decoration, or on a sample the talk shows as unreadable`,
|
|
416
|
+
data: { tag: element.tag, text },
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** DEK010: a class neither theme.css nor the slide's own stylesheet defines. */
|
|
422
|
+
function unknownClassDiagnostics(
|
|
423
|
+
{ section, path, scan }: SlideHtml,
|
|
424
|
+
classes: Set<string>,
|
|
425
|
+
hasScript: boolean,
|
|
426
|
+
): Diagnostic[] {
|
|
427
|
+
// `slide` is the root every slide already has, never a word to give content.
|
|
428
|
+
const known = [...classes].filter((name) => name !== "slide").sort();
|
|
429
|
+
const shown =
|
|
430
|
+
known.length > MAX_HINT_CLASSES ? [...known.slice(0, MAX_HINT_CLASSES), "…"] : known;
|
|
431
|
+
const scriptHint = hasScript
|
|
432
|
+
? `; to find an element from slides/${section.slug}.ts, use a data-* attribute instead`
|
|
433
|
+
: "";
|
|
434
|
+
const hint =
|
|
435
|
+
known.length === 0
|
|
436
|
+
? `define it in slides/${section.slug}.css, or in theme.css for every slide; the theme defines no class yet${scriptHint}`
|
|
437
|
+
: `define it in slides/${section.slug}.css, or use one of: ${shown.join(", ")}${scriptHint}`;
|
|
438
|
+
const unknown = new Map<string, HtmlAttribute>();
|
|
439
|
+
for (const attribute of attributesNamed(scan, "class")) {
|
|
440
|
+
for (const name of attribute.value.split(/\s+/).filter(Boolean)) {
|
|
441
|
+
if (!classes.has(name) && !unknown.has(name)) {
|
|
442
|
+
unknown.set(name, attribute);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return [...unknown].map(([name, attribute]) => {
|
|
447
|
+
const suggestion = suggest(name, known);
|
|
448
|
+
return diag("DEK010", {
|
|
449
|
+
message: `class "${name}" is not defined in theme.css`,
|
|
450
|
+
path,
|
|
451
|
+
...spotOf(attribute),
|
|
452
|
+
slug: section.slug,
|
|
453
|
+
hint: suggestion ? `did you mean ${suggestion}? ${hint}` : hint,
|
|
454
|
+
data: { class: name, ...(suggestion ? { suggestion } : {}) },
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const MAX_HINT_CLASSES = 20;
|
|
460
|
+
|
|
461
|
+
function stepHint(section: Section): string {
|
|
462
|
+
if (section.beats.length === 0) {
|
|
463
|
+
return `add a ### beat under "## ${section.title}" in script.md, or drop data-step`;
|
|
464
|
+
}
|
|
465
|
+
return formatStepChoices(stepChoices(section.beats));
|
|
466
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
3
|
+
import { listSlideFiles } from "../resolve.ts";
|
|
4
|
+
import type { Section } from "../schema.ts";
|
|
5
|
+
import type { SlideScriptProblem } from "../slide-script.ts";
|
|
6
|
+
import type { ScriptInput } from "../slide-script-eval.ts";
|
|
7
|
+
import { stepKeys } from "../step.ts";
|
|
8
|
+
import type { DeckFiles, LintContext } from "./context.ts";
|
|
9
|
+
|
|
10
|
+
export type LintedScript = { section: Section; path: string; input: ScriptInput };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `slides/<slug>.ts` scripts lint evaluates, each read once: one per section that has HTML.
|
|
14
|
+
* Evaluating is the slow part of lint, so `lintDeck` does it before the rules run.
|
|
15
|
+
*/
|
|
16
|
+
export function lintedSlideScripts(files: DeckFiles): LintedScript[] {
|
|
17
|
+
const scripts = new Map(listSlideFiles(files.deck.dir, ".ts").map((file) => [file.slug, file]));
|
|
18
|
+
return [...files.sectionsBySlug.values()].flatMap((section) => {
|
|
19
|
+
const path = scripts.get(section.slug)?.path;
|
|
20
|
+
if (!path || !files.slidesBySlug.has(section.slug)) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const steps = stepKeys(section.beats);
|
|
24
|
+
return [{ section, path, input: { code: readFileSync(path, "utf8"), steps } }];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** DEK016 from what evaluating each script found, and DEK017 from its source, by slug. */
|
|
29
|
+
export function slideScriptDiagnostics(
|
|
30
|
+
scripted: LintedScript[],
|
|
31
|
+
problems: SlideScriptProblem[][],
|
|
32
|
+
): Map<string, Diagnostic[]> {
|
|
33
|
+
return new Map(
|
|
34
|
+
scripted.map(({ section, path, input }, index) => [
|
|
35
|
+
section.slug,
|
|
36
|
+
[
|
|
37
|
+
...(problems[index] ?? []).map(({ message, line, hint }) =>
|
|
38
|
+
diag("DEK016", {
|
|
39
|
+
message,
|
|
40
|
+
path,
|
|
41
|
+
...(line !== undefined ? { line } : {}),
|
|
42
|
+
slug: section.slug,
|
|
43
|
+
...(hint !== undefined ? { hint } : {}),
|
|
44
|
+
data: { file: `slides/${section.slug}.ts` },
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
...seekProblems(input.code).map((problem) =>
|
|
48
|
+
diag("DEK017", { path, slug: section.slug, ...problem }),
|
|
49
|
+
),
|
|
50
|
+
],
|
|
51
|
+
]),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** DEK016 for a slides/<slug>.js, which dek does not load: motion is written in TypeScript. */
|
|
56
|
+
export function javascriptDiagnostics(ctx: LintContext): Diagnostic[] {
|
|
57
|
+
return listSlideFiles(ctx.deck.dir, ".js").map((file) =>
|
|
58
|
+
diag("DEK016", {
|
|
59
|
+
message: `slide scripts are TypeScript; rename ${file.slug}.js to ${file.slug}.ts`,
|
|
60
|
+
path: file.path,
|
|
61
|
+
slug: file.slug,
|
|
62
|
+
hint: `run \`mv slides/${file.slug}.js slides/${file.slug}.ts\`; plain JavaScript is valid TypeScript`,
|
|
63
|
+
data: { file: `slides/${file.slug}.js` },
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type SeekProblem = {
|
|
69
|
+
line: number;
|
|
70
|
+
message: string;
|
|
71
|
+
/** The call that reads a clock, or the class used to find an element. */
|
|
72
|
+
data: { call: string } | { class: string };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const CLOCK_MESSAGE =
|
|
76
|
+
"runs on its own clock; draw from t alone so video and screenshots can seek it";
|
|
77
|
+
|
|
78
|
+
type SeekFinding = { message: string; data: SeekProblem["data"] } | undefined;
|
|
79
|
+
|
|
80
|
+
const clock = (call: string): SeekFinding => ({
|
|
81
|
+
message: `${call} ${CLOCK_MESSAGE}`,
|
|
82
|
+
data: { call },
|
|
83
|
+
});
|
|
84
|
+
const byClass = (name: string | undefined): SeekFinding =>
|
|
85
|
+
name
|
|
86
|
+
? {
|
|
87
|
+
message: `finds elements by class ".${name}"; give the element a data-* attribute and select that`,
|
|
88
|
+
data: { class: name },
|
|
89
|
+
}
|
|
90
|
+
: undefined;
|
|
91
|
+
|
|
92
|
+
const SEEK_PATTERNS: Array<{ re: RegExp; find: (match: RegExpMatchArray) => SeekFinding }> = [
|
|
93
|
+
{
|
|
94
|
+
re: /\b(setTimeout|setInterval|requestAnimationFrame|requestIdleCallback|Date\.now|performance\.now)\s*\(/g,
|
|
95
|
+
find: (m) => clock(m[1] ?? ""),
|
|
96
|
+
},
|
|
97
|
+
{ re: /\bnew\s+Date\b/g, find: () => clock("new Date") },
|
|
98
|
+
{
|
|
99
|
+
re: /\bMath\.random\s*\(/g,
|
|
100
|
+
find: () => ({
|
|
101
|
+
message: "Math.random differs on every call; derive the value from t or the slide's beats",
|
|
102
|
+
data: { call: "Math.random" },
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
re: /\b(?:querySelectorAll|querySelector|closest|matches)\s*\(\s*(["'`])((?:(?!\1)[^\\\n])*)\1/g,
|
|
107
|
+
find: (m) =>
|
|
108
|
+
byClass((m[2] ?? "").replace(/\[[^\]]*\]/g, "").match(/\.(-?[_a-zA-Z][\w-]*)/)?.[1]),
|
|
109
|
+
},
|
|
110
|
+
{ re: /\bgetElementsByClassName\s*\(\s*(["'`])\s*([\w-]+)/g, find: (m) => byClass(m[2]) },
|
|
111
|
+
{
|
|
112
|
+
// One slide is on a still page, and every slide is in the built deck.
|
|
113
|
+
re: /\bdocument\s*\.\s*(querySelectorAll|querySelector|getElementById|getElementsBy\w+|body|documentElement)\b/g,
|
|
114
|
+
find: (m) => ({
|
|
115
|
+
message: `document.${m[1]} reaches every slide in the deck; find elements from the slide draw is given, as slide.querySelector`,
|
|
116
|
+
data: { call: `document.${m[1]}` },
|
|
117
|
+
}),
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* DEK017: what makes a slide script draw something other than a function of
|
|
123
|
+
* `t`, so a seek to the same `t` in video, screenshots, or the PDF would not
|
|
124
|
+
* give the same frame. Also classes used to find elements, which a theme may
|
|
125
|
+
* rename. Comments are ignored; lines are 1-based.
|
|
126
|
+
*/
|
|
127
|
+
export function seekProblems(code: string): SeekProblem[] {
|
|
128
|
+
const source = blankComments(code);
|
|
129
|
+
const found: Array<SeekProblem & { index: number }> = [];
|
|
130
|
+
for (const { re, find } of SEEK_PATTERNS) {
|
|
131
|
+
for (const match of source.matchAll(re)) {
|
|
132
|
+
const finding = find(match);
|
|
133
|
+
if (finding) {
|
|
134
|
+
const index = match.index ?? 0;
|
|
135
|
+
found.push({ index, line: source.slice(0, index).split("\n").length, ...finding });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return found.sort((a, b) => a.index - b.index).map(({ index: _index, ...problem }) => problem);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Comments replaced by spaces, newlines and string literals kept. */
|
|
143
|
+
function blankComments(code: string): string {
|
|
144
|
+
let out = "";
|
|
145
|
+
let quote: string | undefined;
|
|
146
|
+
for (let i = 0; i < code.length; i++) {
|
|
147
|
+
const ch = code[i] ?? "";
|
|
148
|
+
if (quote !== undefined) {
|
|
149
|
+
out += ch;
|
|
150
|
+
if (ch === "\\") {
|
|
151
|
+
out += code[i + 1] ?? "";
|
|
152
|
+
i++;
|
|
153
|
+
} else if (ch === quote) {
|
|
154
|
+
quote = undefined;
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (ch === "/" && code[i + 1] === "/") {
|
|
159
|
+
while (i < code.length && code[i] !== "\n") {
|
|
160
|
+
out += " ";
|
|
161
|
+
i++;
|
|
162
|
+
}
|
|
163
|
+
out += code[i] ?? "";
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (ch === "/" && code[i + 1] === "*") {
|
|
167
|
+
const end = code.indexOf("*/", i + 2);
|
|
168
|
+
const stop = end < 0 ? code.length : end + 2;
|
|
169
|
+
out += code.slice(i, stop).replace(/[^\n]/g, " ");
|
|
170
|
+
i = stop - 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
174
|
+
quote = ch;
|
|
175
|
+
}
|
|
176
|
+
out += ch;
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|