@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/tar.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dek's reader for a tar from someone else, a pack or GitHub's tarball of a repository (ADR 27).
|
|
3
|
+
* It reads the tar where it lies in memory, and each file is a copy of the bytes the tar stores
|
|
4
|
+
* for it, so nothing comes out larger than the tar that was counted on the way in. It reads what
|
|
5
|
+
* git, GNU tar, bsdtar, and Bun write: ustar headers, POSIX or GNU, with pax `x` and `g` headers
|
|
6
|
+
* and GNU `L` and `K` long names before an entry. Anything else, a sparse file, a device, a header
|
|
7
|
+
* that does not add up, or a second archive after the first, is refused rather than guessed at.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const BLOCK = 512;
|
|
11
|
+
|
|
12
|
+
// A byte order mark at the start of a name is part of the name, as Bun.Archive kept it.
|
|
13
|
+
const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
14
|
+
|
|
15
|
+
/** The longest name dek reads, from a GNU long-name header or a pax record: PATH_MAX. */
|
|
16
|
+
const MAX_NAME_BYTES = 4096;
|
|
17
|
+
|
|
18
|
+
/** Headers that describe the entry after them, or the whole archive, rather than an entry. */
|
|
19
|
+
const META_TYPES = new Set(["x", "g", "L", "K"]);
|
|
20
|
+
|
|
21
|
+
/** Regular files: `0`, the old `\0`, and `7`, which tar writes and reads as a regular file. */
|
|
22
|
+
const FILE_TYPES = new Set(["0", "\0", "7"]);
|
|
23
|
+
|
|
24
|
+
/** Folders and links, which hold no data; Bun leaves links out, and so does dek. */
|
|
25
|
+
const EMPTY_TYPES = new Set(["5", "1", "2"]);
|
|
26
|
+
|
|
27
|
+
/** pax keys that make an entry a sparse file, whose bytes are not the ones the tar stores. */
|
|
28
|
+
const SPARSE_KEY = /^(?:GNU\.sparse\.|SCHILY\.realsize$|SUN\.holesdata$)/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Why dek will not read a tar: its entries declare more than it may hold, it is not a tar at all,
|
|
32
|
+
* or it `holds` something dek does not read, named so its sender can find it, such as "a sparse
|
|
33
|
+
* file (`t/big`)".
|
|
34
|
+
*/
|
|
35
|
+
export type TarRefusal =
|
|
36
|
+
| { kind: "too large" }
|
|
37
|
+
| { kind: "not a tar" }
|
|
38
|
+
| { kind: "holds"; what: string };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The regular files of `tar` by the path it gives them, or why there are none. It may hold
|
|
42
|
+
* `maxEntries` headers at most, and its entries may declare `maxBytes` in all. The tar must end
|
|
43
|
+
* with a block of zeros, and only zeros may follow it, so a second archive after the first is
|
|
44
|
+
* refused rather than half read. A file whose name's last segment starts with `._`, the
|
|
45
|
+
* AppleDouble file in which macOS's tar keeps another file's extended attributes, is left out,
|
|
46
|
+
* so dek does not read `slides/._intro.html` as a slide.
|
|
47
|
+
*/
|
|
48
|
+
export function readTar(
|
|
49
|
+
tar: Uint8Array<ArrayBuffer>,
|
|
50
|
+
{ maxBytes, maxEntries }: { maxBytes: number; maxEntries: number },
|
|
51
|
+
): Map<string, Blob> | TarRefusal {
|
|
52
|
+
// A first block that is no tar header at all, rather than a damaged one, is not a tar.
|
|
53
|
+
if (tar.byteLength < BLOCK || parseHeader(tar.subarray(0, BLOCK)) === NOT_USTAR) {
|
|
54
|
+
return { kind: "not a tar" };
|
|
55
|
+
}
|
|
56
|
+
const files = new Map<string, Blob>();
|
|
57
|
+
let declared = 0;
|
|
58
|
+
let entries = 0;
|
|
59
|
+
// What the headers before an entry say about it; they apply to that entry and no other.
|
|
60
|
+
let pending: Pending = NOTHING_PENDING;
|
|
61
|
+
let offset = 0;
|
|
62
|
+
while (offset + BLOCK <= tar.byteLength) {
|
|
63
|
+
const block = tar.subarray(offset, offset + BLOCK);
|
|
64
|
+
if (isZero(block)) {
|
|
65
|
+
if (pending !== NOTHING_PENDING) {
|
|
66
|
+
return holds("a pax or long-name header with no entry after it");
|
|
67
|
+
}
|
|
68
|
+
return afterEnd(tar.subarray(offset)) ?? files;
|
|
69
|
+
}
|
|
70
|
+
entries += 1;
|
|
71
|
+
if (entries > maxEntries) {
|
|
72
|
+
return holds(`more than ${maxEntries.toLocaleString("en-US")} entries`);
|
|
73
|
+
}
|
|
74
|
+
const header = parseHeader(block);
|
|
75
|
+
if (typeof header === "string") {
|
|
76
|
+
return holds(header);
|
|
77
|
+
}
|
|
78
|
+
const meta = META_TYPES.has(header.type);
|
|
79
|
+
const paxSize = meta ? undefined : pending.pax?.get("size");
|
|
80
|
+
const size = paxSize === undefined ? header.size : decimal(paxSize);
|
|
81
|
+
// The last of a pax path and a GNU long name names the entry; an empty one leaves the header's.
|
|
82
|
+
const path = meta ? header.name : pending.name || header.name;
|
|
83
|
+
if (size === undefined) {
|
|
84
|
+
return holds(`a pax size that does not read (${shown(path)})`);
|
|
85
|
+
}
|
|
86
|
+
// Short of what it declares before larger than the limit: a kilobyte that declares 200 MB is cut
|
|
87
|
+
// short, not 200 MB.
|
|
88
|
+
const start = offset + BLOCK;
|
|
89
|
+
if (start + size > tar.byteLength) {
|
|
90
|
+
return holds(`an entry cut short (${shown(path)})`);
|
|
91
|
+
}
|
|
92
|
+
declared += size;
|
|
93
|
+
if (declared > maxBytes) {
|
|
94
|
+
return { kind: "too large" };
|
|
95
|
+
}
|
|
96
|
+
const data = tar.subarray(start, start + size);
|
|
97
|
+
offset = start + Math.ceil(size / BLOCK) * BLOCK;
|
|
98
|
+
|
|
99
|
+
if (meta) {
|
|
100
|
+
const read = readMeta(header, data, pending);
|
|
101
|
+
if (typeof read === "string") {
|
|
102
|
+
return holds(read);
|
|
103
|
+
}
|
|
104
|
+
pending = read;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
pending = NOTHING_PENDING;
|
|
108
|
+
if (path.includes("\0")) {
|
|
109
|
+
return holds(`a name with a NUL in it (${shown(path)})`);
|
|
110
|
+
}
|
|
111
|
+
if (FILE_TYPES.has(header.type)) {
|
|
112
|
+
if (path.endsWith("/")) {
|
|
113
|
+
return holds(`a file named like a folder (${shown(path)})`);
|
|
114
|
+
}
|
|
115
|
+
if (!path.slice(path.lastIndexOf("/") + 1).startsWith("._")) {
|
|
116
|
+
files.set(path, new Blob([data]));
|
|
117
|
+
}
|
|
118
|
+
} else if (!EMPTY_TYPES.has(header.type)) {
|
|
119
|
+
return holds(
|
|
120
|
+
`${KIND_OF[header.type] ?? `an entry of type ${shown(header.type)}`} (${shown(path)})`,
|
|
121
|
+
);
|
|
122
|
+
} else if (size !== 0) {
|
|
123
|
+
return holds(
|
|
124
|
+
`${header.type === "5" ? "a folder" : "a link"} that declares data (${shown(path)})`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return holds("a tar with no end");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The entry types dek names when it refuses one; any other is named by its type. */
|
|
132
|
+
const KIND_OF: Record<string, string> = {
|
|
133
|
+
S: "a sparse file",
|
|
134
|
+
"3": "a device",
|
|
135
|
+
"4": "a device",
|
|
136
|
+
"6": "a FIFO",
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function holds(what: string): TarRefusal {
|
|
140
|
+
return { kind: "holds", what };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What follows the end of the tar, when it is not only zeros: another archive, or other data. */
|
|
144
|
+
function afterEnd(rest: Uint8Array): TarRefusal | undefined {
|
|
145
|
+
const start = rest.findIndex((byte) => byte !== 0);
|
|
146
|
+
if (start < 0) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const block = rest.subarray(start - (start % BLOCK), start - (start % BLOCK) + BLOCK);
|
|
150
|
+
return holds(
|
|
151
|
+
typeof parseHeader(block) === "string"
|
|
152
|
+
? "data after the end of the tar"
|
|
153
|
+
: "a second archive after the first",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A name from the tar as dek may print it: on one line, with no control or format character, such
|
|
159
|
+
* as one that moves the cursor or turns text around, no longer than 80 characters, in backticks.
|
|
160
|
+
* It is cut before anything else, so a long name costs no more than a short one.
|
|
161
|
+
*/
|
|
162
|
+
function shown(name: string): string {
|
|
163
|
+
const head = Array.from(name.slice(0, 200));
|
|
164
|
+
const cut = head.length > 80 ? `${head.slice(0, 79).join("")}…` : head.join("");
|
|
165
|
+
return `\`${cut.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}`]/gu, "\uFFFD")}\``;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** What the pax and GNU headers before an entry said: its pax records, its name, which were seen. */
|
|
169
|
+
type Pending = {
|
|
170
|
+
pax?: Map<string, string>;
|
|
171
|
+
name?: string;
|
|
172
|
+
longName?: true;
|
|
173
|
+
longLink?: true;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const NOTHING_PENDING: Pending = {};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* What a pax or GNU header adds to what is pending for the next entry, or what is wrong with it:
|
|
180
|
+
* it does not read, names a path too long or repeats one already pending, or marks a sparse file.
|
|
181
|
+
* A global `g` header applies to nothing dek reads; it may come only between entries.
|
|
182
|
+
*/
|
|
183
|
+
function readMeta(header: Header, data: Uint8Array, pending: Pending): Pending | string {
|
|
184
|
+
const twice = `two pax or long-name headers before one entry (${shown(header.name)})`;
|
|
185
|
+
if (header.type === "L" || header.type === "K") {
|
|
186
|
+
const end = data.indexOf(0);
|
|
187
|
+
if ((end < 0 ? data.byteLength : end) > MAX_NAME_BYTES) {
|
|
188
|
+
return tooLong(data);
|
|
189
|
+
}
|
|
190
|
+
if (header.type === "K") {
|
|
191
|
+
return pending.longLink ? twice : { ...pending, longLink: true };
|
|
192
|
+
}
|
|
193
|
+
const name = text(data);
|
|
194
|
+
return pending.longName ? twice : { ...pending, longName: true, name: name || pending.name };
|
|
195
|
+
}
|
|
196
|
+
const records = paxRecords(data);
|
|
197
|
+
if (typeof records === "string") {
|
|
198
|
+
return records || `pax records that do not read (${shown(header.name)})`;
|
|
199
|
+
}
|
|
200
|
+
if ([...records.keys()].some((key) => SPARSE_KEY.test(key))) {
|
|
201
|
+
const name = records.get("GNU.sparse.name") ?? records.get("path") ?? header.name;
|
|
202
|
+
return `a sparse file (${shown(name)})`;
|
|
203
|
+
}
|
|
204
|
+
if (header.type === "g") {
|
|
205
|
+
return pending === NOTHING_PENDING
|
|
206
|
+
? pending
|
|
207
|
+
: "a pax global header between a header and its entry";
|
|
208
|
+
}
|
|
209
|
+
if (pending.pax) {
|
|
210
|
+
return twice;
|
|
211
|
+
}
|
|
212
|
+
return { ...pending, pax: records, name: records.get("path") || pending.name };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A name longer than dek reads, shown by its start. */
|
|
216
|
+
function tooLong(bytes: Uint8Array): string {
|
|
217
|
+
return `a name longer than ${MAX_NAME_BYTES} bytes (${shown(decoder.decode(bytes.subarray(0, 400)))})`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
type Header = { type: string; name: string; size: number };
|
|
221
|
+
|
|
222
|
+
const NOT_USTAR = "a header that is neither ustar nor GNU tar";
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A header block, or what is wrong with it, unless it is a ustar header as POSIX (`ustar\0`
|
|
226
|
+
* `00`) or GNU (`ustar \0`) writes it, with a checksum that adds up and a size in octal. A
|
|
227
|
+
* POSIX header's prefix goes before its name; a GNU header keeps other fields there.
|
|
228
|
+
*/
|
|
229
|
+
function parseHeader(block: Uint8Array): Header | string {
|
|
230
|
+
const magic = decoder.decode(block.subarray(257, 265));
|
|
231
|
+
const posix = magic === "ustar\u000000";
|
|
232
|
+
if (!posix && magic !== "ustar \u0000" && !isZero(block)) {
|
|
233
|
+
return NOT_USTAR;
|
|
234
|
+
}
|
|
235
|
+
const name = text(block.subarray(0, 100));
|
|
236
|
+
const prefix = posix ? text(block.subarray(345, 500)) : "";
|
|
237
|
+
const path = prefix === "" ? name : `${prefix}/${name}`;
|
|
238
|
+
if (!checksumHolds(block)) {
|
|
239
|
+
return `a header whose checksum does not add up (${shown(path)})`;
|
|
240
|
+
}
|
|
241
|
+
const size = octal(block.subarray(124, 136));
|
|
242
|
+
if (size === undefined) {
|
|
243
|
+
return `a header whose size does not read (${shown(path)})`;
|
|
244
|
+
}
|
|
245
|
+
return { type: String.fromCharCode(block[156] ?? 0), name: path, size };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** An octal field: digits, after spaces if any, then only NULs or spaces. */
|
|
249
|
+
function octal(field: Uint8Array): number | undefined {
|
|
250
|
+
const digits = /^ *([0-7]+)[ \0]*$/.exec(decoder.decode(field))?.[1];
|
|
251
|
+
return digits === undefined ? undefined : Number.parseInt(digits, 8);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A pax number: decimal digits and nothing else. */
|
|
255
|
+
function decimal(value: string): number | undefined {
|
|
256
|
+
return /^\d+$/.test(value) ? Number(value) : undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The header's checksum against its bytes, summed as unsigned or as signed, as tar allows. */
|
|
260
|
+
function checksumHolds(block: Uint8Array): boolean {
|
|
261
|
+
const stored = octal(block.subarray(148, 156));
|
|
262
|
+
let unsigned = 0;
|
|
263
|
+
let signed = 0;
|
|
264
|
+
for (let index = 0; index < BLOCK; index++) {
|
|
265
|
+
const value = index >= 148 && index < 156 ? 0x20 : (block[index] ?? 0);
|
|
266
|
+
unsigned += value;
|
|
267
|
+
signed += value > 127 ? value - 256 : value;
|
|
268
|
+
}
|
|
269
|
+
return stored === unsigned || stored === signed;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The pax records that name a path, which may be `MAX_NAME_BYTES` long at most. */
|
|
273
|
+
const NAME_KEYS = new Set(["path", "linkpath"]);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* pax records, `<length> <key>=<value>\n` each with the length in decimal digits, filling `data`
|
|
277
|
+
* exactly, by key; otherwise what is wrong, a path too long or, as "", a record that does not read.
|
|
278
|
+
*/
|
|
279
|
+
function paxRecords(data: Uint8Array): Map<string, string> | string {
|
|
280
|
+
const records = new Map<string, string>();
|
|
281
|
+
let offset = 0;
|
|
282
|
+
while (offset < data.byteLength) {
|
|
283
|
+
const space = data.indexOf(0x20, offset);
|
|
284
|
+
const digits = space < 0 ? "" : decoder.decode(data.subarray(offset, space));
|
|
285
|
+
if (!/^[1-9]\d*$/.test(digits)) {
|
|
286
|
+
return "";
|
|
287
|
+
}
|
|
288
|
+
const end = offset + Number(digits);
|
|
289
|
+
if (end <= space + 1 || end > data.byteLength || data[end - 1] !== 0x0a) {
|
|
290
|
+
return "";
|
|
291
|
+
}
|
|
292
|
+
const equals = data.subarray(0, end - 1).indexOf(0x3d, space + 1);
|
|
293
|
+
if (equals <= space + 1) {
|
|
294
|
+
return "";
|
|
295
|
+
}
|
|
296
|
+
const key = decoder.decode(data.subarray(space + 1, equals));
|
|
297
|
+
const value = data.subarray(equals + 1, end - 1);
|
|
298
|
+
if (NAME_KEYS.has(key) && value.byteLength > MAX_NAME_BYTES) {
|
|
299
|
+
return tooLong(value);
|
|
300
|
+
}
|
|
301
|
+
records.set(key, decoder.decode(value));
|
|
302
|
+
offset = end;
|
|
303
|
+
}
|
|
304
|
+
return records;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** A field's text up to its first NUL. */
|
|
308
|
+
function text(field: Uint8Array): string {
|
|
309
|
+
const end = field.indexOf(0);
|
|
310
|
+
return decoder.decode(end < 0 ? field : field.subarray(0, end));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isZero(bytes: Uint8Array): boolean {
|
|
314
|
+
return bytes.every((byte) => byte === 0);
|
|
315
|
+
}
|
|
@@ -255,6 +255,7 @@ async function sampleTextContrasts({
|
|
|
255
255
|
texts,
|
|
256
256
|
}: SamplerInput): Promise<Array<TextContrast | null>> {
|
|
257
257
|
const decode = async (base64: string): Promise<Pixels> => {
|
|
258
|
+
// biome-ignore lint/style/noRestrictedGlobals: it runs in the page and decodes a data: URL, which reaches no network.
|
|
258
259
|
const blob = await (await fetch(`data:image/png;base64,${base64}`)).blob();
|
|
259
260
|
const bitmap = await createImageBitmap(blob);
|
|
260
261
|
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
@@ -419,6 +420,7 @@ ${
|
|
|
419
420
|
}, GLYPHS_MARK);
|
|
420
421
|
const found = await page.evaluate(async (input) => {
|
|
421
422
|
const decode = async (base64: string) => {
|
|
423
|
+
// biome-ignore lint/style/noRestrictedGlobals: it runs in the page and decodes a data: URL, which reaches no network.
|
|
422
424
|
const blob = await (await fetch(`data:image/png;base64,${base64}`)).blob();
|
|
423
425
|
const bitmap = await createImageBitmap(blob);
|
|
424
426
|
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
package/src/core/toml-keys.ts
CHANGED
|
@@ -17,7 +17,8 @@ export type TomlKey = {
|
|
|
17
17
|
/**
|
|
18
18
|
* Every key and table header in a TOML file, in order, with where each is written: the one reader
|
|
19
19
|
* dek.toml lint, `dekc mv`, and `dekc ref` share. It reads `[table]`, `[[array]]`, (dotted) keys, and
|
|
20
|
-
*
|
|
20
|
+
* inline tables, and steps over comments, multi-line strings, and arrays and inline tables written
|
|
21
|
+
* over several lines, as Bun's TOML parser reads them.
|
|
21
22
|
*/
|
|
22
23
|
export function scanTomlKeys(source: string): TomlKey[] {
|
|
23
24
|
const lines = source.split("\n");
|
|
@@ -38,10 +39,7 @@ export function scanTomlKeys(source: string): TomlKey[] {
|
|
|
38
39
|
}
|
|
39
40
|
const path = [...table, ...names(assignment.segments)];
|
|
40
41
|
keys.push(keyAt("key", path, assignment.segments, index));
|
|
41
|
-
|
|
42
|
-
inlineKeys(line, assignment.value, path, index, keys);
|
|
43
|
-
}
|
|
44
|
-
index = valueLastLine(lines, index, assignment.value);
|
|
42
|
+
index = readValue(lines, { index, at: assignment.value }, path, keys).index;
|
|
45
43
|
}
|
|
46
44
|
return keys;
|
|
47
45
|
}
|
|
@@ -82,64 +80,131 @@ function readAssignment(
|
|
|
82
80
|
return { segments: path.segments, value: skipSpace(line, path.end + 1) };
|
|
83
81
|
}
|
|
84
82
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
83
|
+
/** A place in a file split into lines: the line's index, and the character's on it. */
|
|
84
|
+
type Cursor = { index: number; at: number };
|
|
85
|
+
|
|
86
|
+
const VALUE_ENDS = new Set([",", "#", "]", "}"]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The place past the value at `from`: a string, multi-line or not, an array, an inline table, or
|
|
90
|
+
* a bare word. An inline table's keys are recorded under `path`, nested ones too; one in an
|
|
91
|
+
* array is not, as no key path names it.
|
|
92
|
+
*/
|
|
93
|
+
function readValue(
|
|
94
|
+
lines: string[],
|
|
95
|
+
from: Cursor,
|
|
96
|
+
path: string[] | undefined,
|
|
91
97
|
keys: TomlKey[],
|
|
92
|
-
):
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
): Cursor {
|
|
99
|
+
const line = lines[from.index] ?? "";
|
|
100
|
+
const ch = line[from.at];
|
|
101
|
+
const delimiter = tripleQuote(line, from.at);
|
|
102
|
+
if (delimiter) {
|
|
103
|
+
return skipMultilineString(lines, from, delimiter);
|
|
104
|
+
}
|
|
105
|
+
if (isQuote(ch)) {
|
|
106
|
+
return { index: from.index, at: Math.min(skipString(line, from.at), lineEnd(line)) };
|
|
107
|
+
}
|
|
108
|
+
if (ch === "[" || ch === "{") {
|
|
109
|
+
return readItems(lines, { index: from.index, at: from.at + 1 }, ch, path, keys);
|
|
110
|
+
}
|
|
111
|
+
let at = from.at;
|
|
112
|
+
while (at < lineEnd(line) && !VALUE_ENDS.has(line[at] ?? "")) {
|
|
113
|
+
at++;
|
|
114
|
+
}
|
|
115
|
+
return { index: from.index, at };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The place past the array or inline table whose items start at `from`, which may run on over
|
|
120
|
+
* lines. A table's keys are recorded under `path`. One that breaks off, as at a key with no comma
|
|
121
|
+
* before it, ends with the line it broke off on, so what follows is read as the file's own.
|
|
122
|
+
*/
|
|
123
|
+
function readItems(
|
|
124
|
+
lines: string[],
|
|
125
|
+
from: Cursor,
|
|
126
|
+
open: "[" | "{",
|
|
127
|
+
path: string[] | undefined,
|
|
128
|
+
keys: TomlKey[],
|
|
129
|
+
): Cursor {
|
|
130
|
+
const close = open === "[" ? "]" : "}";
|
|
131
|
+
let last = from;
|
|
132
|
+
let next = skipBlank(lines, from);
|
|
133
|
+
for (;;) {
|
|
134
|
+
const line = lines[next.index] ?? "";
|
|
135
|
+
const ch = line[next.at];
|
|
136
|
+
if (ch === close) {
|
|
137
|
+
return { index: next.index, at: next.at + 1 };
|
|
138
|
+
}
|
|
139
|
+
if (ch === undefined || next.at >= lineEnd(line)) {
|
|
140
|
+
return next;
|
|
98
141
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
142
|
+
let valueAt = next;
|
|
143
|
+
let itemPath: string[] | undefined;
|
|
144
|
+
if (open === "{") {
|
|
145
|
+
const assignment = readAssignment(line, next.at);
|
|
146
|
+
if (!assignment) {
|
|
147
|
+
return { index: last.index, at: lineEnd(lines[last.index] ?? "") };
|
|
148
|
+
}
|
|
149
|
+
itemPath = path && [...path, ...names(assignment.segments)];
|
|
150
|
+
if (itemPath) {
|
|
151
|
+
keys.push(keyAt("inline", itemPath, assignment.segments, next.index));
|
|
152
|
+
}
|
|
153
|
+
valueAt = { index: next.index, at: assignment.value };
|
|
154
|
+
}
|
|
155
|
+
last = readValue(lines, valueAt, itemPath, keys);
|
|
156
|
+
next = skipBlank(lines, last);
|
|
157
|
+
const after = lines[next.index]?.[next.at];
|
|
158
|
+
if (after === ",") {
|
|
159
|
+
last = { index: next.index, at: next.at + 1 };
|
|
160
|
+
next = skipBlank(lines, last);
|
|
161
|
+
} else if (after !== close) {
|
|
162
|
+
return { index: last.index, at: lineEnd(lines[last.index] ?? "") };
|
|
108
163
|
}
|
|
109
164
|
}
|
|
110
|
-
return i + 1;
|
|
111
165
|
}
|
|
112
166
|
|
|
113
|
-
|
|
114
|
-
|
|
167
|
+
/** The place past the spaces, comments, and line breaks at `from`; the end of the file at most. */
|
|
168
|
+
function skipBlank(lines: string[], from: Cursor): Cursor {
|
|
169
|
+
let { index, at } = from;
|
|
170
|
+
for (;;) {
|
|
171
|
+
const line = lines[index] ?? "";
|
|
172
|
+
at = skipSpace(line, at);
|
|
173
|
+
if ((at < lineEnd(line) && line[at] !== "#") || index >= lines.length - 1) {
|
|
174
|
+
return { index, at };
|
|
175
|
+
}
|
|
176
|
+
index++;
|
|
177
|
+
at = 0;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
115
180
|
|
|
116
|
-
/** The
|
|
117
|
-
function
|
|
118
|
-
let
|
|
119
|
-
let
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
|
|
181
|
+
/** The place past the multi-line string opening at `from`; an unclosed one runs to the end. */
|
|
182
|
+
function skipMultilineString(lines: string[], from: Cursor, delimiter: string): Cursor {
|
|
183
|
+
let at = from.at + 3;
|
|
184
|
+
for (let index = from.index; index < lines.length; index++, at = 0) {
|
|
185
|
+
const line = lines[index] ?? "";
|
|
186
|
+
const close = line.indexOf(delimiter, at);
|
|
187
|
+
if (close !== -1) {
|
|
188
|
+
// Up to two quotes of the string's own may come right before its closing three.
|
|
189
|
+
let end = close + 3;
|
|
190
|
+
while (end < close + 5 && line[end] === delimiter[0]) {
|
|
191
|
+
end++;
|
|
192
|
+
}
|
|
193
|
+
return { index, at: end };
|
|
124
194
|
}
|
|
125
|
-
depth += NESTING[ch] ?? 0;
|
|
126
|
-
i = isQuote(ch) ? skipString(line, i) : i + 1;
|
|
127
195
|
}
|
|
128
|
-
|
|
196
|
+
const index = lines.length - 1;
|
|
197
|
+
return { index, at: lineEnd(lines[index] ?? "") };
|
|
129
198
|
}
|
|
130
199
|
|
|
131
|
-
/**
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
200
|
+
/** Where a line's text ends, before the CR of a CRLF. */
|
|
201
|
+
function lineEnd(line: string): number {
|
|
202
|
+
return line.endsWith("\r") ? line.length - 1 : line.length;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The line the value starting at `start` ends on, a later one when it runs on over lines. */
|
|
135
206
|
function valueLastLine(lines: string[], index: number, start: number): number {
|
|
136
|
-
|
|
137
|
-
const delimiter = tripleQuote(line, start);
|
|
138
|
-
if (!delimiter || line.includes(delimiter, start + 3)) {
|
|
139
|
-
return index;
|
|
140
|
-
}
|
|
141
|
-
const close = lines.findIndex((next, i) => i > index && next.includes(delimiter));
|
|
142
|
-
return close === -1 ? lines.length - 1 : close;
|
|
207
|
+
return readValue(lines, { index, at: start }, undefined, []).index;
|
|
143
208
|
}
|
|
144
209
|
|
|
145
210
|
/** Reads `a . "b" . 'c'` from `from`; `end` is the first non-space after it. */
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Which attributes name a URL, and where in the value each URL is written. Lint checks exactly
|
|
3
3
|
* these, and the build inlines exactly these, so a reference that passes one never breaks the other.
|
|
4
4
|
*/
|
|
5
|
+
import { isAsciiWhitespace } from "./attribute-value.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* What the page does with a URL: a `resource` is loaded to draw the slide, so it must exist and
|
|
@@ -46,18 +47,18 @@ function urlAttribute(tag: string, attr: string): (typeof URL_ATTRIBUTES)[number
|
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
49
|
* The URLs of a `srcset`, each with its offset in the value, parsed the way the HTML standard
|
|
49
|
-
* does: a URL runs to the next whitespace, a trailing comma ends it, and the descriptors
|
|
50
|
-
* run to the next comma. So `data:` URIs keep their commas.
|
|
50
|
+
* does: a URL runs to the next ASCII whitespace, a trailing comma ends it, and the descriptors
|
|
51
|
+
* after it run to the next comma. So `data:` URIs keep their commas.
|
|
51
52
|
*/
|
|
52
53
|
export function srcsetUrls(value: string): Array<{ url: string; offset: number }> {
|
|
53
54
|
const candidates: Array<{ url: string; offset: number }> = [];
|
|
54
55
|
let index = 0;
|
|
55
56
|
while (index < value.length) {
|
|
56
|
-
while (index < value.length &&
|
|
57
|
+
while (index < value.length && (value[index] === "," || isAsciiWhitespace(value[index]))) {
|
|
57
58
|
index++;
|
|
58
59
|
}
|
|
59
60
|
const start = index;
|
|
60
|
-
while (index < value.length &&
|
|
61
|
+
while (index < value.length && !isAsciiWhitespace(value[index])) {
|
|
61
62
|
index++;
|
|
62
63
|
}
|
|
63
64
|
let url = value.slice(start, index);
|
|
@@ -90,14 +91,31 @@ export function attributeUrls(
|
|
|
90
91
|
value: string,
|
|
91
92
|
): { use: UrlUse; urls: Array<{ url: string; offset: number }> } | undefined {
|
|
92
93
|
const entry = urlAttribute(tag, attr);
|
|
93
|
-
const
|
|
94
|
-
if (!entry ||
|
|
94
|
+
const { start, end } = urlBounds(value);
|
|
95
|
+
if (!entry || start === end) {
|
|
95
96
|
return undefined;
|
|
96
97
|
}
|
|
97
98
|
return {
|
|
98
99
|
use: entry.use,
|
|
99
|
-
urls: entry.list
|
|
100
|
-
? srcsetUrls(value)
|
|
101
|
-
: [{ url, offset: value.length - value.trimStart().length }],
|
|
100
|
+
urls: entry.list ? srcsetUrls(value) : [{ url: value.slice(start, end), offset: start }],
|
|
102
101
|
};
|
|
103
102
|
}
|
|
103
|
+
|
|
104
|
+
/** A URL without the C0 controls and spaces at its ends, which the URL parser strips. */
|
|
105
|
+
export function trimUrl(url: string): string {
|
|
106
|
+
const { start, end } = urlBounds(url);
|
|
107
|
+
return url.slice(start, end);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Where `value` starts and ends past the C0 controls and spaces; a no-break space stays. */
|
|
111
|
+
function urlBounds(value: string): { start: number; end: number } {
|
|
112
|
+
let start = 0;
|
|
113
|
+
let end = value.length;
|
|
114
|
+
while (start < end && value.charCodeAt(start) <= 0x20) {
|
|
115
|
+
start++;
|
|
116
|
+
}
|
|
117
|
+
while (end > start && value.charCodeAt(end - 1) <= 0x20) {
|
|
118
|
+
end--;
|
|
119
|
+
}
|
|
120
|
+
return { start, end };
|
|
121
|
+
}
|
package/src/core/visual.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { EDGES, type Edge } from "./overflow.ts";
|
|
|
4
4
|
import { renderPdfHtml } from "./pdf.ts";
|
|
5
5
|
import {
|
|
6
6
|
askPlaywright,
|
|
7
|
-
type ContrastOrigin,
|
|
8
7
|
type FillFinding,
|
|
9
8
|
type PageAction,
|
|
10
9
|
type PagesResponse,
|
|
@@ -315,9 +314,14 @@ function groupBySteps<T extends { step: string }>(items: T[], key: (item: T) =>
|
|
|
315
314
|
/**
|
|
316
315
|
* Where to fix low contrast. Text the theme alone draws too faint is fixed once in theme.css
|
|
317
316
|
* for every slide that uses the pair; a slide that fixed it in its own CSS would leave the rest.
|
|
317
|
+
* Text only the slide's CSS shows was never measured on the theme alone, so both are named.
|
|
318
318
|
*/
|
|
319
|
-
function contrastHint(
|
|
319
|
+
function contrastHint(sample: ContrastSample, threshold: number): string {
|
|
320
|
+
const { origin, slug } = sample;
|
|
320
321
|
const own = `slides/${slug}.css`;
|
|
322
|
+
if (sample.onlyWithSlideCss) {
|
|
323
|
+
return `without ${own} it does not show, so dek cannot tell whether theme.css alone draws it below ${threshold}:1: fix the pair in ${own}, or in theme.css if the colors are the theme's`;
|
|
324
|
+
}
|
|
321
325
|
switch (origin) {
|
|
322
326
|
case "theme":
|
|
323
327
|
return `theme.css alone draws it below ${threshold}:1: fix the pair in theme.css, where one change reaches every slide that uses it`;
|
|
@@ -333,8 +337,17 @@ function contrastHint(origin: ContrastOrigin | undefined, threshold: number, slu
|
|
|
333
337
|
}
|
|
334
338
|
}
|
|
335
339
|
|
|
336
|
-
/**
|
|
337
|
-
|
|
340
|
+
/**
|
|
341
|
+
* The file to change: the one the origin names, or the slide itself when it is not known. Text
|
|
342
|
+
* only the slide's CSS shows goes to that CSS, which the hint names first.
|
|
343
|
+
*/
|
|
344
|
+
function contrastPath(
|
|
345
|
+
{ origin, slug, onlyWithSlideCss }: ContrastSample,
|
|
346
|
+
paths: DeckPaths,
|
|
347
|
+
): string {
|
|
348
|
+
if (onlyWithSlideCss) {
|
|
349
|
+
return paths.slide(slug, ".css");
|
|
350
|
+
}
|
|
338
351
|
switch (origin) {
|
|
339
352
|
case "theme":
|
|
340
353
|
return paths.theme;
|
|
@@ -583,9 +596,15 @@ function shownRatio(sample: ContrastSample): number {
|
|
|
583
596
|
}
|
|
584
597
|
|
|
585
598
|
function contrastKey(c: ContrastSample): string {
|
|
586
|
-
return [
|
|
587
|
-
|
|
588
|
-
|
|
599
|
+
return [
|
|
600
|
+
c.slug,
|
|
601
|
+
c.box,
|
|
602
|
+
c.text ?? "",
|
|
603
|
+
shownRatio(c),
|
|
604
|
+
contrastThreshold(c),
|
|
605
|
+
c.origin ?? "",
|
|
606
|
+
c.onlyWithSlideCss ?? "",
|
|
607
|
+
].join("\0");
|
|
589
608
|
}
|
|
590
609
|
|
|
591
610
|
function overflowWhere(amounts: Partial<Record<Edge, number>>): string {
|
|
@@ -681,9 +700,9 @@ function contrastDiagnostic(
|
|
|
681
700
|
const colors = fg && bg ? ` (${fg} on ${bg})` : "";
|
|
682
701
|
return diag("DEK031", {
|
|
683
702
|
message: `${describeTarget(first.box, first.text)}has contrast ${ratio}${colors}, below ${threshold}:1${size} ${atSteps(steps)}`,
|
|
684
|
-
path: contrastPath(first
|
|
703
|
+
path: contrastPath(first, paths),
|
|
685
704
|
slug: first.slug,
|
|
686
|
-
hint: contrastHint(first
|
|
705
|
+
hint: contrastHint(first, threshold),
|
|
687
706
|
data: {
|
|
688
707
|
box: first.box,
|
|
689
708
|
...(first.text ? { text: first.text } : {}),
|