@hajimism/dek 0.4.0 → 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 +73 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +4 -3
- package/src/cli/address.ts +11 -4
- 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/lint.ts +32 -13
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +46 -11
- 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 +49 -15
- 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/slide-html.ts +21 -18
- 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-script-eval.ts +2 -0
- package/src/core/spawn.ts +131 -32
- 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 +11 -3
- package/src/server/lock.ts +4 -4
- 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/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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { outermostStyleRules, type Stylesheet, splitSelectorList, startsAtSlide } from "./css.ts";
|
|
2
|
-
import { consumeComment,
|
|
2
|
+
import { consumeComment, consumeToken, cssIdents } from "./css-scan.ts";
|
|
3
3
|
|
|
4
4
|
/** Text to put in place of `[start, end)` of the stylesheet as it was read. */
|
|
5
5
|
type CssEdit = { start: number; end: number; text: string };
|
|
@@ -108,32 +108,78 @@ export function replaceUrls(replace: (value: string) => string | undefined): Css
|
|
|
108
108
|
|
|
109
109
|
/** Whitespace as CSS defines it. U+3000 and NBSP are content, not whitespace. */
|
|
110
110
|
const CSS_WHITESPACE = new Set([" ", "\t", "\n", "\r", "\f"]);
|
|
111
|
+
const CSS_NEWLINES = new Set(["\n", "\r", "\f"]);
|
|
112
|
+
|
|
113
|
+
/** A token that runs on into the next with nothing between: a name, a number, or `#@.+/`. */
|
|
114
|
+
const RUNS_ON_RE = /^[-\w\u0080-\uffff\0\\#@.+/]/;
|
|
115
|
+
/** What such a token runs on into: a name, a number, `(`, `%`, or the `*` that opens a comment. */
|
|
116
|
+
const RUNS_INTO_RE = /^[-\w\u0080-\uffff\0\\.+(%*]/;
|
|
111
117
|
|
|
112
118
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
119
|
+
* Whether the token `last` and the one that starts with `next` would read as one with nothing
|
|
120
|
+
* between them: `x` and `url(` as `xurl(`, `/` and `*` as a comment, or `--` and `>`, `<` and
|
|
121
|
+
* `!`, `!` and `--` as the `-->` and `<!--` a stylesheet skips.
|
|
122
|
+
*/
|
|
123
|
+
function runTogether(last: string, next: string): boolean {
|
|
124
|
+
return (
|
|
125
|
+
(RUNS_ON_RE.test(last) && RUNS_INTO_RE.test(next)) ||
|
|
126
|
+
(last.endsWith("--") && next === ">") ||
|
|
127
|
+
(last === "<" && next === "!") ||
|
|
128
|
+
(last === "!" && next === "-")
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A hex escape at the end of a token, which takes one whitespace after it as its own. */
|
|
133
|
+
const HEX_ESCAPE_END_RE = /\\[0-9a-fA-F]{1,6}$/;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Drops comments and collapses whitespace to one space, leaving strings and `url()`s byte for
|
|
137
|
+
* byte, so `content: "a b"` or `" "` renders in the build as it does on the dev server. The
|
|
138
|
+
* result reads as the source does: a newline after a string stays one, since it is what ends a
|
|
139
|
+
* string cut short, and a comment between two tokens that would run into one, as `x` and `url(`
|
|
140
|
+
* into `xurl(`, stays as an empty one, since a space would make `div` and `p` two selectors. A
|
|
141
|
+
* hex escape that a comment ends, such as `\41` before a comment and ` x`, is followed by two
|
|
142
|
+
* spaces, since it takes one as its own.
|
|
115
143
|
*/
|
|
116
144
|
export function minifyCss(css: string): string {
|
|
117
145
|
let out = "";
|
|
118
|
-
let
|
|
146
|
+
let last = "";
|
|
147
|
+
// What parts the last token written from the next: "", "comment", a space, or the newline that
|
|
148
|
+
// ends a string.
|
|
149
|
+
let gap = "";
|
|
119
150
|
let i = 0;
|
|
120
151
|
while (i < css.length) {
|
|
121
152
|
const ch = css[i] ?? "";
|
|
122
153
|
if (ch === "/" && css[i + 1] === "*") {
|
|
123
154
|
i = consumeComment(css, i);
|
|
155
|
+
gap ||= "comment";
|
|
124
156
|
continue;
|
|
125
157
|
}
|
|
126
158
|
if (CSS_WHITESPACE.has(ch)) {
|
|
127
|
-
|
|
128
|
-
|
|
159
|
+
const whitespace = ch === "\r" && css[i + 1] === "\n" ? "\r\n" : ch;
|
|
160
|
+
if (gap === "" && CSS_NEWLINES.has(ch) && (last[0] === '"' || last[0] === "'")) {
|
|
161
|
+
// One LF, unless the string ends on a CR it escapes, which an LF would join into a CRLF.
|
|
162
|
+
gap = last.endsWith("\r") ? whitespace : "\n";
|
|
163
|
+
} else if (gap === "" || gap === "comment") {
|
|
164
|
+
gap = " ";
|
|
165
|
+
}
|
|
166
|
+
i += whitespace.length;
|
|
129
167
|
continue;
|
|
130
168
|
}
|
|
131
|
-
if (
|
|
132
|
-
out +=
|
|
169
|
+
if (out !== "") {
|
|
170
|
+
out +=
|
|
171
|
+
gap === "comment"
|
|
172
|
+
? runTogether(last, ch)
|
|
173
|
+
? "/**/"
|
|
174
|
+
: ""
|
|
175
|
+
: gap === " " && HEX_ESCAPE_END_RE.test(last)
|
|
176
|
+
? " "
|
|
177
|
+
: gap;
|
|
133
178
|
}
|
|
134
|
-
|
|
135
|
-
const end =
|
|
136
|
-
|
|
179
|
+
gap = "";
|
|
180
|
+
const end = consumeToken(css, i);
|
|
181
|
+
last = css.slice(i, end);
|
|
182
|
+
out += last;
|
|
137
183
|
i = end;
|
|
138
184
|
}
|
|
139
185
|
return out;
|
package/src/core/css.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
blankStringsAndComments,
|
|
3
3
|
consumeComment,
|
|
4
4
|
consumeString,
|
|
5
|
+
consumeToken,
|
|
5
6
|
matchBrace,
|
|
6
7
|
scanTopLevel,
|
|
7
8
|
splitTopLevel,
|
|
@@ -131,16 +132,11 @@ class SheetReader {
|
|
|
131
132
|
const { source } = this;
|
|
132
133
|
let i = 0;
|
|
133
134
|
while (i < source.length) {
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
136
|
-
i
|
|
137
|
-
} else if (ch === "/" && source[i + 1] === "*") {
|
|
138
|
-
const close = consumeComment(source, i);
|
|
139
|
-
this.comments.push([i, close]);
|
|
140
|
-
i = close;
|
|
141
|
-
} else {
|
|
142
|
-
i += ch === "\\" ? 2 : 1;
|
|
135
|
+
const next = consumeToken(source, i);
|
|
136
|
+
if (source[i] === "/" && source[i + 1] === "*") {
|
|
137
|
+
this.comments.push([i, next]);
|
|
143
138
|
}
|
|
139
|
+
i = next;
|
|
144
140
|
}
|
|
145
141
|
const locate = lineLocator(sheet.source);
|
|
146
142
|
this.lineOf = (offset) => locate(offset).line;
|
|
@@ -181,13 +177,23 @@ class SheetReader {
|
|
|
181
177
|
return i;
|
|
182
178
|
}
|
|
183
179
|
|
|
184
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* A prelude's span with the spaces and comments at its end trimmed off; a string it ends on,
|
|
182
|
+
* such as the name of `@keyframes "pop"`, stays.
|
|
183
|
+
*/
|
|
185
184
|
trimEnd(start: number, end: number): number {
|
|
186
185
|
let j = end;
|
|
187
|
-
while (j > start
|
|
188
|
-
j
|
|
186
|
+
while (j > start) {
|
|
187
|
+
const comment = this.comments.find(([, close]) => close === j);
|
|
188
|
+
if (comment) {
|
|
189
|
+
j = comment[0];
|
|
190
|
+
} else if (WHITESPACE_RE.test(this.source[j - 1] ?? "")) {
|
|
191
|
+
j--;
|
|
192
|
+
} else {
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
189
195
|
}
|
|
190
|
-
return j;
|
|
196
|
+
return Math.max(j, start);
|
|
191
197
|
}
|
|
192
198
|
|
|
193
199
|
atRule(start: number, end: number, scope: Scope): number {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text someone else wrote, such as a ref's title or the folder names in its repository, as dek
|
|
3
|
+
* puts it inside its own words. An agent reads dek's messages and AGENTS.md's dek block as dek's,
|
|
4
|
+
* so such text must not start a line there, carry a control character, or hold a character the
|
|
5
|
+
* person reading never sees.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Characters that show as nothing or move the text around them: those Unicode marks
|
|
10
|
+
* default-ignorable, such as zero-width ones, the byte order mark, bidi controls, Hangul fillers,
|
|
11
|
+
* and tag characters, which a model reads as text though a person sees nothing; and the line and
|
|
12
|
+
* paragraph separators and the interlinear annotation marks. The variation selectors, Mongolian's
|
|
13
|
+
* free ones included, are default-ignorable too and left out: emoji, ideographic variants of a
|
|
14
|
+
* kanji, and Mongolian text use them on purpose.
|
|
15
|
+
*/
|
|
16
|
+
const HIDDEN_RE =
|
|
17
|
+
/(?![\u{fe00}-\u{fe0f}\u{e0100}-\u{e01ef}\u{180b}-\u{180d}\u{180f}])[\p{Default_Ignorable_Code_Point}\u{2028}\u{2029}\u{fff9}-\u{fffb}]/u;
|
|
18
|
+
|
|
19
|
+
/** Whitespace and control characters, which a line of someone else's text shows as one space. */
|
|
20
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
|
|
21
|
+
const SPACE_RE = /[\s\x00-\x1f\x7f-\x9f]/u;
|
|
22
|
+
|
|
23
|
+
/** HIDDEN_RE for every match. */
|
|
24
|
+
const HIDDEN_ALL_RE = new RegExp(HIDDEN_RE.source, "gu");
|
|
25
|
+
|
|
26
|
+
/** True when `text` holds a character that shows as nothing or moves the text around it. */
|
|
27
|
+
export function holdsHidden(text: string): boolean {
|
|
28
|
+
return HIDDEN_RE.test(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `U+202E`, as a code point is written. */
|
|
32
|
+
function codePoint(char: string): string {
|
|
33
|
+
return `U+${(char.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, "0")}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A hidden character as dek shows it, so a reader sees it is there and which it is. */
|
|
37
|
+
function marker(char: string): string {
|
|
38
|
+
return `⟨${codePoint(char)}⟩`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `text` with each hidden character as dek shows it, `⟨U+XXXX⟩`, and the rest as it is. */
|
|
42
|
+
export function revealHidden(text: string): string {
|
|
43
|
+
return text.replace(HIDDEN_ALL_RE, marker);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A line of a file someone else wrote that holds characters a person cannot see. */
|
|
47
|
+
export type HiddenText = {
|
|
48
|
+
path: string;
|
|
49
|
+
line: number;
|
|
50
|
+
/** 1-based, in UTF-16 units as editors count: where the first of them is. */
|
|
51
|
+
column: number;
|
|
52
|
+
/** Each of them on the line, in order, as U+XXXX. */
|
|
53
|
+
codePoints: string[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Each line of `text`, read from `path` and starting at line `firstLine`, that holds any. */
|
|
57
|
+
export function hiddenText(text: string, path: string, firstLine = 1): HiddenText[] {
|
|
58
|
+
return text.split("\n").flatMap((line, index) => {
|
|
59
|
+
const found = [...line.matchAll(HIDDEN_ALL_RE)];
|
|
60
|
+
const [first] = found;
|
|
61
|
+
if (first === undefined) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
path,
|
|
67
|
+
line: firstLine + index,
|
|
68
|
+
column: first.index + 1,
|
|
69
|
+
codePoints: found.map((match) => codePoint(match[0])),
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A marker, which text revealHidden already went over holds, or else one code point: the units a
|
|
77
|
+
* line is cut between.
|
|
78
|
+
*/
|
|
79
|
+
const UNIT_RE = /⟨U\+[0-9A-F]{4,6}⟩|[\s\S]/gu;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One line of someone else's text inside dek's own: each hidden character shows as `⟨U+XXXX⟩`,
|
|
83
|
+
* whitespace and control characters as one space, and the line is cut to `max` characters with
|
|
84
|
+
* an ellipsis, never through a marker, whether this made it or revealHidden did before.
|
|
85
|
+
*/
|
|
86
|
+
export function foreignLine(text: string, max: number): string {
|
|
87
|
+
const units: string[] = [];
|
|
88
|
+
for (const [char = ""] of text.matchAll(UNIT_RE)) {
|
|
89
|
+
// A marker is longer than any one code point, which is at most two UTF-16 units.
|
|
90
|
+
const unit =
|
|
91
|
+
char.length > 2
|
|
92
|
+
? char
|
|
93
|
+
: HIDDEN_RE.test(char)
|
|
94
|
+
? marker(char)
|
|
95
|
+
: SPACE_RE.test(char)
|
|
96
|
+
? " "
|
|
97
|
+
: char;
|
|
98
|
+
if (unit !== " " || (units.length > 0 && units.at(-1) !== " ")) {
|
|
99
|
+
units.push(unit);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (units.at(-1) === " ") {
|
|
103
|
+
units.pop();
|
|
104
|
+
}
|
|
105
|
+
const width = (unit: string) => [...unit].length;
|
|
106
|
+
if (units.reduce((sum, unit) => sum + width(unit), 0) <= max) {
|
|
107
|
+
return units.join("");
|
|
108
|
+
}
|
|
109
|
+
const kept: string[] = [];
|
|
110
|
+
let size = 0;
|
|
111
|
+
for (const unit of units) {
|
|
112
|
+
if (size + width(unit) > max - 1) {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
kept.push(unit);
|
|
116
|
+
size += width(unit);
|
|
117
|
+
}
|
|
118
|
+
return `${kept.join("")}…`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The longest a ref's title shows in a line of dek's: a title names a deck, it does not describe it. */
|
|
122
|
+
export const TITLE_MAX = 80;
|
|
123
|
+
|
|
124
|
+
/** The longest message dek gives that quotes someone else's text, such as Bun's on their source. */
|
|
125
|
+
export const MESSAGE_MAX = 200;
|
|
126
|
+
|
|
127
|
+
/** The longest name a list shows: a deck's folder names a deck, it does not describe one. */
|
|
128
|
+
const NAME_MAX = 40;
|
|
129
|
+
|
|
130
|
+
/** How many names a list shows before it counts the rest. */
|
|
131
|
+
const LIST_MAX = 10;
|
|
132
|
+
|
|
133
|
+
/** Names someone else chose, each a foreignLine of NAME_MAX, the first LIST_MAX and a count of the rest. */
|
|
134
|
+
export function foreignList(names: string[]): string {
|
|
135
|
+
const shown = names.slice(0, LIST_MAX).map((name) => foreignLine(name, NAME_MAX));
|
|
136
|
+
const rest = names.length - shown.length;
|
|
137
|
+
return rest > 0 ? `${shown.join(", ")}, and ${rest} more` : shown.join(", ");
|
|
138
|
+
}
|
package/src/core/github.ts
CHANGED
|
@@ -93,6 +93,7 @@ async function request(
|
|
|
93
93
|
const auth = authorization(base);
|
|
94
94
|
let response: Response;
|
|
95
95
|
try {
|
|
96
|
+
// biome-ignore lint/style/noRestrictedGlobals: the deadline's signal bounds the request, and readBody cancels a read that outlasts it.
|
|
96
97
|
response = await fetch(`${base}${path}`, {
|
|
97
98
|
headers: {
|
|
98
99
|
accept,
|