@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,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An attribute's value as the browser reads it. Bun's HTMLRewriter gives each value as written,
|
|
3
|
+
* character references and all, so `assets/Q&A.png` would name a file the page never loads.
|
|
4
|
+
* Every value lint or the build reads off the rewriter goes through here first; a value written
|
|
5
|
+
* back into the markup is written from the one as written, never from this.
|
|
6
|
+
*/
|
|
7
|
+
import { LEGACY_REFERENCES, NAMED_REFERENCES } from "./html-entities.ts";
|
|
8
|
+
|
|
9
|
+
/** A decoded value, and where in the value as written each of its UTF-16 units comes from. */
|
|
10
|
+
export type DecodedAttribute = {
|
|
11
|
+
value: string;
|
|
12
|
+
/** The offset in the written value of the unit at `index`; `value.length` maps past the end. */
|
|
13
|
+
writtenAt(index: number): number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The value of `name` on a rewriter's element as the browser reads it, or null when absent. */
|
|
17
|
+
export function attributeOf(
|
|
18
|
+
element: { getAttribute(name: string): string | null },
|
|
19
|
+
name: string,
|
|
20
|
+
): string | null {
|
|
21
|
+
const written = element.getAttribute(name);
|
|
22
|
+
return written === null ? null : decodeAttribute(written).value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether `char` is ASCII whitespace, the only whitespace HTML splits a list of tokens on or trims
|
|
27
|
+
* a value of. A no-break space, written as itself or as ` `, is part of the value.
|
|
28
|
+
*/
|
|
29
|
+
export function isAsciiWhitespace(char: string | undefined): boolean {
|
|
30
|
+
return char === " " || char === "\t" || char === "\n" || char === "\f" || char === "\r";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The tokens of a value such as `class` or `role`, split on ASCII whitespace as HTML splits it. */
|
|
34
|
+
export function attributeTokens(value: string | null | undefined): string[] {
|
|
35
|
+
return (value ?? "").split(/[\t\n\f\r ]+/).filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `value` without the ASCII whitespace at its ends, as HTML trims a keyword or a name. */
|
|
39
|
+
export function trimAsciiWhitespace(value: string): string {
|
|
40
|
+
return value.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `written` read the way the HTML standard reads an attribute value: every numeric and named
|
|
45
|
+
* character reference decoded, a carriage return read as a line feed, and a NUL as U+FFFD. A
|
|
46
|
+
* name the standard allows without its `;` stays as written when a letter, a digit, or `=`
|
|
47
|
+
* follows it, so the `©` of `?a=1©=2` is left alone, as the browser leaves it.
|
|
48
|
+
*/
|
|
49
|
+
export function decodeAttribute(written: string): DecodedAttribute {
|
|
50
|
+
// Each replacement, where it is in the decoded value and where in the written one, in order.
|
|
51
|
+
const spans: Array<{ start: number; end: number; writtenStart: number; writtenEnd: number }> = [];
|
|
52
|
+
let shift = 0;
|
|
53
|
+
const value = written.replace(
|
|
54
|
+
REFERENCE_RE,
|
|
55
|
+
(match: string, hex?: string, decimal?: string, name?: string, semicolon?: string, at = 0) => {
|
|
56
|
+
const text = replacement(written, match, at, { hex, decimal, name, semicolon });
|
|
57
|
+
if (text !== match) {
|
|
58
|
+
const start = at - shift;
|
|
59
|
+
spans.push({
|
|
60
|
+
start,
|
|
61
|
+
end: start + text.length,
|
|
62
|
+
writtenStart: at,
|
|
63
|
+
writtenEnd: at + match.length,
|
|
64
|
+
});
|
|
65
|
+
shift += match.length - text.length;
|
|
66
|
+
}
|
|
67
|
+
return text;
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
return {
|
|
71
|
+
value,
|
|
72
|
+
writtenAt(index) {
|
|
73
|
+
let offset = index;
|
|
74
|
+
for (const span of spans) {
|
|
75
|
+
if (index < span.start) {
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
if (index < span.end) {
|
|
79
|
+
return span.writtenStart;
|
|
80
|
+
}
|
|
81
|
+
offset = span.writtenEnd + (index - span.end);
|
|
82
|
+
}
|
|
83
|
+
return offset;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A character reference, with its parts as groups; a carriage return, which the input stream reads
|
|
90
|
+
* as a line feed; or a NUL, which the tokenizer reads as U+FFFD in a value.
|
|
91
|
+
*/
|
|
92
|
+
const REFERENCE_RE = /&(?:#(?:[xX]([0-9a-fA-F]+)|([0-9]+));?|([A-Za-z0-9]+)(;?))|\r\n?|\0/g;
|
|
93
|
+
|
|
94
|
+
/** What one match of REFERENCE_RE reads as: itself, when the standard leaves it as written. */
|
|
95
|
+
function replacement(
|
|
96
|
+
written: string,
|
|
97
|
+
match: string,
|
|
98
|
+
at: number,
|
|
99
|
+
parts: { hex?: string; decimal?: string; name?: string; semicolon?: string },
|
|
100
|
+
): string {
|
|
101
|
+
if (match === "\0") {
|
|
102
|
+
return "\uFFFD";
|
|
103
|
+
}
|
|
104
|
+
if (match.startsWith("\r")) {
|
|
105
|
+
return "\n";
|
|
106
|
+
}
|
|
107
|
+
if (parts.hex !== undefined || parts.decimal !== undefined) {
|
|
108
|
+
return numericReference(
|
|
109
|
+
parts.hex === undefined
|
|
110
|
+
? Number.parseInt(parts.decimal ?? "", 10)
|
|
111
|
+
: Number.parseInt(parts.hex, 16),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const name = parts.name ?? "";
|
|
115
|
+
if (parts.semicolon) {
|
|
116
|
+
return namedReferences().get(name) ?? match;
|
|
117
|
+
}
|
|
118
|
+
// The browser takes the longest name it knows. A legacy name shorter than the run of letters
|
|
119
|
+
// and digits is followed by one of them, which keeps it as written, so only the whole run can.
|
|
120
|
+
if (legacyReferences().has(name) && written[at + match.length] !== "=") {
|
|
121
|
+
return namedReferences().get(name) ?? match;
|
|
122
|
+
}
|
|
123
|
+
return match;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The character a numeric reference stands for: U+FFFD for NUL, a surrogate, or a number past
|
|
128
|
+
* Unicode, and for 0x80 to 0x9F the Windows-1252 character the standard reads it as.
|
|
129
|
+
*/
|
|
130
|
+
function numericReference(number: number): string {
|
|
131
|
+
if (number === 0 || number > 0x10ffff || (number >= 0xd800 && number <= 0xdfff)) {
|
|
132
|
+
return "\uFFFD";
|
|
133
|
+
}
|
|
134
|
+
return String.fromCodePoint(C1_REPLACEMENTS.get(number) ?? number);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The HTML standard's table for a numeric reference to a C1 control. */
|
|
138
|
+
const C1_REPLACEMENTS: ReadonlyMap<number, number> = new Map([
|
|
139
|
+
[0x80, 0x20ac],
|
|
140
|
+
[0x82, 0x201a],
|
|
141
|
+
[0x83, 0x0192],
|
|
142
|
+
[0x84, 0x201e],
|
|
143
|
+
[0x85, 0x2026],
|
|
144
|
+
[0x86, 0x2020],
|
|
145
|
+
[0x87, 0x2021],
|
|
146
|
+
[0x88, 0x02c6],
|
|
147
|
+
[0x89, 0x2030],
|
|
148
|
+
[0x8a, 0x0160],
|
|
149
|
+
[0x8b, 0x2039],
|
|
150
|
+
[0x8c, 0x0152],
|
|
151
|
+
[0x8e, 0x017d],
|
|
152
|
+
[0x91, 0x2018],
|
|
153
|
+
[0x92, 0x2019],
|
|
154
|
+
[0x93, 0x201c],
|
|
155
|
+
[0x94, 0x201d],
|
|
156
|
+
[0x95, 0x2022],
|
|
157
|
+
[0x96, 0x2013],
|
|
158
|
+
[0x97, 0x2014],
|
|
159
|
+
[0x98, 0x02dc],
|
|
160
|
+
[0x99, 0x2122],
|
|
161
|
+
[0x9a, 0x0161],
|
|
162
|
+
[0x9b, 0x203a],
|
|
163
|
+
[0x9c, 0x0153],
|
|
164
|
+
[0x9e, 0x017e],
|
|
165
|
+
[0x9f, 0x0178],
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
let named: Map<string, string> | undefined;
|
|
169
|
+
let legacy: Set<string> | undefined;
|
|
170
|
+
|
|
171
|
+
/** Each named reference, without its `;`, and the characters it stands for; read once. */
|
|
172
|
+
function namedReferences(): Map<string, string> {
|
|
173
|
+
named ??= new Map(
|
|
174
|
+
NAMED_REFERENCES.split(" ").map((entry) => {
|
|
175
|
+
const [name = "", points = ""] = entry.split(":");
|
|
176
|
+
return [
|
|
177
|
+
name,
|
|
178
|
+
String.fromCodePoint(...points.split("+").map((point) => Number.parseInt(point, 16))),
|
|
179
|
+
];
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
return named;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function legacyReferences(): Set<string> {
|
|
186
|
+
legacy ??= new Set(LEGACY_REFERENCES.split(" "));
|
|
187
|
+
return legacy;
|
|
188
|
+
}
|
package/src/core/build.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { loadConfig } from "./config.ts";
|
|
4
4
|
import { renderDeckDocument } from "./document.ts";
|
|
5
5
|
import { type DistOptions, distFile } from "./path.ts";
|
|
6
6
|
import type { PlaywrightRunner } from "./playwright.ts";
|
|
7
7
|
import { asResolvedDeck, type ResolvedDeck } from "./resolve.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { removeInside, writeInside } from "./safe-fs.ts";
|
|
9
9
|
import { coverShot } from "./shot/still.ts";
|
|
10
10
|
import { logicalSize } from "./size.ts";
|
|
11
11
|
|
|
@@ -43,7 +43,7 @@ export async function buildDeck(
|
|
|
43
43
|
const shot = baseUrl ? await coverShot(deck, options.runner) : undefined;
|
|
44
44
|
|
|
45
45
|
if (shot) {
|
|
46
|
-
|
|
46
|
+
writeInside(imagePath, readFileSync(shot), project.root);
|
|
47
47
|
} else {
|
|
48
48
|
// An image from an earlier build would outlive the tag that pointed at it.
|
|
49
49
|
removeInside(imagePath, project.root);
|
package/src/core/config.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { DekError } from "./error.ts";
|
|
4
|
+
import { shellQuote } from "./escape.ts";
|
|
3
5
|
import { isPackLicense, PACK_LICENSES, type PackLicense } from "./licenses.ts";
|
|
4
6
|
import { parsePublicUrl } from "./ogp.ts";
|
|
5
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
isPinFor,
|
|
9
|
+
isPlainRefName,
|
|
10
|
+
packFile,
|
|
11
|
+
parseRefSource,
|
|
12
|
+
REF_DECK_NAME_RULE,
|
|
13
|
+
} from "./ref-name.ts";
|
|
14
|
+
import { readSourceIfExists } from "./safe-fs.ts";
|
|
6
15
|
import { DeckSettings } from "./schema.ts";
|
|
16
|
+
import { scanTomlKeys } from "./toml-keys.ts";
|
|
7
17
|
import { parseTomlWith } from "./zod.ts";
|
|
8
18
|
|
|
9
19
|
export const Voice = z.object({
|
|
@@ -32,20 +42,21 @@ export const DekToml = z.object({
|
|
|
32
42
|
...DeckSettings.partial().shape,
|
|
33
43
|
voice: Voice.optional(),
|
|
34
44
|
pack: Pack.optional(),
|
|
45
|
+
// Each key is checked before its value, so a key that names no ref is never repeated.
|
|
35
46
|
refs: z
|
|
36
|
-
.record(z.string(), z.
|
|
47
|
+
.record(z.string(), z.unknown())
|
|
37
48
|
.superRefine((refs, ctx) => {
|
|
38
49
|
for (const [name, rev] of Object.entries(refs)) {
|
|
39
50
|
if (!isPlainRefName(name)) {
|
|
40
51
|
ctx.addIssue({
|
|
41
52
|
code: "custom",
|
|
42
|
-
path: [
|
|
43
|
-
message:
|
|
53
|
+
path: [],
|
|
54
|
+
message: `${REFUSED_REF_KEY}; ${REFUSED_REF_HINT}`,
|
|
44
55
|
});
|
|
45
56
|
continue;
|
|
46
57
|
}
|
|
47
58
|
const source = parseRefSource(name);
|
|
48
|
-
if (isPinFor(source, rev)) {
|
|
59
|
+
if (typeof rev === "string" && isPinFor(source, rev)) {
|
|
49
60
|
continue;
|
|
50
61
|
}
|
|
51
62
|
ctx.addIssue({
|
|
@@ -53,14 +64,49 @@ export const DekToml = z.object({
|
|
|
53
64
|
path: [name],
|
|
54
65
|
message:
|
|
55
66
|
source.kind === "file"
|
|
56
|
-
? `pin a pack's ref to the 64-character sha256 of its files; run \`dekc ref ${packFile(source.deck)}\` to pin one`
|
|
57
|
-
: `pin a ref to a 40-character commit sha; run \`dekc ref ${name}\` to pin one`,
|
|
67
|
+
? `pin a pack's ref to the 64-character sha256 of its files; run \`dekc ref ${shellQuote(packFile(source.deck))}\` to pin one`
|
|
68
|
+
: `pin a ref to a 40-character commit sha; run \`dekc ref ${shellQuote(name)}\` to pin one`,
|
|
58
69
|
});
|
|
59
70
|
}
|
|
60
71
|
})
|
|
72
|
+
.transform((refs) => refs as Record<string, string>)
|
|
61
73
|
.optional(),
|
|
62
74
|
});
|
|
63
75
|
|
|
76
|
+
const REFUSED_REF_KEY = "a key in [refs] names no ref";
|
|
77
|
+
|
|
78
|
+
const REFUSED_REF_HINT = `delete that line, and for a pack its packs/<deck>.tgz and refs/file/<deck>, then pin the deck again under a name a ref can carry: a ref is named owner/repo/deck, or file/<deck> for a pack, and ${REF_DECK_NAME_RULE}`;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The first key in [refs] that names no ref, by its line when the file says it plainly. Such a
|
|
82
|
+
* key may be someone else's text, from a project cloned or a pack added before dek checked it, so
|
|
83
|
+
* the error points at it and never repeats it.
|
|
84
|
+
*/
|
|
85
|
+
function refusedRefKey(source: string): { line?: number } | undefined {
|
|
86
|
+
let parsed: unknown;
|
|
87
|
+
try {
|
|
88
|
+
parsed = Bun.TOML.parse(source);
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
const refs = (parsed as { refs?: unknown } | null)?.refs;
|
|
93
|
+
const name =
|
|
94
|
+
refs !== null && typeof refs === "object"
|
|
95
|
+
? Object.keys(refs).find((key) => !isPlainRefName(key))
|
|
96
|
+
: undefined;
|
|
97
|
+
if (name === undefined) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const written = scanTomlKeys(source).filter(
|
|
101
|
+
(entry) => entry.path[0] === "refs" && entry.path.length === 2,
|
|
102
|
+
);
|
|
103
|
+
// A key written with escapes reads differently on its line; any key there that names no ref is it.
|
|
104
|
+
const entry =
|
|
105
|
+
written.find((key) => key.path[1] === name) ??
|
|
106
|
+
written.find((key) => !isPlainRefName(key.path[1] ?? ""));
|
|
107
|
+
return entry === undefined ? {} : { line: entry.line };
|
|
108
|
+
}
|
|
109
|
+
|
|
64
110
|
type VoiceDefaults = {
|
|
65
111
|
engine: string;
|
|
66
112
|
speaker: string;
|
|
@@ -83,6 +129,10 @@ export type DekConfig = {
|
|
|
83
129
|
};
|
|
84
130
|
|
|
85
131
|
export function parseDekToml(source: string, path?: string): DekConfig {
|
|
132
|
+
const refused = refusedRefKey(source);
|
|
133
|
+
if (refused) {
|
|
134
|
+
throw new DekError(REFUSED_REF_KEY, { path, ...refused, hint: REFUSED_REF_HINT });
|
|
135
|
+
}
|
|
86
136
|
const data = parseTomlWith(DekToml, source, {
|
|
87
137
|
label: "dek.toml",
|
|
88
138
|
anchor: "dek-toml",
|
|
@@ -108,9 +158,8 @@ export function parseDekToml(source: string, path?: string): DekConfig {
|
|
|
108
158
|
};
|
|
109
159
|
}
|
|
110
160
|
|
|
161
|
+
/** The project's dek.toml, which sits at its root; a link out of the project is an error. */
|
|
111
162
|
export function loadConfig(configPath: string): DekConfig {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
return parseDekToml(readFileSync(configPath, "utf8"), configPath);
|
|
163
|
+
const source = readSourceIfExists(configPath, dirname(configPath));
|
|
164
|
+
return source === undefined ? {} : parseDekToml(source, configPath);
|
|
116
165
|
}
|
package/src/core/css-scan.ts
CHANGED
|
@@ -1,9 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one way dek steps through CSS text
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The one way dek steps through CSS text, as the CSS tokenizer reads it: strings, comments, and
|
|
3
|
+
* an unquoted `url()` are opaque, CR, FF, and CRLF are newlines as LF is, and a newline ends an
|
|
4
|
+
* unclosed string. Every scanner in css.ts, css-transform.ts, and theme-excerpt.ts is built on
|
|
5
|
+
* these, so none of them can disagree about where a string or a block ends. Nothing is rewritten
|
|
6
|
+
* before it is read, so every index still points into the text as written.
|
|
5
7
|
*/
|
|
6
8
|
|
|
9
|
+
/** A newline as CSS preprocesses its input: LF, CR, or FF; CRLF is one, read at its CR. */
|
|
10
|
+
function isNewline(ch: string | undefined): boolean {
|
|
11
|
+
return ch === "\n" || ch === "\r" || ch === "\f";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isWhitespace(ch: string | undefined): boolean {
|
|
15
|
+
return ch === " " || ch === "\t" || isNewline(ch);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The index just past the whitespace at `i`, taking CRLF whole. */
|
|
19
|
+
function whitespaceEnd(source: string, i: number, end: number): number {
|
|
20
|
+
return Math.min(source[i] === "\r" && source[i + 1] === "\n" ? i + 2 : i + 1, end);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function skipWhitespace(source: string, i: number, end: number): number {
|
|
24
|
+
let j = i;
|
|
25
|
+
while (j < end && isWhitespace(source[j])) {
|
|
26
|
+
j++;
|
|
27
|
+
}
|
|
28
|
+
return j;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether a `\` at `i` escapes what follows: anything but a newline, the end included. */
|
|
32
|
+
function startsEscape(source: string, i: number, end: number): boolean {
|
|
33
|
+
return source[i] === "\\" && (i + 1 >= end || !isNewline(source[i + 1]));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const HEX_RE = /[0-9a-fA-F]/;
|
|
37
|
+
|
|
38
|
+
/** The index just past the escape at `i`: one code point, or up to six hex digits and a space. */
|
|
39
|
+
function consumeEscape(source: string, i: number, end: number): number {
|
|
40
|
+
let j = i + 1;
|
|
41
|
+
if (j >= end || !HEX_RE.test(source[j] ?? "")) {
|
|
42
|
+
return Math.min(j + 1, end);
|
|
43
|
+
}
|
|
44
|
+
while (j < end && j < i + 7 && HEX_RE.test(source[j] ?? "")) {
|
|
45
|
+
j++;
|
|
46
|
+
}
|
|
47
|
+
return j < end && isWhitespace(source[j]) ? whitespaceEnd(source, j, end) : j;
|
|
48
|
+
}
|
|
49
|
+
|
|
7
50
|
/** The index just past the string that opens at `start`, or the newline that cuts it short. */
|
|
8
51
|
export function consumeString(source: string, start: number, end = source.length): number {
|
|
9
52
|
const quote = source[start];
|
|
@@ -11,13 +54,16 @@ export function consumeString(source: string, start: number, end = source.length
|
|
|
11
54
|
while (i < end) {
|
|
12
55
|
const ch = source[i];
|
|
13
56
|
if (ch === "\\") {
|
|
14
|
-
|
|
57
|
+
// A `\` before a newline carries the string on to the next line.
|
|
58
|
+
i = startsEscape(source, i, end)
|
|
59
|
+
? consumeEscape(source, i, end)
|
|
60
|
+
: whitespaceEnd(source, i + 1, end);
|
|
15
61
|
continue;
|
|
16
62
|
}
|
|
17
63
|
if (ch === quote) {
|
|
18
64
|
return i + 1;
|
|
19
65
|
}
|
|
20
|
-
if (ch
|
|
66
|
+
if (isNewline(ch)) {
|
|
21
67
|
return i;
|
|
22
68
|
}
|
|
23
69
|
i++;
|
|
@@ -35,27 +81,122 @@ function isCommentStart(source: string, i: number): boolean {
|
|
|
35
81
|
return source[i] === "/" && source[i + 1] === "*";
|
|
36
82
|
}
|
|
37
83
|
|
|
84
|
+
const NAME_RE = /[-\w\u0080-\uffff]/;
|
|
85
|
+
|
|
86
|
+
/** A code point of a name: a letter, a digit, `-`, `_`, non-ASCII, or NUL, which CSS reads as U+FFFD. */
|
|
87
|
+
function isNameChar(ch: string | undefined): boolean {
|
|
88
|
+
return ch === "\0" || NAME_RE.test(ch ?? "");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The index just past the name at `i`: letters, digits, `-`, `_`, non-ASCII, and escapes. */
|
|
92
|
+
function consumeName(source: string, i: number, end: number): number {
|
|
93
|
+
let j = i;
|
|
94
|
+
while (j < end) {
|
|
95
|
+
if (startsEscape(source, j, end)) {
|
|
96
|
+
j = consumeEscape(source, j, end);
|
|
97
|
+
} else if (isNameChar(source[j])) {
|
|
98
|
+
j++;
|
|
99
|
+
} else {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return j;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether a name reads `url` once its escapes are resolved, as `URL` or `u\72l` does. */
|
|
107
|
+
function isUrlName(name: string): boolean {
|
|
108
|
+
const read = name.includes("\\")
|
|
109
|
+
? name.replace(/\\(?:([0-9a-fA-F]{1,6})(?:\r\n|[ \t\n\r\f])?|([\s\S]))/g, (_, hex, ch) => {
|
|
110
|
+
const code = hex === undefined ? -1 : Number.parseInt(hex, 16);
|
|
111
|
+
return code < 0 ? ch : code > 0x10ffff ? "\ufffd" : String.fromCodePoint(code);
|
|
112
|
+
})
|
|
113
|
+
: name;
|
|
114
|
+
return read.toLowerCase() === "url";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isNonPrintable(ch: string): boolean {
|
|
118
|
+
const code = ch.charCodeAt(0);
|
|
119
|
+
// NUL is no control character here: CSS reads it as U+FFFD.
|
|
120
|
+
return (
|
|
121
|
+
(code >= 0x01 && code <= 0x08) ||
|
|
122
|
+
code === 0x0b ||
|
|
123
|
+
(code >= 0x0e && code <= 0x1f) ||
|
|
124
|
+
code === 0x7f
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
38
128
|
/**
|
|
39
|
-
* The
|
|
40
|
-
*
|
|
129
|
+
* The index just past an unquoted `url()` whose address starts at `start`, after its `(`; none
|
|
130
|
+
* when the address is quoted, which makes it a function holding a string. A broken one, with a
|
|
131
|
+
* space, a quote, a `(`, or a control character in its address, still runs to its `)`.
|
|
41
132
|
*/
|
|
42
|
-
|
|
43
|
-
let
|
|
44
|
-
|
|
133
|
+
function consumeUrl(source: string, start: number, end: number): number | undefined {
|
|
134
|
+
let i = skipWhitespace(source, start, end);
|
|
135
|
+
if (i < end && (source[i] === '"' || source[i] === "'")) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
let broken = false;
|
|
45
139
|
while (i < end) {
|
|
46
140
|
const ch = source[i] ?? "";
|
|
47
|
-
if (ch ===
|
|
48
|
-
i
|
|
49
|
-
continue;
|
|
141
|
+
if (ch === ")") {
|
|
142
|
+
return i + 1;
|
|
50
143
|
}
|
|
51
|
-
if (
|
|
52
|
-
i =
|
|
144
|
+
if (startsEscape(source, i, end)) {
|
|
145
|
+
i = consumeEscape(source, i, end);
|
|
53
146
|
continue;
|
|
54
147
|
}
|
|
55
|
-
if (
|
|
56
|
-
i
|
|
148
|
+
if (!broken && isWhitespace(ch)) {
|
|
149
|
+
i = skipWhitespace(source, i, end);
|
|
150
|
+
broken = i < end && source[i] !== ")";
|
|
57
151
|
continue;
|
|
58
152
|
}
|
|
153
|
+
broken ||= ch === '"' || ch === "'" || ch === "(" || ch === "\\" || isNonPrintable(ch);
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
return end;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The index just past the token that starts at `i`, as far as dek tells tokens apart: a string,
|
|
161
|
+
* a comment, an unquoted `url()`, or a name with its escapes, so the `url` of `myurl(` or
|
|
162
|
+
* `10url(` opens nothing. Any other code point is a token of its own. A `\` before a newline,
|
|
163
|
+
* which escapes nothing, takes the newline with it, so no rewrite can make an escape of the two.
|
|
164
|
+
*/
|
|
165
|
+
export function consumeToken(source: string, i: number, end = source.length): number {
|
|
166
|
+
const ch = source[i];
|
|
167
|
+
if (ch === '"' || ch === "'") {
|
|
168
|
+
return consumeString(source, i, end);
|
|
169
|
+
}
|
|
170
|
+
if (isCommentStart(source, i)) {
|
|
171
|
+
return consumeComment(source, i, end);
|
|
172
|
+
}
|
|
173
|
+
if (ch === "\\" && !startsEscape(source, i, end)) {
|
|
174
|
+
return whitespaceEnd(source, i + 1, end);
|
|
175
|
+
}
|
|
176
|
+
if (!startsEscape(source, i, end) && !isNameChar(ch)) {
|
|
177
|
+
return i + 1;
|
|
178
|
+
}
|
|
179
|
+
const nameEnd = consumeName(source, i, end);
|
|
180
|
+
// `#url(` is a hash and `@url(` an at-rule's name, not a url().
|
|
181
|
+
const url =
|
|
182
|
+
nameEnd < end &&
|
|
183
|
+
source[nameEnd] === "(" &&
|
|
184
|
+
source[i - 1] !== "#" &&
|
|
185
|
+
source[i - 1] !== "@" &&
|
|
186
|
+
isUrlName(source.slice(i, nameEnd));
|
|
187
|
+
return (url && consumeUrl(source, nameEnd + 1, end)) || nameEnd;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The first index from `start` whose character is one of `stops` outside strings, comments,
|
|
192
|
+
* `url()`s, and `()`/`[]`; `end` when there is none. An opening brace counts only when `{` is a
|
|
193
|
+
* stop. Stops are punctuation, which no name holds.
|
|
194
|
+
*/
|
|
195
|
+
export function scanTopLevel(source: string, start: number, end: number, stops: string): number {
|
|
196
|
+
let depth = 0;
|
|
197
|
+
let i = start;
|
|
198
|
+
while (i < end) {
|
|
199
|
+
const ch = source[i] ?? "";
|
|
59
200
|
if (depth === 0 && stops.includes(ch)) {
|
|
60
201
|
return i;
|
|
61
202
|
}
|
|
@@ -64,7 +205,7 @@ export function scanTopLevel(source: string, start: number, end: number, stops:
|
|
|
64
205
|
} else if ((ch === ")" || ch === "]") && depth > 0) {
|
|
65
206
|
depth--;
|
|
66
207
|
}
|
|
67
|
-
i
|
|
208
|
+
i = consumeToken(source, i, end);
|
|
68
209
|
}
|
|
69
210
|
return end;
|
|
70
211
|
}
|
|
@@ -88,17 +229,17 @@ export function matchBrace(source: string, open: number, end = source.length): n
|
|
|
88
229
|
return end;
|
|
89
230
|
}
|
|
90
231
|
|
|
91
|
-
/** The text with each comment removed, strings kept byte for byte. */
|
|
232
|
+
/** The text with each comment removed, strings and `url()`s kept byte for byte. */
|
|
92
233
|
export function stripComments(source: string): string {
|
|
93
234
|
return replaceOpaque(source, { comment: () => "" });
|
|
94
235
|
}
|
|
95
236
|
|
|
96
237
|
/**
|
|
97
238
|
* The text with each string and comment blanked to spaces, newlines kept, so only CSS syntax is
|
|
98
|
-
* left and every offset still points where it did.
|
|
239
|
+
* left and every offset still points where it did. A `url()` is left as written.
|
|
99
240
|
*/
|
|
100
241
|
export function blankStringsAndComments(source: string): string {
|
|
101
|
-
const blank = (text: string) => text.replace(/[^\n]/g, " ");
|
|
242
|
+
const blank = (text: string) => text.replace(/[^\n\r\f]/g, " ");
|
|
102
243
|
return replaceOpaque(source, { comment: blank, string: blank });
|
|
103
244
|
}
|
|
104
245
|
|
|
@@ -110,26 +251,19 @@ function replaceOpaque(
|
|
|
110
251
|
let from = 0;
|
|
111
252
|
let i = 0;
|
|
112
253
|
while (i < source.length) {
|
|
254
|
+
const next = consumeToken(source, i);
|
|
113
255
|
const ch = source[i];
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (isCommentStart(source, i)) {
|
|
124
|
-
const end = consumeComment(source, i);
|
|
125
|
-
if (replace.comment) {
|
|
126
|
-
out += source.slice(from, i) + replace.comment(source.slice(i, end));
|
|
127
|
-
from = end;
|
|
128
|
-
}
|
|
129
|
-
i = end;
|
|
130
|
-
continue;
|
|
256
|
+
const replacer =
|
|
257
|
+
ch === '"' || ch === "'"
|
|
258
|
+
? replace.string
|
|
259
|
+
: isCommentStart(source, i)
|
|
260
|
+
? replace.comment
|
|
261
|
+
: undefined;
|
|
262
|
+
if (replacer) {
|
|
263
|
+
out += source.slice(from, i) + replacer(source.slice(i, next));
|
|
264
|
+
from = next;
|
|
131
265
|
}
|
|
132
|
-
i
|
|
266
|
+
i = next;
|
|
133
267
|
}
|
|
134
268
|
return out + source.slice(from);
|
|
135
269
|
}
|