@hajimism/dek 0.3.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +136 -0
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/package.json +5 -3
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +95 -18
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +57 -19
- package/src/cli/contract.ts +58 -4
- package/src/cli/files.ts +2 -5
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +5 -4
- package/src/cli/goto.ts +44 -11
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +36 -16
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +51 -14
- package/src/cli/new.ts +28 -8
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +92 -9
- package/src/cli/result.ts +47 -6
- package/src/cli/scope.ts +57 -29
- package/src/cli/shot.ts +78 -6
- package/src/cli/show.ts +15 -2
- package/src/cli/text.ts +88 -11
- package/src/cli/theme.ts +58 -10
- package/src/cli/tty.ts +16 -2
- package/src/cli/voice.ts +15 -9
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- package/src/core/assets.ts +18 -10
- package/src/core/attribute-value.ts +188 -0
- package/src/core/build.ts +3 -3
- package/src/core/config.ts +61 -12
- package/src/core/css-scan.ts +173 -39
- package/src/core/css-transform.ts +57 -11
- package/src/core/css.ts +19 -13
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/foreign-text.ts +138 -0
- package/src/core/github.ts +1 -0
- package/src/core/html-entities.ts +318 -0
- package/src/core/html-scan.ts +49 -27
- package/src/core/html.ts +21 -15
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +50 -8
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +24 -19
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +57 -27
- package/src/core/order.ts +14 -0
- package/src/core/pack.ts +2 -2
- package/src/core/path.ts +8 -2
- package/src/core/playwright-module.d.ts +13 -1
- package/src/core/playwright-motion.ts +1 -0
- package/src/core/playwright-sheet.ts +1 -0
- package/src/core/playwright-visual.ts +207 -30
- package/src/core/playwright.ts +279 -44
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +56 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +52 -8
- package/src/core/ref.ts +308 -54
- package/src/core/resolve.ts +23 -11
- package/src/core/rumdl.ts +51 -25
- package/src/core/safe-fs.ts +19 -2
- package/src/core/sandbox.ts +217 -0
- package/src/core/shot/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +26 -17
- package/src/core/slide-eval-worker.ts +1 -0
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +12 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/spawn.ts +131 -32
- package/src/core/sync.ts +5 -14
- package/src/core/tar.ts +315 -0
- package/src/core/text-contrast.ts +2 -0
- package/src/core/toml-keys.ts +116 -51
- package/src/core/url-attributes.ts +27 -9
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +182 -24
- package/src/core/voice.ts +5 -6
- package/src/core/zod.ts +9 -1
- package/src/server/auth.ts +21 -0
- package/src/server/dev.ts +26 -7
- package/src/server/lock.ts +50 -7
- package/src/server/routes.ts +7 -2
- package/src/server/serial.ts +8 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
- package/src/video/mux.ts +27 -3
- package/src/video/recorder.ts +3 -2
- package/src/video/worker.ts +1 -0
- package/src/voice/engine.ts +81 -18
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One run of text as the page breaks it into lines: an element's own text with the text of the
|
|
3
|
+
* inline elements in it, as `<strong>` or `<a>`, which share its lines.
|
|
4
|
+
*/
|
|
5
|
+
export type TextFlow = {
|
|
6
|
+
/** Its element's index among the slide's elements, as `measureSlideInPage` lists them. */
|
|
7
|
+
element: number;
|
|
8
|
+
/** Its text as written, a `<br>` or a block inside it read as a newline. */
|
|
9
|
+
text: string;
|
|
10
|
+
/** Where each line starts in `text`, and whether a `<br>` or a block started it. */
|
|
11
|
+
lines: Array<{ start: number; forced: boolean }>;
|
|
12
|
+
/** Where each line would start, broken phrase by phrase; only where that is elsewhere. */
|
|
13
|
+
phrased?: number[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** What `measureTextFlowsInPage` finds: each text with more than one line, and how the slide breaks. */
|
|
17
|
+
export type MeasuredFlows = {
|
|
18
|
+
/** The slide's own `word-break`, which every text inherits unless a rule sets its own. */
|
|
19
|
+
wordBreak: string;
|
|
20
|
+
flows: Array<TextFlow & { textWrap: string }>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Lays each text on the first `.slide` out in lines, as `TextFlow` says. Where a line ends inside
|
|
25
|
+
* a phrase is what the page itself says: each text whose `word-break` is `normal` is laid out
|
|
26
|
+
* again beside itself with only `word-break: auto-phrase` changed, and where that puts its lines
|
|
27
|
+
* elsewhere, `phrased` says where. Chromium breaks by phrase only where the page's `lang` is one
|
|
28
|
+
* it knows phrases for, such as `ja`, so other text is never laid out elsewhere. The copies are
|
|
29
|
+
* taken away before it returns, so the page is as it was. It runs inside the page through
|
|
30
|
+
* `page.evaluate`, which serializes only this function, so it must not reference anything outside
|
|
31
|
+
* its own body.
|
|
32
|
+
*/
|
|
33
|
+
export function measureTextFlowsInPage(): MeasuredFlows {
|
|
34
|
+
const slide = document.querySelector(".slide");
|
|
35
|
+
if (!slide) {
|
|
36
|
+
return { wordBreak: "normal", flows: [] };
|
|
37
|
+
}
|
|
38
|
+
// Code keeps its own lines, and a drawing or a formula its own text.
|
|
39
|
+
const code = "pre, code, kbd, samp";
|
|
40
|
+
const drawings = new Set(["svg", "math"]);
|
|
41
|
+
// A text node, whose characters each take their place on a line, or an inline box such as an
|
|
42
|
+
// image or an inline-block, which takes its place whole.
|
|
43
|
+
type Piece = { at: number } & ({ node: Text } | { box: Element });
|
|
44
|
+
type Read = { text: string; pieces: Piece[]; forced: number[] };
|
|
45
|
+
// The text an element lays out in its own lines: its text nodes and those of the inline elements
|
|
46
|
+
// in it, and an inline box as the text it holds, or U+FFFC when it holds none. A <br>, a block
|
|
47
|
+
// between them, or a newline that white-space keeps, as pre-line does, starts a line.
|
|
48
|
+
const read = (root: Element): Read => {
|
|
49
|
+
const found: Read = { text: "", pieces: [], forced: [] };
|
|
50
|
+
const walk = (el: Element) => {
|
|
51
|
+
for (const child of el.childNodes) {
|
|
52
|
+
if (child instanceof Text) {
|
|
53
|
+
const at = found.text.length;
|
|
54
|
+
found.pieces.push({ at, node: child });
|
|
55
|
+
found.text += child.data;
|
|
56
|
+
if (/^(?:pre|pre-wrap|pre-line|break-spaces)$/.test(getComputedStyle(el).whiteSpace)) {
|
|
57
|
+
for (const kept of child.data.matchAll(/\n/g)) {
|
|
58
|
+
found.forced.push(at + kept.index);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!(child instanceof Element)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const display = getComputedStyle(child).display;
|
|
67
|
+
if (display === "none") {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const tag = child.tagName.toLowerCase();
|
|
71
|
+
if (tag === "br" || (!display.startsWith("inline") && display !== "contents")) {
|
|
72
|
+
found.forced.push(found.text.length);
|
|
73
|
+
found.text += "\n";
|
|
74
|
+
} else if ((display === "inline" || display === "contents") && !drawings.has(tag)) {
|
|
75
|
+
walk(child);
|
|
76
|
+
} else {
|
|
77
|
+
found.pieces.push({ at: found.text.length, box: child });
|
|
78
|
+
found.text += child.textContent?.trim() || "";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
walk(root);
|
|
83
|
+
return found;
|
|
84
|
+
};
|
|
85
|
+
// What a piece draws, each with its place in the text: a text node's characters but its white
|
|
86
|
+
// space, which draws nothing of its own, or an inline box whole.
|
|
87
|
+
const drawn = (piece: Piece): Array<{ offset: number; rect: DOMRect }> => {
|
|
88
|
+
if ("box" in piece) {
|
|
89
|
+
return [{ offset: piece.at, rect: piece.box.getBoundingClientRect() }];
|
|
90
|
+
}
|
|
91
|
+
const { node, at } = piece;
|
|
92
|
+
const marks: Array<{ offset: number; rect: DOMRect }> = [];
|
|
93
|
+
for (let end = 0; end < node.data.length; ) {
|
|
94
|
+
const begin = end;
|
|
95
|
+
end += (node.data.codePointAt(begin) ?? 0) > 0xffff ? 2 : 1;
|
|
96
|
+
if (/\s/.test(node.data.slice(begin, end))) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const range = document.createRange();
|
|
100
|
+
range.setStart(node, begin);
|
|
101
|
+
range.setEnd(node, end);
|
|
102
|
+
marks.push({ offset: at + begin, rect: range.getBoundingClientRect() });
|
|
103
|
+
}
|
|
104
|
+
return marks;
|
|
105
|
+
};
|
|
106
|
+
// Where each line starts: at the first thing drawn below the line before.
|
|
107
|
+
const lineStarts = ({ pieces, forced }: Read): Array<{ start: number; forced: boolean }> => {
|
|
108
|
+
const lines: Array<{ start: number; forced: boolean }> = [];
|
|
109
|
+
let bottom = Number.NEGATIVE_INFINITY;
|
|
110
|
+
let previous = -1;
|
|
111
|
+
for (const { offset, rect } of pieces.flatMap(drawn)) {
|
|
112
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (rect.top >= bottom - 1) {
|
|
116
|
+
lines.push({
|
|
117
|
+
start: offset,
|
|
118
|
+
forced: lines.length > 0 && forced.some((at) => at > previous && at < offset),
|
|
119
|
+
});
|
|
120
|
+
bottom = rect.bottom;
|
|
121
|
+
} else {
|
|
122
|
+
bottom = Math.max(bottom, rect.bottom);
|
|
123
|
+
}
|
|
124
|
+
previous = offset;
|
|
125
|
+
}
|
|
126
|
+
return lines;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const all = [...slide.querySelectorAll("*")];
|
|
130
|
+
const measured = all.flatMap((el, element) => {
|
|
131
|
+
const display = getComputedStyle(el).display;
|
|
132
|
+
if (
|
|
133
|
+
!(el instanceof HTMLElement) ||
|
|
134
|
+
display === "inline" ||
|
|
135
|
+
display === "contents" ||
|
|
136
|
+
display === "none" ||
|
|
137
|
+
el.closest(code) !== null ||
|
|
138
|
+
el.closest("svg, math") !== null ||
|
|
139
|
+
el.closest('[aria-hidden="true"]') !== null
|
|
140
|
+
) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const flow = read(el);
|
|
144
|
+
const lines = lineStarts(flow);
|
|
145
|
+
if (lines.length < 2) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
const style = getComputedStyle(el);
|
|
149
|
+
const soft = lines.some(({ forced }, at) => at > 0 && !forced);
|
|
150
|
+
return [
|
|
151
|
+
{
|
|
152
|
+
el,
|
|
153
|
+
element,
|
|
154
|
+
flow,
|
|
155
|
+
lines,
|
|
156
|
+
textWrap: style.textWrap,
|
|
157
|
+
rewrap: soft && style.wordBreak === "normal",
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
});
|
|
161
|
+
// Every line is read before any copy goes in, and every copy is in before any is read, so the
|
|
162
|
+
// page is laid out once for each. A copy sits where its text is, out of the flow, with only its
|
|
163
|
+
// word-break changed.
|
|
164
|
+
const copies = measured.map(({ el, rewrap }) => {
|
|
165
|
+
if (!rewrap) {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
const copy = el.cloneNode(true) as HTMLElement;
|
|
169
|
+
Object.assign(copy.style, {
|
|
170
|
+
position: "absolute",
|
|
171
|
+
visibility: "hidden",
|
|
172
|
+
left: "0px",
|
|
173
|
+
top: "0px",
|
|
174
|
+
margin: "0px",
|
|
175
|
+
boxSizing: "border-box",
|
|
176
|
+
width: `${el.offsetWidth}px`,
|
|
177
|
+
minWidth: "0px",
|
|
178
|
+
maxWidth: "none",
|
|
179
|
+
wordBreak: "auto-phrase",
|
|
180
|
+
});
|
|
181
|
+
el.after(copy);
|
|
182
|
+
return copy;
|
|
183
|
+
});
|
|
184
|
+
const flows = measured.map(({ element, flow, lines, textWrap }, at) => {
|
|
185
|
+
const copy = copies[at];
|
|
186
|
+
const phrased = copy && lineStarts(read(copy)).map(({ start }) => start);
|
|
187
|
+
copy?.remove();
|
|
188
|
+
const moved =
|
|
189
|
+
phrased !== undefined &&
|
|
190
|
+
(phrased.length !== lines.length || phrased.some((start, at) => start !== lines[at]?.start));
|
|
191
|
+
return {
|
|
192
|
+
element,
|
|
193
|
+
text: flow.text,
|
|
194
|
+
lines,
|
|
195
|
+
...(moved ? { phrased } : {}),
|
|
196
|
+
textWrap,
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
return { wordBreak: getComputedStyle(slide).wordBreak, flows };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** What reads wrong about a text's line breaks. */
|
|
203
|
+
export type LineBreakKind =
|
|
204
|
+
| {
|
|
205
|
+
/** A line ends inside a phrase. */
|
|
206
|
+
kind: "split";
|
|
207
|
+
/** The lines broken phrase by phrase. */
|
|
208
|
+
phrased: string[];
|
|
209
|
+
}
|
|
210
|
+
| {
|
|
211
|
+
/** A paragraph's last line holds two letters or fewer. */
|
|
212
|
+
kind: "short";
|
|
213
|
+
tail: string;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** A line break the audience trips over, in the flow of the element at `element`. */
|
|
217
|
+
export type LineBreak = {
|
|
218
|
+
element: number;
|
|
219
|
+
/** Its lines as the audience reads them. */
|
|
220
|
+
lines: string[];
|
|
221
|
+
} & LineBreakKind;
|
|
222
|
+
|
|
223
|
+
/** A last line with no more letters or digits than this reads as a remnant. */
|
|
224
|
+
const MAX_SHORT = 2;
|
|
225
|
+
|
|
226
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
227
|
+
|
|
228
|
+
/** The letters and digits in `text`, an inline box that holds no text counted as one. */
|
|
229
|
+
function letters(text: string): number {
|
|
230
|
+
return [...graphemes.segment(text)].filter(({ segment }) => /[\p{L}\p{N}]/u.test(segment))
|
|
231
|
+
.length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The lines of `text` that start at `starts`, as read: white space collapsed. */
|
|
235
|
+
function readLines(text: string, starts: number[]): string[] {
|
|
236
|
+
return starts.map((start, at) =>
|
|
237
|
+
text
|
|
238
|
+
.slice(start, starts[at + 1] ?? text.length)
|
|
239
|
+
.replace(/\s+/g, " ")
|
|
240
|
+
.trim(),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The line breaks in each flow that read wrong: a line that ends inside a phrase, which `phrased`
|
|
246
|
+
* shows where the page breaks it phrase by phrase, and a paragraph whose last line holds two
|
|
247
|
+
* letters or fewer. A short line a `<br>` or a block starts is the author's, and passes. Where a
|
|
248
|
+
* phrase is broken, every line after it moves once the text breaks by phrase, so that is all a
|
|
249
|
+
* flow reports.
|
|
250
|
+
*/
|
|
251
|
+
export function findLineBreaks(flows: TextFlow[]): LineBreak[] {
|
|
252
|
+
return flows.flatMap((flow): LineBreak[] => {
|
|
253
|
+
const lines = readLines(
|
|
254
|
+
flow.text,
|
|
255
|
+
flow.lines.map(({ start }) => start),
|
|
256
|
+
);
|
|
257
|
+
if (flow.phrased) {
|
|
258
|
+
return [
|
|
259
|
+
{
|
|
260
|
+
element: flow.element,
|
|
261
|
+
kind: "split",
|
|
262
|
+
lines,
|
|
263
|
+
phrased: readLines(flow.text, flow.phrased),
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
return flow.lines.flatMap(({ forced }, at): LineBreak[] => {
|
|
268
|
+
const last = at === flow.lines.length - 1 || flow.lines[at + 1]?.forced === true;
|
|
269
|
+
const tail = lines[at] ?? "";
|
|
270
|
+
return at > 0 && !forced && last && letters(tail) <= MAX_SHORT
|
|
271
|
+
? [{ element: flow.element, kind: "short", lines, tail }]
|
|
272
|
+
: [];
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
@@ -2,8 +2,10 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join, posix, resolve } from "node:path";
|
|
3
3
|
import { classifyAssetRef, isCanonicalAssetPath } from "../assets.ts";
|
|
4
4
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
5
|
+
import { isPlainWord } from "../escape.ts";
|
|
5
6
|
import type { HtmlRef } from "../html-scan.ts";
|
|
6
7
|
import { isInside } from "../path.ts";
|
|
8
|
+
import { trimUrl } from "../url-attributes.ts";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* DEK020 to DEK023 for one reference, whether it comes from a slide's markup
|
|
@@ -55,9 +57,9 @@ export function assetRefDiagnostics(
|
|
|
55
57
|
if (kind === "missing") {
|
|
56
58
|
return [
|
|
57
59
|
diag("DEK021", {
|
|
58
|
-
message: `missing ${ref.tag === "img" ? "image" : "file"} "${ref.value}"`,
|
|
60
|
+
message: `missing ${ref.tag === "img" ? "image" : "file"} "${visibleSpaces(ref.value)}"`,
|
|
59
61
|
...location,
|
|
60
|
-
...missingImageHint(ref.value, deckDir),
|
|
62
|
+
...(strayedSpaceHint(ref.value, deckDir, from) ?? missingImageHint(ref.value, deckDir)),
|
|
61
63
|
data: { src: ref.value },
|
|
62
64
|
}),
|
|
63
65
|
];
|
|
@@ -65,7 +67,7 @@ export function assetRefDiagnostics(
|
|
|
65
67
|
if (!isCanonicalAssetPath(ref.value)) {
|
|
66
68
|
return [
|
|
67
69
|
diag("DEK023", {
|
|
68
|
-
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))}`,
|
|
69
71
|
...location,
|
|
70
72
|
data: { src: ref.value },
|
|
71
73
|
}),
|
|
@@ -75,19 +77,56 @@ export function assetRefDiagnostics(
|
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
function leavesFromEither(value: string, deckDir: string, from: string): boolean {
|
|
78
|
-
const path = value
|
|
80
|
+
const path = trimUrl(value).split(/[?#]/)[0] ?? "";
|
|
79
81
|
return (
|
|
80
82
|
path !== "" &&
|
|
81
83
|
[resolve(deckDir, path), resolve(from, path)].some((target) => !isInside(target, deckDir))
|
|
82
84
|
);
|
|
83
85
|
}
|
|
84
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
|
+
|
|
85
124
|
/**
|
|
86
125
|
* Decks copy what they use from the project's `assets/` (so a deck stays whole when moved);
|
|
87
126
|
* when the project has the file, the fix is that copy.
|
|
88
127
|
*/
|
|
89
128
|
function missingImageHint(value: string, deckDir: string): { hint?: string } {
|
|
90
|
-
const name = value
|
|
129
|
+
const name = trimUrl(value).split(/[?#]/)[0] ?? "";
|
|
91
130
|
if (!name.startsWith("assets/")) {
|
|
92
131
|
return {};
|
|
93
132
|
}
|
|
@@ -95,15 +134,18 @@ function missingImageHint(value: string, deckDir: string): { hint?: string } {
|
|
|
95
134
|
if (!existsSync(projectFile)) {
|
|
96
135
|
return {};
|
|
97
136
|
}
|
|
98
|
-
|
|
137
|
+
// The name is the slide's text: one that is no plain word is left out of the hint.
|
|
138
|
+
const copy = isPlainWord(name) ? `../../${name}` : "it from the project's assets/";
|
|
139
|
+
return { hint: `the project has it: copy ${copy} into the deck's assets/` };
|
|
99
140
|
}
|
|
100
141
|
|
|
101
142
|
function remoteHint(value: string): string {
|
|
102
143
|
let name = "";
|
|
103
144
|
try {
|
|
104
|
-
name = posix.basename(new URL(value
|
|
145
|
+
name = posix.basename(new URL(trimUrl(value), "https://dek.invalid").pathname);
|
|
105
146
|
} catch {}
|
|
106
|
-
|
|
147
|
+
// The name is the remote URL's: one that is no plain word is left out of the hint.
|
|
148
|
+
return name && isPlainWord(name)
|
|
107
149
|
? `download it into assets/ and use assets/${name}`
|
|
108
150
|
: "download it into assets/ and reference it from there";
|
|
109
151
|
}
|
package/src/core/lint/files.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
2
|
+
import { shellName } from "../escape.ts";
|
|
2
3
|
import { either } from "../prose.ts";
|
|
3
4
|
import { listSlideFiles, SLIDE_SIDECARS } from "../resolve.ts";
|
|
5
|
+
import { Id } from "../schema.ts";
|
|
4
6
|
import type { LintContext } from "./context.ts";
|
|
5
7
|
|
|
6
8
|
/** DEK001 for a section without slides/<slug>.html; DEK002 for a slide file without its section. */
|
|
@@ -60,9 +62,13 @@ export function pairingDiagnostics(ctx: LintContext): Diagnostic[] {
|
|
|
60
62
|
|
|
61
63
|
/**
|
|
62
64
|
* What to do with a slide file the script does not name, when no rename explains it: give it a
|
|
63
|
-
* section, or take it out of the talk. Which one is the author's call.
|
|
65
|
+
* section, or take it out of the talk. Which one is the author's call. A name that is no section
|
|
66
|
+
* id cannot head one, and is the file's text, so the hint names it nowhere.
|
|
64
67
|
*/
|
|
65
68
|
function orphanHint(slug: string, file: string): string {
|
|
69
|
+
if (!Id.safeParse(slug).success) {
|
|
70
|
+
return "its name is no section id, which uses a-z, 0-9, and -: rename it to one and add that section to script.md, or remove it if the slide is gone from the talk";
|
|
71
|
+
}
|
|
66
72
|
const title = slug.charAt(0).toUpperCase() + slug.slice(1).replaceAll("-", " ");
|
|
67
73
|
return `add a section for it to script.md, like \`## ${title} {#${slug}}\`, or remove ${file} if the slide is gone from the talk`;
|
|
68
74
|
}
|
|
@@ -91,10 +97,16 @@ export function suggestRename(diagnostics: Diagnostic[], ctx: LintContext): Diag
|
|
|
91
97
|
}
|
|
92
98
|
const [orphan] = orphans;
|
|
93
99
|
const [target] = targets;
|
|
94
|
-
if (
|
|
100
|
+
if (
|
|
101
|
+
orphans.size === 1 &&
|
|
102
|
+
targets.length === 1 &&
|
|
103
|
+
orphan?.slug &&
|
|
104
|
+
target &&
|
|
105
|
+
movable(orphan.slug)
|
|
106
|
+
) {
|
|
95
107
|
// An orphan beside a section without its own slide is a rename, made in the script or in the
|
|
96
108
|
// files: which one is the author's to say, and each command finishes it from this state.
|
|
97
|
-
const hint = `run
|
|
109
|
+
const hint = `run ${mvCommand(orphan.slug, target)} to move the files to the script's id, or ${mvCommand(target, orphan.slug)} to give the section the files' id`;
|
|
98
110
|
return diagnostics.map((diagnostic) =>
|
|
99
111
|
diagnostic === orphan
|
|
100
112
|
? { ...diagnostic, hint, data: { ...diagnostic.data, renames: targets } }
|
|
@@ -104,10 +116,11 @@ export function suggestRename(diagnostics: Diagnostic[], ctx: LintContext): Diag
|
|
|
104
116
|
);
|
|
105
117
|
}
|
|
106
118
|
return diagnostics.map((diagnostic) => {
|
|
107
|
-
|
|
119
|
+
const file = diagnostic.slug;
|
|
120
|
+
if (!orphans.has(diagnostic) || !file || !movable(file)) {
|
|
108
121
|
return diagnostic;
|
|
109
122
|
}
|
|
110
|
-
const commands = targets.map((slug) =>
|
|
123
|
+
const commands = targets.map((slug) => mvCommand(file, slug));
|
|
111
124
|
return {
|
|
112
125
|
...diagnostic,
|
|
113
126
|
hint: `run ${either(commands)} to move the files to the section they now belong to`,
|
|
@@ -115,3 +128,15 @@ export function suggestRename(diagnostics: Diagnostic[], ctx: LintContext): Diag
|
|
|
115
128
|
};
|
|
116
129
|
});
|
|
117
130
|
}
|
|
131
|
+
|
|
132
|
+
/** Whether a slide file's name can be written in a command: see `shellName`. */
|
|
133
|
+
function movable(slug: string): boolean {
|
|
134
|
+
return shellName(slug) !== undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `dekc mv from to`, with `--` first when a name starts with a dash, which would read as a flag. */
|
|
138
|
+
function mvCommand(from: string, to: string): string {
|
|
139
|
+
const words = [from, to].map((slug) => shellName(slug) ?? slug);
|
|
140
|
+
const end = [from, to].some((slug) => slug.startsWith("-")) ? "-- " : "";
|
|
141
|
+
return `\`dekc mv ${end}${words.join(" ")}\``;
|
|
142
|
+
}
|
package/src/core/lint/keys.ts
CHANGED
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
unknownTomlKeys,
|
|
6
6
|
} from "../config-keys.ts";
|
|
7
7
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
8
|
+
import { isPlainWord } from "../escape.ts";
|
|
8
9
|
import { readFrontmatterYaml } from "../parse.ts";
|
|
9
|
-
import {
|
|
10
|
+
import type { Project } from "../resolve.ts";
|
|
11
|
+
import { readSourceIfExists } from "../safe-fs.ts";
|
|
10
12
|
import type { DeckSettings } from "../schema.ts";
|
|
11
13
|
import type { LintContext } from "./context.ts";
|
|
12
14
|
|
|
@@ -17,7 +19,7 @@ import type { LintContext } from "./context.ts";
|
|
|
17
19
|
|
|
18
20
|
/** DEK008 for dek.toml: a project finding, the same for every deck. */
|
|
19
21
|
export function tomlKeyDiagnostics(project: Project): Diagnostic[] {
|
|
20
|
-
return unknownTomlKeys(
|
|
22
|
+
return unknownTomlKeys(readSourceIfExists(project.configPath, project.root) ?? "").map((key) =>
|
|
21
23
|
unknownKeyDiagnostic(key, { file: "dek.toml", path: project.configPath }),
|
|
22
24
|
);
|
|
23
25
|
}
|
|
@@ -79,8 +81,10 @@ function unknownKeyDiagnostic(
|
|
|
79
81
|
where.file === "dek.toml"
|
|
80
82
|
? `see ${REFERENCE_URL}#dek-toml`
|
|
81
83
|
: `the keys are ${FRONTMATTER_KEYS.join(", ")}; see ${REFERENCE_URL}#frontmatter`;
|
|
84
|
+
// The key is the file's text, which a cloned project brings along: see formatZodIssues.
|
|
85
|
+
const shown = isPlainWord(key) ? key : "<key>";
|
|
82
86
|
return diag("DEK008", {
|
|
83
|
-
message: `unknown key ${
|
|
87
|
+
message: `unknown key ${shown} in ${place}; dek ignores it`,
|
|
84
88
|
path: where.path,
|
|
85
89
|
line,
|
|
86
90
|
hint: suggestion ? `did you mean ${suggestion}?` : known,
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import { attributeTokens } from "../attribute-value.ts";
|
|
1
2
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
2
|
-
import
|
|
3
|
+
import { isPlainWord } from "../escape.ts";
|
|
4
|
+
import {
|
|
5
|
+
type HtmlAttribute,
|
|
6
|
+
type HtmlScan,
|
|
7
|
+
isName,
|
|
8
|
+
type Picture,
|
|
9
|
+
type SourceSpot,
|
|
10
|
+
} from "../html-scan.ts";
|
|
3
11
|
import type { Section } from "../schema.ts";
|
|
4
12
|
import { beatAt, formatStepChoices, resolveStep, stepChoices } from "../step.ts";
|
|
5
13
|
import { suggest } from "../suggest.ts";
|
|
@@ -159,7 +167,8 @@ function layoutDiagnostics({ section, path, scan }: SlideHtml, layouts: Set<stri
|
|
|
159
167
|
return [];
|
|
160
168
|
}
|
|
161
169
|
const known = [...layouts].sort();
|
|
162
|
-
|
|
170
|
+
// A layout's name is the theme's text: one that is no plain word is never a guess.
|
|
171
|
+
const guess = suggest(scan.layout, known.filter(isPlainWord));
|
|
163
172
|
return [
|
|
164
173
|
diag("DEK019", {
|
|
165
174
|
message: `data-layout "${scan.layout}" is not a layout of the theme`,
|
|
@@ -366,24 +375,22 @@ const EVENT_HANDLER_RE = /^on[a-z]+$/;
|
|
|
366
375
|
/** Tags that load a URL as a document of its own, so an SVG there runs its scripts. */
|
|
367
376
|
const DOCUMENT_TAGS = new Set(["iframe", "frame", "object", "embed"]);
|
|
368
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
|
+
|
|
369
381
|
/**
|
|
370
382
|
* The scheme of a URL that runs script, or undefined: `javascript:` and `vbscript:` always, and a
|
|
371
383
|
* `data:` URL holding HTML, or SVG where a tag loads it as a document. An image's `data:` stays.
|
|
372
|
-
*
|
|
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.
|
|
373
386
|
*/
|
|
374
387
|
function scriptUrlScheme(tag: string, value: string): string | undefined {
|
|
375
|
-
const url = value
|
|
376
|
-
|
|
377
|
-
.
|
|
378
|
-
.toLowerCase();
|
|
379
|
-
if (url.startsWith("javascript:")) {
|
|
380
|
-
return "javascript:";
|
|
381
|
-
}
|
|
382
|
-
if (url.startsWith("vbscript:")) {
|
|
383
|
-
return "vbscript:";
|
|
388
|
+
const url = URL.parse(value, SOME_PAGE);
|
|
389
|
+
if (url?.protocol === "javascript:" || url?.protocol === "vbscript:") {
|
|
390
|
+
return url.protocol;
|
|
384
391
|
}
|
|
385
|
-
if (url
|
|
386
|
-
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();
|
|
387
394
|
const runs =
|
|
388
395
|
type === "text/html" ||
|
|
389
396
|
type === "application/xhtml+xml" ||
|
|
@@ -476,14 +483,12 @@ function unnamedPicture({
|
|
|
476
483
|
}: Picture): { message: string; hint: string; data: Record<string, string> } | undefined {
|
|
477
484
|
const value = (name: string): string | undefined =>
|
|
478
485
|
element.attributes.find((attribute) => attribute.name === name)?.value;
|
|
479
|
-
const named = [value("aria-label"), value("aria-labelledby"), value("title")].some(
|
|
480
|
-
(name) => name !== undefined && name.trim() !== "",
|
|
481
|
-
);
|
|
486
|
+
const named = [value("aria-label"), value("aria-labelledby"), value("title")].some(isName);
|
|
482
487
|
const { tag } = element;
|
|
483
488
|
if (named || (tag === "img" && value("alt") !== undefined)) {
|
|
484
489
|
return undefined;
|
|
485
490
|
}
|
|
486
|
-
if ((value("role")
|
|
491
|
+
if (attributeTokens(value("role"))[0]?.toLowerCase() === "img") {
|
|
487
492
|
if (titled) {
|
|
488
493
|
return undefined;
|
|
489
494
|
}
|
|
@@ -541,7 +546,7 @@ function unknownClassDiagnostics(
|
|
|
541
546
|
: `define it in slides/${section.slug}.css, or use one of: ${shown.join(", ")}${scriptHint}`;
|
|
542
547
|
const unknown = new Map<string, HtmlAttribute>();
|
|
543
548
|
for (const attribute of attributesNamed(scan, "class")) {
|
|
544
|
-
for (const name of attribute.value
|
|
549
|
+
for (const name of attributeTokens(attribute.value)) {
|
|
545
550
|
if (!classes.has(name) && !unknown.has(name)) {
|
|
546
551
|
unknown.set(name, attribute);
|
|
547
552
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
3
|
+
import { shellName } from "../escape.ts";
|
|
3
4
|
import { listSlideFiles } from "../resolve.ts";
|
|
4
5
|
import type { Section } from "../schema.ts";
|
|
5
6
|
import type { SlideScriptProblem } from "../slide-script.ts";
|
|
@@ -54,15 +55,16 @@ export function slideScriptDiagnostics(
|
|
|
54
55
|
|
|
55
56
|
/** DEK016 for a slides/<slug>.js, which dek does not load: motion is written in TypeScript. */
|
|
56
57
|
export function javascriptDiagnostics(ctx: LintContext): Diagnostic[] {
|
|
57
|
-
return listSlideFiles(ctx.deck.dir, ".js").map((file) =>
|
|
58
|
-
|
|
58
|
+
return listSlideFiles(ctx.deck.dir, ".js").map((file) => {
|
|
59
|
+
const [from, to] = [`slides/${file.slug}.js`, `slides/${file.slug}.ts`].map(shellName);
|
|
60
|
+
return diag("DEK016", {
|
|
59
61
|
message: `slide scripts are TypeScript; rename ${file.slug}.js to ${file.slug}.ts`,
|
|
60
62
|
path: file.path,
|
|
61
63
|
slug: file.slug,
|
|
62
|
-
hint: `run \`mv
|
|
64
|
+
hint: `${from && to ? `run \`mv ${from} ${to}\`` : "rename it to .ts"}; plain JavaScript is valid TypeScript`,
|
|
63
65
|
data: { file: `slides/${file.slug}.js` },
|
|
64
|
-
})
|
|
65
|
-
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export type SeekProblem = {
|