@hajimism/dek 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +8 -7
- package/src/cli/address.ts +59 -15
- package/src/cli/commands.ts +47 -14
- package/src/cli/contract.ts +49 -3
- package/src/cli/files.ts +1 -0
- package/src/cli/format.ts +5 -4
- package/src/cli/goto.ts +41 -10
- package/src/cli/keep-alive.ts +12 -6
- package/src/cli/lint.ts +32 -13
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +48 -13
- package/src/cli/new.ts +14 -1
- package/src/cli/ref.ts +1 -0
- package/src/cli/result.ts +42 -4
- package/src/cli/scope.ts +40 -1
- package/src/cli/shot.ts +77 -6
- package/src/cli/show.ts +15 -2
- package/src/cli/text.ts +61 -9
- package/src/cli/theme.ts +50 -16
- package/src/cli/tty.ts +16 -2
- package/src/cli/voice.ts +11 -6
- package/src/core/assets.ts +18 -10
- package/src/core/attribute-value.ts +188 -0
- package/src/core/build.ts +3 -3
- package/src/core/css-scan.ts +173 -39
- package/src/core/css-transform.ts +57 -11
- package/src/core/css.ts +19 -13
- package/src/core/foreign-text.ts +138 -0
- package/src/core/github.ts +1 -0
- package/src/core/html-entities.ts +318 -0
- package/src/core/html-scan.ts +49 -27
- package/src/core/html.ts +21 -15
- package/src/core/lint/asset-refs.ts +44 -6
- package/src/core/lint/context.ts +2 -0
- package/src/core/lint/shared-styles.ts +26 -1
- package/src/core/lint/slide-html.ts +21 -18
- package/src/core/lint/theme-css.ts +64 -3
- package/src/core/lint.ts +15 -2
- package/src/core/mv.ts +28 -18
- package/src/core/order.ts +14 -0
- package/src/core/path.ts +8 -2
- package/src/core/playwright-module.d.ts +12 -1
- package/src/core/playwright-motion.ts +1 -0
- package/src/core/playwright-sheet.ts +1 -0
- package/src/core/playwright-visual.ts +27 -10
- package/src/core/playwright.ts +77 -19
- package/src/core/pptx.ts +1 -0
- package/src/core/ref-name.ts +8 -3
- package/src/core/ref.ts +145 -47
- package/src/core/resolve.ts +3 -2
- package/src/core/rumdl.ts +51 -25
- package/src/core/safe-fs.ts +19 -2
- package/src/core/sandbox.ts +217 -0
- package/src/core/shot-cache.ts +1 -0
- package/src/core/slide-eval-worker.ts +1 -0
- package/src/core/slide-measure.ts +1 -0
- package/src/core/slide-script-eval.ts +2 -0
- package/src/core/spawn.ts +136 -35
- package/src/core/sync.ts +3 -12
- package/src/core/tar.ts +315 -0
- package/src/core/text-contrast.ts +2 -0
- package/src/core/toml-keys.ts +116 -51
- package/src/core/url-attributes.ts +27 -9
- package/src/core/visual.ts +28 -9
- package/src/core/voice.ts +2 -1
- package/src/server/auth.ts +21 -0
- package/src/server/dev.ts +13 -5
- package/src/server/lock.ts +43 -13
- package/src/server/serial.ts +8 -2
- package/src/video/mux.ts +27 -3
- package/src/video/recorder.ts +3 -2
- package/src/video/worker.ts +1 -0
- package/src/voice/engine.ts +81 -18
- package/tsconfig.json +1 -1
package/src/core/html-scan.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
attributeOf,
|
|
3
|
+
attributeTokens,
|
|
4
|
+
decodeAttribute,
|
|
5
|
+
trimAsciiWhitespace,
|
|
6
|
+
} from "./attribute-value.ts";
|
|
1
7
|
import { hasSlideClass, uniqueMark } from "./html.ts";
|
|
2
8
|
import { lineLocator } from "./lines.ts";
|
|
3
9
|
import { attributeUrls, type UrlUse } from "./url-attributes.ts";
|
|
@@ -5,7 +11,7 @@ import { attributeUrls, type UrlUse } from "./url-attributes.ts";
|
|
|
5
11
|
/** Where something is written: 1-based line, and 1-based column in UTF-16 units, as editors count. */
|
|
6
12
|
export type SourceSpot = { line: number; column: number };
|
|
7
13
|
|
|
8
|
-
/** One attribute as the
|
|
14
|
+
/** One attribute, its value as the browser reads it, located at its name. */
|
|
9
15
|
export type HtmlAttribute = SourceSpot & { name: string; value: string };
|
|
10
16
|
|
|
11
17
|
/**
|
|
@@ -59,7 +65,8 @@ const UNDRAWN_TAGS = new Set(["title", "desc"]);
|
|
|
59
65
|
/**
|
|
60
66
|
* What a slide's markup uses and references, read in one pass of the real parser and located in
|
|
61
67
|
* the source. The parser has no positions, so each start tag gets a marker in front of it; where
|
|
62
|
-
* the markers land, less their own length, is where the tags were written.
|
|
68
|
+
* the markers land, less their own length, is where the tags were written. Each value is read as
|
|
69
|
+
* the browser reads it, its character references decoded.
|
|
63
70
|
*/
|
|
64
71
|
export function scanSlideHtml(html: string): HtmlScan {
|
|
65
72
|
const mark = uniqueMark(html);
|
|
@@ -81,9 +88,10 @@ export function scanSlideHtml(html: string): HtmlScan {
|
|
|
81
88
|
element(el) {
|
|
82
89
|
el.before(mark, { html: true });
|
|
83
90
|
const tag = el.tagName.toLowerCase();
|
|
84
|
-
|
|
91
|
+
// As written: each is decoded once the tag is located, which maps a URL back into it.
|
|
92
|
+
const attributes = [...el.attributes].map(([name, written]): [string, string] => [
|
|
85
93
|
name.toLowerCase(),
|
|
86
|
-
|
|
94
|
+
written,
|
|
87
95
|
]);
|
|
88
96
|
if (CONTENT_TAGS.has(tag)) {
|
|
89
97
|
for (const heading of open) {
|
|
@@ -99,7 +107,7 @@ export function scanSlideHtml(html: string): HtmlScan {
|
|
|
99
107
|
undrawn--;
|
|
100
108
|
});
|
|
101
109
|
}
|
|
102
|
-
} else if (el
|
|
110
|
+
} else if (isAriaHidden(attributeOf(el, "aria-hidden")) && el.canHaveContent) {
|
|
103
111
|
const entry = { index: parsed.length, text: "" };
|
|
104
112
|
hidden.push(entry);
|
|
105
113
|
hiding = entry;
|
|
@@ -116,8 +124,8 @@ export function scanSlideHtml(html: string): HtmlScan {
|
|
|
116
124
|
}
|
|
117
125
|
} else if (
|
|
118
126
|
!hiding &&
|
|
119
|
-
el
|
|
120
|
-
isPicture(tag, el
|
|
127
|
+
!isAriaHidden(attributeOf(el, "aria-hidden")) &&
|
|
128
|
+
isPicture(tag, attributeOf(el, "role"))
|
|
121
129
|
) {
|
|
122
130
|
const entry = { index: parsed.length, text: false, titled: false };
|
|
123
131
|
pictures.push(entry);
|
|
@@ -131,7 +139,7 @@ export function scanSlideHtml(html: string): HtmlScan {
|
|
|
131
139
|
if (HEADING_TAGS.has(tag) && el.canHaveContent) {
|
|
132
140
|
const heading = {
|
|
133
141
|
index: parsed.length,
|
|
134
|
-
content: (el
|
|
142
|
+
content: isName(attributeOf(el, "aria-label")),
|
|
135
143
|
};
|
|
136
144
|
headings.push(heading);
|
|
137
145
|
open.push(heading);
|
|
@@ -139,7 +147,7 @@ export function scanSlideHtml(html: string): HtmlScan {
|
|
|
139
147
|
open.splice(open.indexOf(heading), 1);
|
|
140
148
|
});
|
|
141
149
|
}
|
|
142
|
-
if (tag === "section" && hasSlideClass(el
|
|
150
|
+
if (tag === "section" && hasSlideClass(attributeOf(el, "class")) && el.canHaveContent) {
|
|
143
151
|
slidesOpen++;
|
|
144
152
|
el.onEndTag(() => {
|
|
145
153
|
slidesOpen--;
|
|
@@ -181,23 +189,24 @@ export function scanSlideHtml(html: string): HtmlScan {
|
|
|
181
189
|
inSlide: false,
|
|
182
190
|
};
|
|
183
191
|
const offsets = attributeOffsets(html, start);
|
|
192
|
+
const decoded = attributes.map(([name, written]) => ({ name, ...decodeAttribute(written) }));
|
|
184
193
|
const element: HtmlElement = {
|
|
185
194
|
tag,
|
|
186
195
|
inSlide,
|
|
187
196
|
...spot(start),
|
|
188
|
-
attributes:
|
|
197
|
+
attributes: decoded.map(({ name, value }) => ({
|
|
189
198
|
name,
|
|
190
199
|
value,
|
|
191
200
|
...spot(offsets.get(name)?.name ?? start),
|
|
192
201
|
})),
|
|
193
202
|
};
|
|
194
203
|
elements.push(element);
|
|
195
|
-
for (const
|
|
196
|
-
const valueAt = offsets.get(
|
|
204
|
+
for (const { name, value, writtenAt } of decoded) {
|
|
205
|
+
const valueAt = offsets.get(name)?.value;
|
|
197
206
|
refs.push(
|
|
198
|
-
...attributeRefs(element.tag,
|
|
207
|
+
...attributeRefs(element.tag, name, value).map(({ ref, offset }) => ({
|
|
199
208
|
...ref,
|
|
200
|
-
...(valueAt === undefined ? spot(start) : spot(valueAt + offset)),
|
|
209
|
+
...(valueAt === undefined ? spot(start) : spot(valueAt + writtenAt(offset))),
|
|
201
210
|
})),
|
|
202
211
|
);
|
|
203
212
|
}
|
|
@@ -213,9 +222,7 @@ export function scanSlideHtml(html: string): HtmlScan {
|
|
|
213
222
|
slides,
|
|
214
223
|
...(slug === undefined ? {} : { slug }),
|
|
215
224
|
...(layout === undefined ? {} : { layout }),
|
|
216
|
-
classes: elements.flatMap((element) =>
|
|
217
|
-
(attributeValue(element, "class") ?? "").split(/\s+/).filter(Boolean),
|
|
218
|
-
),
|
|
225
|
+
classes: elements.flatMap((element) => attributeTokens(attributeValue(element, "class"))),
|
|
219
226
|
refs,
|
|
220
227
|
emptyHeadings: headings
|
|
221
228
|
.filter((heading) => !heading.content)
|
|
@@ -237,14 +244,28 @@ export function scanSlideHtml(html: string): HtmlScan {
|
|
|
237
244
|
* or `"none"` says it is decoration, as `aria-hidden` does.
|
|
238
245
|
*/
|
|
239
246
|
function isPicture(tag: string, role: string | null): boolean {
|
|
240
|
-
const
|
|
241
|
-
if (
|
|
247
|
+
const first = attributeTokens(role)[0]?.toLowerCase();
|
|
248
|
+
if (first === "presentation" || first === "none") {
|
|
242
249
|
return false;
|
|
243
250
|
}
|
|
244
|
-
return tag === "img" || tag === "svg" ||
|
|
251
|
+
return tag === "img" || tag === "svg" || first === "img";
|
|
245
252
|
}
|
|
246
253
|
|
|
247
|
-
/**
|
|
254
|
+
/** Whether `aria-hidden` marks decoration: `true`, give or take ASCII whitespace. */
|
|
255
|
+
function isAriaHidden(value: string | null): boolean {
|
|
256
|
+
return value !== null && trimAsciiWhitespace(value) === "true";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Whether a label gives a name. Chromium's accessible name keeps an `aria-label` of no-break
|
|
261
|
+
* spaces or other spaces outside ASCII, and drops one of ASCII whitespace alone; dek reads a
|
|
262
|
+
* `title` or an `aria-labelledby` of whitespace alone as no name too.
|
|
263
|
+
*/
|
|
264
|
+
export function isName(label: string | null | undefined): boolean {
|
|
265
|
+
return label != null && trimAsciiWhitespace(label) !== "";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The value of `name` on `element`, as the browser reads it. */
|
|
248
269
|
function attributeValue(element: HtmlElement, name: string): string | undefined {
|
|
249
270
|
return element.attributes.find((attribute) => attribute.name === name)?.value;
|
|
250
271
|
}
|
|
@@ -253,24 +274,25 @@ function isDefined<T>(value: T | undefined): value is T {
|
|
|
253
274
|
return value !== undefined;
|
|
254
275
|
}
|
|
255
276
|
|
|
256
|
-
/** The URLs one attribute names, each with its offset into the value. */
|
|
277
|
+
/** The URLs one attribute names, each with its offset into the decoded value. */
|
|
257
278
|
function attributeRefs(
|
|
258
279
|
tag: string,
|
|
259
|
-
|
|
280
|
+
attr: string,
|
|
281
|
+
value: string,
|
|
260
282
|
): Array<{ ref: Omit<HtmlRef, keyof SourceSpot>; offset: number }> {
|
|
261
|
-
const named = attributeUrls(tag,
|
|
283
|
+
const named = attributeUrls(tag, attr, value);
|
|
262
284
|
if (!named) {
|
|
263
285
|
return [];
|
|
264
286
|
}
|
|
265
287
|
return named.urls.map(({ url, offset }) => ({
|
|
266
|
-
ref: { tag, attr
|
|
288
|
+
ref: { tag, attr, value: url, use: named.use },
|
|
267
289
|
offset,
|
|
268
290
|
}));
|
|
269
291
|
}
|
|
270
292
|
|
|
271
293
|
/**
|
|
272
|
-
* Where each attribute of the start tag at `start` is written: its name, and its value
|
|
273
|
-
* has one. Only positions come from here; names and values are the parser's.
|
|
294
|
+
* Where each attribute of the start tag at `start` is written: its name, and its value as
|
|
295
|
+
* written when it has one. Only positions come from here; names and values are the parser's.
|
|
274
296
|
*/
|
|
275
297
|
function attributeOffsets(
|
|
276
298
|
html: string,
|
package/src/core/html.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { cutText } from "./annotation-rows.ts";
|
|
2
2
|
import { assetInliner } from "./assets.ts";
|
|
3
|
+
import {
|
|
4
|
+
attributeOf,
|
|
5
|
+
attributeTokens,
|
|
6
|
+
decodeAttribute,
|
|
7
|
+
trimAsciiWhitespace,
|
|
8
|
+
} from "./attribute-value.ts";
|
|
3
9
|
import { deckPaths } from "./deck-paths.ts";
|
|
4
10
|
import { escapeAttr, escapeHtml } from "./escape.ts";
|
|
5
11
|
import { type LineColumn, lineLocator } from "./lines.ts";
|
|
@@ -33,7 +39,7 @@ export function htmlShell(options: {
|
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export function hasSlideClass(className: string | null | undefined): boolean {
|
|
36
|
-
return (className
|
|
42
|
+
return attributeTokens(className).some((token) => token.toLowerCase() === "slide");
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
/**
|
|
@@ -54,7 +60,7 @@ function locateSlideSection(html: string): { section: string; start: number } |
|
|
|
54
60
|
const marked = rewriteHtml(html, (rewriter) => {
|
|
55
61
|
rewriter.on("section", {
|
|
56
62
|
element(el) {
|
|
57
|
-
if (found || !hasSlideClass(el
|
|
63
|
+
if (found || !hasSlideClass(attributeOf(el, "class"))) {
|
|
58
64
|
return;
|
|
59
65
|
}
|
|
60
66
|
found = true;
|
|
@@ -182,16 +188,17 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
|
|
|
182
188
|
return rewriteHtml(html, (rewriter) => {
|
|
183
189
|
rewriter.on("section", {
|
|
184
190
|
element(el) {
|
|
185
|
-
if (done || !hasSlideClass(el
|
|
191
|
+
if (done || !hasSlideClass(attributeOf(el, "class"))) {
|
|
186
192
|
return;
|
|
187
193
|
}
|
|
188
194
|
done = true;
|
|
189
195
|
if (el.getAttribute("data-slug") === null) {
|
|
190
196
|
el.setAttribute("data-slug", stamp.slug);
|
|
191
197
|
}
|
|
192
|
-
// Ahead of the author's own style, so a slide can still set its own.
|
|
198
|
+
// Ahead of the author's own style, so a slide can still set its own. The style goes back
|
|
199
|
+
// into the markup, so it stays as written.
|
|
193
200
|
const place = `--dek-slide-number: ${stamp.place.number}; --dek-slide-count: ${stamp.place.count}`;
|
|
194
|
-
const own = el.getAttribute("style")
|
|
201
|
+
const own = trimAsciiWhitespace(el.getAttribute("style") ?? "");
|
|
195
202
|
el.setAttribute("style", own ? `${place}; ${own}` : place);
|
|
196
203
|
if (stamp.shown) {
|
|
197
204
|
addClass(el, "is-current");
|
|
@@ -206,7 +213,7 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
|
|
|
206
213
|
if (shown) {
|
|
207
214
|
rewriter.on("[data-step]", {
|
|
208
215
|
element(el) {
|
|
209
|
-
const step = el
|
|
216
|
+
const step = attributeOf(el, "data-step");
|
|
210
217
|
if (step !== null && shown.has(step)) {
|
|
211
218
|
addClass(el, "is-shown");
|
|
212
219
|
}
|
|
@@ -225,7 +232,7 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
|
|
|
225
232
|
if (spot) {
|
|
226
233
|
el.setAttribute("data-dek-source", `${spot.line}:${spot.column}`);
|
|
227
234
|
if (spot.classes !== undefined) {
|
|
228
|
-
el.setAttribute("data-dek-class", spot.classes);
|
|
235
|
+
el.setAttribute("data-dek-class", escapeAttr(spot.classes));
|
|
229
236
|
}
|
|
230
237
|
}
|
|
231
238
|
},
|
|
@@ -235,8 +242,8 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
|
|
|
235
242
|
}
|
|
236
243
|
|
|
237
244
|
/**
|
|
238
|
-
* Where a start tag is written, its tag, its classes as written
|
|
239
|
-
* and the text inside it as written, whitespace collapsed.
|
|
245
|
+
* Where a start tag is written, its tag, its classes as written and read as the browser reads
|
|
246
|
+
* them (none when it has no `class`), and the text inside it as written, whitespace collapsed.
|
|
240
247
|
*/
|
|
241
248
|
type TagSpot = LineColumn & { tag: string; classes?: string; text: string };
|
|
242
249
|
|
|
@@ -254,7 +261,7 @@ function startTagSpots(html: string, source: SlideSource): TagSpot[] {
|
|
|
254
261
|
element(el) {
|
|
255
262
|
el.before(mark, { html: true });
|
|
256
263
|
const index = tags.length;
|
|
257
|
-
tags.push({ tag: el.tagName.toLowerCase(), classes: el
|
|
264
|
+
tags.push({ tag: el.tagName.toLowerCase(), classes: attributeOf(el, "class"), text: "" });
|
|
258
265
|
if (el.canHaveContent) {
|
|
259
266
|
open.push(index);
|
|
260
267
|
el.onEndTag(() => {
|
|
@@ -283,9 +290,7 @@ function startTagSpots(html: string, source: SlideSource): TagSpot[] {
|
|
|
283
290
|
offset += before.length;
|
|
284
291
|
const { tag = "", classes = null, text = "" } = tags[index] ?? {};
|
|
285
292
|
const found = { ...spot(offset), tag, text: text.replace(/\s+/g, " ").trim() };
|
|
286
|
-
return classes === null
|
|
287
|
-
? found
|
|
288
|
-
: { ...found, classes: classes.split(/\s+/).filter(Boolean).join(" ") };
|
|
293
|
+
return classes === null ? found : { ...found, classes: attributeTokens(classes).join(" ") };
|
|
289
294
|
});
|
|
290
295
|
}
|
|
291
296
|
|
|
@@ -334,12 +339,13 @@ export function collectSlidesHtml(
|
|
|
334
339
|
.join("");
|
|
335
340
|
}
|
|
336
341
|
|
|
342
|
+
/** `className` added to the classes as written, unless the browser reads it among them already. */
|
|
337
343
|
function addClass(el: HTMLRewriterTypes.Element, className: string): void {
|
|
338
344
|
const current = el.getAttribute("class");
|
|
339
|
-
|
|
340
|
-
if (tokens.includes(className)) {
|
|
345
|
+
if (current !== null && attributeTokens(decodeAttribute(current).value).includes(className)) {
|
|
341
346
|
return;
|
|
342
347
|
}
|
|
348
|
+
const tokens = attributeTokens(current);
|
|
343
349
|
tokens.push(className);
|
|
344
350
|
el.setAttribute("class", tokens.join(" "));
|
|
345
351
|
}
|
|
@@ -5,6 +5,7 @@ import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
|
5
5
|
import { isPlainWord } from "../escape.ts";
|
|
6
6
|
import type { HtmlRef } from "../html-scan.ts";
|
|
7
7
|
import { isInside } from "../path.ts";
|
|
8
|
+
import { trimUrl } from "../url-attributes.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* DEK020 to DEK023 for one reference, whether it comes from a slide's markup
|
|
@@ -56,9 +57,9 @@ export function assetRefDiagnostics(
|
|
|
56
57
|
if (kind === "missing") {
|
|
57
58
|
return [
|
|
58
59
|
diag("DEK021", {
|
|
59
|
-
message: `missing ${ref.tag === "img" ? "image" : "file"} "${ref.value}"`,
|
|
60
|
+
message: `missing ${ref.tag === "img" ? "image" : "file"} "${visibleSpaces(ref.value)}"`,
|
|
60
61
|
...location,
|
|
61
|
-
...missingImageHint(ref.value, deckDir),
|
|
62
|
+
...(strayedSpaceHint(ref.value, deckDir, from) ?? missingImageHint(ref.value, deckDir)),
|
|
62
63
|
data: { src: ref.value },
|
|
63
64
|
}),
|
|
64
65
|
];
|
|
@@ -66,7 +67,7 @@ export function assetRefDiagnostics(
|
|
|
66
67
|
if (!isCanonicalAssetPath(ref.value)) {
|
|
67
68
|
return [
|
|
68
69
|
diag("DEK023", {
|
|
69
|
-
message: `asset "${ref.value}" must be referenced as assets/${posix.basename(ref.value
|
|
70
|
+
message: `asset "${ref.value}" must be referenced as assets/${posix.basename(trimUrl(ref.value))}`,
|
|
70
71
|
...location,
|
|
71
72
|
data: { src: ref.value },
|
|
72
73
|
}),
|
|
@@ -76,19 +77,56 @@ export function assetRefDiagnostics(
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
function leavesFromEither(value: string, deckDir: string, from: string): boolean {
|
|
79
|
-
const path = value
|
|
80
|
+
const path = trimUrl(value).split(/[?#]/)[0] ?? "";
|
|
80
81
|
return (
|
|
81
82
|
path !== "" &&
|
|
82
83
|
[resolve(deckDir, path), resolve(from, path)].some((target) => !isInside(target, deckDir))
|
|
83
84
|
);
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/** `value` with each space but the plain one written as `\uXXXX`, so a message shows it. */
|
|
88
|
+
function visibleSpaces(value: string): string {
|
|
89
|
+
return value.replace(
|
|
90
|
+
/[^\S ]/g,
|
|
91
|
+
(char) => `\\u${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The hint for a path that names a deck file once the spaces outside ASCII at its ends are gone,
|
|
97
|
+
* as ` assets/a.png` does: the browser keeps those spaces, so the path names nothing.
|
|
98
|
+
*/
|
|
99
|
+
function strayedSpaceHint(
|
|
100
|
+
value: string,
|
|
101
|
+
deckDir: string,
|
|
102
|
+
from: string,
|
|
103
|
+
): { hint: string } | undefined {
|
|
104
|
+
const url = trimUrl(value);
|
|
105
|
+
const bare = url.trim();
|
|
106
|
+
if (bare === url || classifyAssetRef(bare, { deckDir, from }).kind !== "file") {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const at = url.indexOf(bare);
|
|
110
|
+
const before = url.slice(0, at);
|
|
111
|
+
const after = url.slice(at + bare.length);
|
|
112
|
+
const where = before && after ? "around" : before ? "before" : "after";
|
|
113
|
+
const spaces = [...new Set(before + after)].map((char) => {
|
|
114
|
+
const point = `U+${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
|
|
115
|
+
return char === "\u00A0" ? `no-break space (${point})` : `space (${point})`;
|
|
116
|
+
});
|
|
117
|
+
// The path is the slide's text: one that is no plain word is left out of the hint.
|
|
118
|
+
const path = isPlainWord(bare) ? bare : "the path";
|
|
119
|
+
return {
|
|
120
|
+
hint: `remove the ${spaces.join(" and the ")} ${where} ${path}: the browser keeps ${spaces.length > 1 ? "them" : "it"} as part of the path, which then names no file`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
86
124
|
/**
|
|
87
125
|
* Decks copy what they use from the project's `assets/` (so a deck stays whole when moved);
|
|
88
126
|
* when the project has the file, the fix is that copy.
|
|
89
127
|
*/
|
|
90
128
|
function missingImageHint(value: string, deckDir: string): { hint?: string } {
|
|
91
|
-
const name = value
|
|
129
|
+
const name = trimUrl(value).split(/[?#]/)[0] ?? "";
|
|
92
130
|
if (!name.startsWith("assets/")) {
|
|
93
131
|
return {};
|
|
94
132
|
}
|
|
@@ -104,7 +142,7 @@ function missingImageHint(value: string, deckDir: string): { hint?: string } {
|
|
|
104
142
|
function remoteHint(value: string): string {
|
|
105
143
|
let name = "";
|
|
106
144
|
try {
|
|
107
|
-
name = posix.basename(new URL(value
|
|
145
|
+
name = posix.basename(new URL(trimUrl(value), "https://dek.invalid").pathname);
|
|
108
146
|
} catch {}
|
|
109
147
|
// The name is the remote URL's: one that is no plain word is left out of the hint.
|
|
110
148
|
return name && isPlainWord(name)
|
package/src/core/lint/context.ts
CHANGED
|
@@ -26,6 +26,8 @@ type SlideSource = { html: string; scan: HtmlScan; skeleton: boolean };
|
|
|
26
26
|
export type LintContext = DeckFiles & {
|
|
27
27
|
/** DEK016 and DEK017 of each slide script, by slug: evaluated before the rules run. */
|
|
28
28
|
scripts: Map<string, Diagnostic[]>;
|
|
29
|
+
/** The source of each slide script lint evaluated, by slug. */
|
|
30
|
+
scriptCode: Map<string, string>;
|
|
29
31
|
};
|
|
30
32
|
|
|
31
33
|
/** The deck as read from disk, each file once. */
|
|
@@ -2,6 +2,7 @@ import { isKeyframesPrelude, selectorClasses, splitSelectorList, startsAtSlide }
|
|
|
2
2
|
import { deckPaths } from "../deck-paths.ts";
|
|
3
3
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
4
4
|
import { listed } from "../prose.ts";
|
|
5
|
+
import { RUNTIME_CLASSES } from "../theme-facts.ts";
|
|
5
6
|
import type { LintContext } from "./context.ts";
|
|
6
7
|
|
|
7
8
|
/** How many slides may style something alike before it belongs in the theme: the third copy. */
|
|
@@ -56,6 +57,21 @@ function alsoReaches(ctx: LintContext, selector: string, setters: string[]): str
|
|
|
56
57
|
});
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* How many classes theme.css would define with a rule under `selector` moved into it, when that
|
|
62
|
+
* is more than the deck's `max_classes` (DEK013); nothing while the move stays within it.
|
|
63
|
+
*/
|
|
64
|
+
function overBudget(ctx: LintContext, selector: string): number | undefined {
|
|
65
|
+
const defined = ctx.theme?.classes ?? new Set<string>();
|
|
66
|
+
const added = new Set(
|
|
67
|
+
selectorClasses(themeSelector(selector)).filter(
|
|
68
|
+
(name) => !defined.has(name) && !RUNTIME_CLASSES.has(name),
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
const after = defined.size + added.size;
|
|
72
|
+
return added.size > 0 && after > ctx.deck.deck.max_classes ? after : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
/**
|
|
60
76
|
* DEK026: the same declaration under the same selector in the stylesheets of SHARED_BY slides
|
|
61
77
|
* or more. Each slide fixed alike on its own, as agents working on one slide each will, is one
|
|
@@ -146,18 +162,27 @@ export function sharedStyleDiagnostics(ctx: LintContext): Diagnostic[] {
|
|
|
146
162
|
.join(
|
|
147
163
|
"",
|
|
148
164
|
)} without it, so check ${reaches.length === 1 ? "that slide" : "those slides"} after the move`;
|
|
165
|
+
// Following the hint must not trade this warning for DEK013's error.
|
|
166
|
+
const themeClasses = overBudget(ctx, selector);
|
|
167
|
+
const budget =
|
|
168
|
+
themeClasses === undefined
|
|
169
|
+
? ""
|
|
170
|
+
: `; theme.css would then define ${themeClasses} classes, past max_classes ${ctx.deck.deck.max_classes}, so set \`max_classes: ${themeClasses}\` in the frontmatter too, or leave the rule in the slides`;
|
|
149
171
|
return diag("DEK026", {
|
|
150
172
|
message: `${rule} is also in ${listed(others)}`,
|
|
151
173
|
path: paths.slide(slug, ".css"),
|
|
152
174
|
line,
|
|
153
175
|
slug,
|
|
154
|
-
hint: `write ${themeRule} once in theme.css and delete it from ${listed(slugs.map(cssOf))}${affected}`,
|
|
176
|
+
hint: `write ${themeRule} once in theme.css and delete it from ${listed(slugs.map(cssOf))}${affected}${budget}`,
|
|
155
177
|
data: {
|
|
156
178
|
selector,
|
|
157
179
|
declarations,
|
|
158
180
|
slides: slugs,
|
|
159
181
|
...(atPath.length > 0 ? { atRules: atPath } : {}),
|
|
160
182
|
...(reaches.length > 0 ? { alsoReaches: reaches } : {}),
|
|
183
|
+
...(themeClasses !== undefined
|
|
184
|
+
? { themeClasses, limit: ctx.deck.deck.max_classes }
|
|
185
|
+
: {}),
|
|
161
186
|
},
|
|
162
187
|
});
|
|
163
188
|
}),
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { attributeTokens } from "../attribute-value.ts";
|
|
1
2
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
2
3
|
import { isPlainWord } from "../escape.ts";
|
|
3
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
type HtmlAttribute,
|
|
6
|
+
type HtmlScan,
|
|
7
|
+
isName,
|
|
8
|
+
type Picture,
|
|
9
|
+
type SourceSpot,
|
|
10
|
+
} from "../html-scan.ts";
|
|
4
11
|
import type { Section } from "../schema.ts";
|
|
5
12
|
import { beatAt, formatStepChoices, resolveStep, stepChoices } from "../step.ts";
|
|
6
13
|
import { suggest } from "../suggest.ts";
|
|
@@ -368,24 +375,22 @@ const EVENT_HANDLER_RE = /^on[a-z]+$/;
|
|
|
368
375
|
/** Tags that load a URL as a document of its own, so an SVG there runs its scripts. */
|
|
369
376
|
const DOCUMENT_TAGS = new Set(["iframe", "frame", "object", "embed"]);
|
|
370
377
|
|
|
378
|
+
/** What a relative URL resolves against; only a URL's own scheme can run script. */
|
|
379
|
+
const SOME_PAGE = "https://dek.invalid/";
|
|
380
|
+
|
|
371
381
|
/**
|
|
372
382
|
* The scheme of a URL that runs script, or undefined: `javascript:` and `vbscript:` always, and a
|
|
373
383
|
* `data:` URL holding HTML, or SVG where a tag loads it as a document. An image's `data:` stays.
|
|
374
|
-
*
|
|
384
|
+
* The value comes decoded, and the URL parser reads it as the browser's does: C0 controls and
|
|
385
|
+
* spaces trimmed, tabs and newlines dropped anywhere, the scheme in any case.
|
|
375
386
|
*/
|
|
376
387
|
function scriptUrlScheme(tag: string, value: string): string | undefined {
|
|
377
|
-
const url = value
|
|
378
|
-
|
|
379
|
-
.
|
|
380
|
-
.toLowerCase();
|
|
381
|
-
if (url.startsWith("javascript:")) {
|
|
382
|
-
return "javascript:";
|
|
383
|
-
}
|
|
384
|
-
if (url.startsWith("vbscript:")) {
|
|
385
|
-
return "vbscript:";
|
|
388
|
+
const url = URL.parse(value, SOME_PAGE);
|
|
389
|
+
if (url?.protocol === "javascript:" || url?.protocol === "vbscript:") {
|
|
390
|
+
return url.protocol;
|
|
386
391
|
}
|
|
387
|
-
if (url
|
|
388
|
-
const type = url.slice("data:".length).split(/[;,]/, 1)[0]?.trim();
|
|
392
|
+
if (url?.protocol === "data:") {
|
|
393
|
+
const type = url.href.slice("data:".length).split(/[;,]/, 1)[0]?.trim().toLowerCase();
|
|
389
394
|
const runs =
|
|
390
395
|
type === "text/html" ||
|
|
391
396
|
type === "application/xhtml+xml" ||
|
|
@@ -478,14 +483,12 @@ function unnamedPicture({
|
|
|
478
483
|
}: Picture): { message: string; hint: string; data: Record<string, string> } | undefined {
|
|
479
484
|
const value = (name: string): string | undefined =>
|
|
480
485
|
element.attributes.find((attribute) => attribute.name === name)?.value;
|
|
481
|
-
const named = [value("aria-label"), value("aria-labelledby"), value("title")].some(
|
|
482
|
-
(name) => name !== undefined && name.trim() !== "",
|
|
483
|
-
);
|
|
486
|
+
const named = [value("aria-label"), value("aria-labelledby"), value("title")].some(isName);
|
|
484
487
|
const { tag } = element;
|
|
485
488
|
if (named || (tag === "img" && value("alt") !== undefined)) {
|
|
486
489
|
return undefined;
|
|
487
490
|
}
|
|
488
|
-
if ((value("role")
|
|
491
|
+
if (attributeTokens(value("role"))[0]?.toLowerCase() === "img") {
|
|
489
492
|
if (titled) {
|
|
490
493
|
return undefined;
|
|
491
494
|
}
|
|
@@ -543,7 +546,7 @@ function unknownClassDiagnostics(
|
|
|
543
546
|
: `define it in slides/${section.slug}.css, or use one of: ${shown.join(", ")}${scriptHint}`;
|
|
544
547
|
const unknown = new Map<string, HtmlAttribute>();
|
|
545
548
|
for (const attribute of attributesNamed(scan, "class")) {
|
|
546
|
-
for (const name of attribute.value
|
|
549
|
+
for (const name of attributeTokens(attribute.value)) {
|
|
547
550
|
if (!classes.has(name) && !unknown.has(name)) {
|
|
548
551
|
unknown.set(name, attribute);
|
|
549
552
|
}
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
} from "../css.ts";
|
|
13
13
|
import { deckPaths } from "../deck-paths.ts";
|
|
14
14
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
15
|
+
import { escapeRegExp } from "../escape.ts";
|
|
16
|
+
import { listed } from "../prose.ts";
|
|
15
17
|
import { assetRefDiagnostics } from "./asset-refs.ts";
|
|
16
18
|
import type { LintContext } from "./context.ts";
|
|
17
19
|
import { isRawThemeValue, isRawTokenValue, REQUIRED_TOKENS, tokenSuggestion } from "./tokens.ts";
|
|
@@ -24,14 +26,18 @@ function missingThemeDiagnostic(ctx: LintContext): Diagnostic {
|
|
|
24
26
|
return diag("DEK018", {
|
|
25
27
|
message: "theme.css not found",
|
|
26
28
|
path: deckPaths(ctx.deck.dir).theme,
|
|
27
|
-
hint: "copy
|
|
29
|
+
hint: "copy the project's template, ../../theme.css, or another deck's theme into the deck directory",
|
|
28
30
|
});
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/** DEK012, DEK013, DEK015, DEK014, and what its `url()`s load (DEK020 to DEK023) for theme.css. */
|
|
32
34
|
function lintTheme(
|
|
33
35
|
{ path, sheet, classes, tokens }: NonNullable<LintContext["theme"]>,
|
|
34
|
-
{
|
|
36
|
+
{
|
|
37
|
+
maxClasses,
|
|
38
|
+
deckDir,
|
|
39
|
+
usedBy,
|
|
40
|
+
}: { maxClasses: number; deckDir: string; usedBy: Map<string, string[]> | undefined },
|
|
35
41
|
): Diagnostic[] {
|
|
36
42
|
const diagnostics: Diagnostic[] = [];
|
|
37
43
|
// A rule inside `@media` reaches the page as surely as one outside it.
|
|
@@ -56,6 +62,7 @@ function lintTheme(
|
|
|
56
62
|
diag("DEK013", {
|
|
57
63
|
message: `theme.css has ${classCount} classes; limit is ${maxClasses}`,
|
|
58
64
|
path,
|
|
65
|
+
hint: classBudgetHint(classCount, usedBy),
|
|
59
66
|
data: { classes: classCount, limit: maxClasses },
|
|
60
67
|
}),
|
|
61
68
|
);
|
|
@@ -287,9 +294,63 @@ function themeScopeHint(selector: string): string {
|
|
|
287
294
|
return `write it as ${scoped.join(", ")}`;
|
|
288
295
|
}
|
|
289
296
|
|
|
297
|
+
/**
|
|
298
|
+
* How to bring the theme back within its class budget: first what frees a class without changing
|
|
299
|
+
* a slide, a class no slide uses and one only one slide uses, then raising the budget itself.
|
|
300
|
+
*/
|
|
301
|
+
function classBudgetHint(classCount: number, usedBy: Map<string, string[]> | undefined): string {
|
|
302
|
+
if (usedBy === undefined) {
|
|
303
|
+
return `set \`max_classes: ${classCount}\` in the frontmatter`;
|
|
304
|
+
}
|
|
305
|
+
const unused = [...usedBy].filter(([, slugs]) => slugs.length === 0).map(([name]) => `.${name}`);
|
|
306
|
+
const single = [...usedBy].flatMap(([name, slugs]) =>
|
|
307
|
+
slugs.length === 1 ? [`.${name} into slides/${slugs[0]}.css`] : [],
|
|
308
|
+
);
|
|
309
|
+
return [
|
|
310
|
+
...(unused.length > 0 ? [`delete ${listed(unused)}, which no slide uses`] : []),
|
|
311
|
+
...(single.length > 0
|
|
312
|
+
? [`move each class only one slide uses into that slide's stylesheet: ${listed(single)}`]
|
|
313
|
+
: []),
|
|
314
|
+
`set \`max_classes: ${classCount}\` in the frontmatter`,
|
|
315
|
+
].join("; or ");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Each class the theme defines but `.slide`, which every slide is, to the slides that use it, in
|
|
320
|
+
* script order: in their markup, or anywhere in their script, which may set it with `classList`.
|
|
321
|
+
* None while a section has no HTML yet or only its skeleton: what it will use is unknown.
|
|
322
|
+
*/
|
|
323
|
+
function slidesUsing(ctx: LintContext, classes: Set<string>): Map<string, string[]> | undefined {
|
|
324
|
+
const usedBy = new Map(
|
|
325
|
+
[...classes].filter((name) => name !== "slide").map((name) => [name, [] as string[]]),
|
|
326
|
+
);
|
|
327
|
+
for (const slug of ctx.sectionsBySlug.keys()) {
|
|
328
|
+
const source = ctx.slideSource(slug);
|
|
329
|
+
if (source === undefined || source.skeleton) {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
const code = ctx.scriptCode.get(slug);
|
|
333
|
+
for (const [name, slugs] of usedBy) {
|
|
334
|
+
if (source.scan.classes.includes(name) || (code !== undefined && namesClass(code, name))) {
|
|
335
|
+
slugs.push(slug);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return usedBy;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Whether `code` holds `name` as a class token: a word of its own, wherever it stands. */
|
|
343
|
+
function namesClass(code: string, name: string): boolean {
|
|
344
|
+
return new RegExp(`(^|[^\\w-])${escapeRegExp(name)}($|[^\\w-])`).test(code);
|
|
345
|
+
}
|
|
346
|
+
|
|
290
347
|
/** The theme's own findings, or DEK018 when there is no theme. */
|
|
291
348
|
export function themeDiagnostics(ctx: LintContext): Diagnostic[] {
|
|
292
349
|
return ctx.theme
|
|
293
|
-
? lintTheme(ctx.theme, {
|
|
350
|
+
? lintTheme(ctx.theme, {
|
|
351
|
+
maxClasses: ctx.deck.deck.max_classes,
|
|
352
|
+
deckDir: ctx.deck.dir,
|
|
353
|
+
usedBy: slidesUsing(ctx, ctx.theme.classes),
|
|
354
|
+
})
|
|
294
355
|
: [missingThemeDiagnostic(ctx)];
|
|
295
356
|
}
|