@crouton-kit/capture 1.3.8 → 1.3.10
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/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- package/src/tab-session.ts +0 -91
package/src/output/render.ts
CHANGED
|
@@ -40,6 +40,26 @@ export const RESULT_TAGS = [
|
|
|
40
40
|
'jank',
|
|
41
41
|
'response',
|
|
42
42
|
'error',
|
|
43
|
+
'session',
|
|
44
|
+
'session-stopped',
|
|
45
|
+
'sessions',
|
|
46
|
+
'session-har',
|
|
47
|
+
'log-tail',
|
|
48
|
+
'clicked',
|
|
49
|
+
'typed',
|
|
50
|
+
'scrolled',
|
|
51
|
+
'navigated',
|
|
52
|
+
'exec-result',
|
|
53
|
+
'elements',
|
|
54
|
+
'screenshot',
|
|
55
|
+
'tabs',
|
|
56
|
+
'tab-opened',
|
|
57
|
+
'tab-reset',
|
|
58
|
+
'network',
|
|
59
|
+
'cdp-result',
|
|
60
|
+
'libs',
|
|
61
|
+
'lib',
|
|
62
|
+
'ax-map',
|
|
43
63
|
] as const;
|
|
44
64
|
|
|
45
65
|
export type ResultTag = (typeof RESULT_TAGS)[number];
|
|
@@ -95,9 +115,383 @@ function capLength(s: string, max: number): string {
|
|
|
95
115
|
return `${s.slice(0, max)}…[+${s.length - max} chars]`;
|
|
96
116
|
}
|
|
97
117
|
|
|
98
|
-
|
|
118
|
+
// XML name-start characters this renderer treats as beginning a tag-like span.
|
|
119
|
+
// This is the FULL XML 1.0 NameStartChar production, including the astral
|
|
120
|
+
// #x10000-#xEFFFF plane: ASCII letters, `_`/`:`, the Unicode letter/name-start
|
|
121
|
+
// BMP ranges XML permits, and astral name-start code points. The `u` flag makes
|
|
122
|
+
// the class code-point-aware so an astral name-start char (a surrogate pair such
|
|
123
|
+
// as `𐀀`, U+10000) is matched as one code point, not a lone surrogate. So
|
|
124
|
+
// hostile, page-derived but XML-valid names — `<_hostile>`, `<évil>`, `<Ω>`,
|
|
125
|
+
// `<名>`, `<𐀀>` — are neutralized like any `<tag>` rather than slipping through
|
|
126
|
+
// because they aren't ASCII-letter-led or sit outside the BMP.
|
|
127
|
+
const TAG_NAME_START =
|
|
128
|
+
/[:A-Z_a-z\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}\u{37F}-\u{1FFF}\u{200C}\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFFD}\u{10000}-\u{EFFFF}]/u;
|
|
129
|
+
|
|
130
|
+
// The additional characters XML 1.0 NameChar permits BEYOND NameStartChar
|
|
131
|
+
// (used only to continue a name, never to begin one): `-`, `.`, ASCII digits,
|
|
132
|
+
// U+00B7 MIDDLE DOT, and the combining/connector ranges U+0300-U+036F and
|
|
133
|
+
// U+203F-U+2040. Combined with TAG_NAME_START this is the full NameChar set.
|
|
134
|
+
const TAG_NAME_CONT = /[-.0-9\u{B7}\u{0300}-\u{036F}\u{203F}-\u{2040}]/u;
|
|
135
|
+
|
|
136
|
+
/** True iff the code point may appear inside an XML tag name (NameStartChar or
|
|
137
|
+
* the NameChar-only continuation set). Code-point-aware so astral name chars
|
|
138
|
+
* are matched whole. */
|
|
139
|
+
function isTagNameChar(cp: number): boolean {
|
|
140
|
+
const c = String.fromCodePoint(cp);
|
|
141
|
+
return TAG_NAME_START.test(c) || TAG_NAME_CONT.test(c);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** True iff the `<` at `ltIndex` opens a markup-like span — i.e. it is
|
|
145
|
+
* immediately (after an optional `/`) followed by an XML 1.0 NameStartChar.
|
|
146
|
+
* Code-point-aware: `codePointAt` reads a full astral char (a surrogate pair
|
|
147
|
+
* such as `𐀀`, U+10000) so an XML-valid astral name is recognized as a real
|
|
148
|
+
* tag opener rather than dismissed as an unmatched lone surrogate. */
|
|
149
|
+
function startsMarkupName(s: string, ltIndex: number): boolean {
|
|
150
|
+
let j = ltIndex + 1;
|
|
151
|
+
if (s[j] === '/') j++;
|
|
152
|
+
if (j >= s.length) return false;
|
|
153
|
+
const cp = s.codePointAt(j);
|
|
154
|
+
if (cp === undefined) return false;
|
|
155
|
+
return TAG_NAME_START.test(String.fromCodePoint(cp));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** From a `<` at `start` (its name-start already confirmed by
|
|
159
|
+
* `startsMarkupName`), return the index of the `>` that TRULY closes the
|
|
160
|
+
* markup-like span, or -1 if `<start` is not genuine tag syntax. The scan is
|
|
161
|
+
* CONTEXTUAL — `insideCssString` selects between two models, because the two
|
|
162
|
+
* contexts genuinely tokenize the same bytes differently:
|
|
163
|
+
*
|
|
164
|
+
* - Top level / prose (`insideCssString === false`) → `scanMarkupCloseHtml`,
|
|
165
|
+
* faithful HTML tag-token semantics. A tag name runs until whitespace, `/`,
|
|
166
|
+
* or `>` (HTML consumes any other byte — backslash, quote — INTO the name),
|
|
167
|
+
* and quotes suppress `>` only when they open a quoted attribute value after
|
|
168
|
+
* `=`. So HTML-tokenizable data whose real `>` an XML-name gate would miss
|
|
169
|
+
* (`<img\foo>`, `<img " >`, `<img alt=foo" >`) is fully neutralized.
|
|
170
|
+
* - Inside a CSS attribute-selector quoted string (`insideCssString === true`)
|
|
171
|
+
* → `scanMarkupCloseCss`, the strict XML-name gate. There backslash is a CSS
|
|
172
|
+
* string escape, so inert selector data like `<value\"` (name run ended by
|
|
173
|
+
* `\`, not whitespace/`/`/`>`) is NOT a tag and returns -1 — its `<` is
|
|
174
|
+
* still escaped, but a later child-combinator `>` stays raw/copy-pastable.
|
|
175
|
+
* A genuine buried tag (`<img alt="x">`, name ended by whitespace) is still
|
|
176
|
+
* recognized and its real `>` neutralized. Applying HTML name semantics
|
|
177
|
+
* here instead would let a run-on name swallow the `] > b` combinator. */
|
|
178
|
+
function scanMarkupClose(s: string, start: number, insideCssString: boolean): number {
|
|
179
|
+
let k = start + 1;
|
|
180
|
+
if (s[k] === '/') k++;
|
|
181
|
+
return insideCssString ? scanMarkupCloseCss(s, k) : scanMarkupCloseHtml(s, k);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isTagWhitespace(ch: string | undefined): boolean {
|
|
185
|
+
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\f' || ch === '\r';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Strict-XML markup close for a `<` nested inside a CSS attribute-selector
|
|
189
|
+
* quoted string. `k` is the first name char (past any `/`). See
|
|
190
|
+
* `scanMarkupClose`. */
|
|
191
|
+
function scanMarkupCloseCss(s: string, startName: number): number {
|
|
192
|
+
let k = startName;
|
|
193
|
+
// Gate 1 — walk the tag name (first char already confirmed a NameStartChar
|
|
194
|
+
// by startsMarkupName) and require a valid tag-name terminator after it.
|
|
195
|
+
let nameLen = 0;
|
|
196
|
+
while (k < s.length) {
|
|
197
|
+
const cp = s.codePointAt(k)!;
|
|
198
|
+
if (!isTagNameChar(cp)) break;
|
|
199
|
+
k += cp > 0xffff ? 2 : 1;
|
|
200
|
+
nameLen++;
|
|
201
|
+
}
|
|
202
|
+
if (nameLen === 0) return -1;
|
|
203
|
+
const boundary = s[k];
|
|
204
|
+
if (boundary !== ' ' && boundary !== '\t' && boundary !== '/' && boundary !== '>') return -1;
|
|
205
|
+
// Gate 2 — find the real `>` with XML attribute-quote semantics. Backslash is
|
|
206
|
+
// a LITERAL attribute character (XML attrs do not backslash-escape), so
|
|
207
|
+
// `<img alt="1 > 0\">` closes at the `>` right after the `\"`.
|
|
208
|
+
let attrQuote: string | null = null;
|
|
209
|
+
while (k < s.length) {
|
|
210
|
+
const ck = s[k];
|
|
211
|
+
if (attrQuote) {
|
|
212
|
+
if (ck === attrQuote) attrQuote = null;
|
|
213
|
+
k++;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (ck === '"' || ck === "'") {
|
|
217
|
+
attrQuote = ck;
|
|
218
|
+
k++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (ck === '>') return k;
|
|
222
|
+
if (ck === '<') return -1; // a new span opens before this one closed — abort
|
|
223
|
+
k++;
|
|
224
|
+
}
|
|
225
|
+
return -1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** HTML tag-token close for a top-level / prose `<`. `k` is the first name
|
|
229
|
+
* char (past any `/`). Implements enough of the HTML tokenizer's tag states to
|
|
230
|
+
* find the real `>`: the name runs until whitespace/`/`/`>` (every other byte,
|
|
231
|
+
* backslash and quote included, is part of the name), and a quote suppresses
|
|
232
|
+
* `>` ONLY inside a quoted attribute value it opened after `=`. A malformed
|
|
233
|
+
* quote that is not a value delimiter (a stray `"` in before-attribute-name, or
|
|
234
|
+
* a `"` inside an unquoted value) does NOT hide the real close. See
|
|
235
|
+
* `scanMarkupClose`. */
|
|
236
|
+
function scanMarkupCloseHtml(s: string, startName: number): number {
|
|
237
|
+
const n = s.length;
|
|
238
|
+
let k = startName;
|
|
239
|
+
// Tag-name state: consume until a name terminator (HTML absorbs any other
|
|
240
|
+
// byte, e.g. `\` or `"`, into the name).
|
|
241
|
+
let nameLen = 0;
|
|
242
|
+
while (k < n) {
|
|
243
|
+
const ch = s[k];
|
|
244
|
+
if (isTagWhitespace(ch) || ch === '/' || ch === '>') break;
|
|
245
|
+
k++;
|
|
246
|
+
nameLen++;
|
|
247
|
+
}
|
|
248
|
+
if (nameLen === 0) return -1;
|
|
249
|
+
// Attribute states. `reconsume` re-reads the current char in the next state
|
|
250
|
+
// without advancing (mirrors the HTML tokenizer's reconsume steps).
|
|
251
|
+
type St = 'beforeName' | 'name' | 'afterName' | 'beforeValue' | 'valueDQ' | 'valueSQ' | 'valueUnquoted' | 'afterValueQuoted';
|
|
252
|
+
let state: St = 'beforeName';
|
|
253
|
+
while (k < n) {
|
|
254
|
+
const ch = s[k];
|
|
255
|
+
switch (state) {
|
|
256
|
+
case 'beforeName':
|
|
257
|
+
if (isTagWhitespace(ch) || ch === '/') { k++; break; }
|
|
258
|
+
if (ch === '>') return k;
|
|
259
|
+
state = 'name'; // reconsume ch as the start of an attribute name
|
|
260
|
+
break;
|
|
261
|
+
case 'name':
|
|
262
|
+
if (isTagWhitespace(ch)) { state = 'afterName'; k++; break; }
|
|
263
|
+
if (ch === '/') { state = 'afterName'; break; }
|
|
264
|
+
if (ch === '>') return k;
|
|
265
|
+
if (ch === '=') { state = 'beforeValue'; k++; break; }
|
|
266
|
+
k++;
|
|
267
|
+
break;
|
|
268
|
+
case 'afterName':
|
|
269
|
+
if (isTagWhitespace(ch) || ch === '/') { k++; break; }
|
|
270
|
+
if (ch === '=') { state = 'beforeValue'; k++; break; }
|
|
271
|
+
if (ch === '>') return k;
|
|
272
|
+
state = 'name'; // reconsume as a new attribute name
|
|
273
|
+
break;
|
|
274
|
+
case 'beforeValue':
|
|
275
|
+
if (isTagWhitespace(ch)) { k++; break; }
|
|
276
|
+
if (ch === '"') { state = 'valueDQ'; k++; break; }
|
|
277
|
+
if (ch === "'") { state = 'valueSQ'; k++; break; }
|
|
278
|
+
if (ch === '>') return k; // attribute with a missing value
|
|
279
|
+
state = 'valueUnquoted'; // reconsume ch as the first unquoted-value byte
|
|
280
|
+
break;
|
|
281
|
+
case 'valueDQ':
|
|
282
|
+
if (ch === '"') state = 'afterValueQuoted';
|
|
283
|
+
k++;
|
|
284
|
+
break;
|
|
285
|
+
case 'valueSQ':
|
|
286
|
+
if (ch === "'") state = 'afterValueQuoted';
|
|
287
|
+
k++;
|
|
288
|
+
break;
|
|
289
|
+
case 'valueUnquoted':
|
|
290
|
+
if (isTagWhitespace(ch)) { state = 'beforeName'; k++; break; }
|
|
291
|
+
if (ch === '>') return k;
|
|
292
|
+
k++; // `"`/`'` here are ordinary unquoted-value bytes, not delimiters
|
|
293
|
+
break;
|
|
294
|
+
case 'afterValueQuoted':
|
|
295
|
+
if (isTagWhitespace(ch)) { state = 'beforeName'; k++; break; }
|
|
296
|
+
if (ch === '/') { k++; break; }
|
|
297
|
+
if (ch === '>') return k;
|
|
298
|
+
state = 'beforeName'; // reconsume as the next attribute name
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return -1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** True iff a CSS identifier character (an attribute-selector name body):
|
|
306
|
+
* ASCII letters, digits, `-`, `_`, or any non-ASCII codepoint (CSS idents
|
|
307
|
+
* allow those). Deliberately excludes `.`, `#`, `:`, `(`, `[`, whitespace.
|
|
308
|
+
* Used only by `opensAttrSelectorString`'s walk-back over `[ident=`. */
|
|
309
|
+
function isCssIdentChar(ch: string | undefined): boolean {
|
|
310
|
+
if (ch === undefined) return false;
|
|
311
|
+
const cp = ch.codePointAt(0)!;
|
|
312
|
+
if (cp > 127) return true;
|
|
313
|
+
return /[A-Za-z0-9_-]/.test(ch);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** True iff the quote at `quoteIndex` opens a CSS ATTRIBUTE-SELECTOR string —
|
|
317
|
+
* the ONE syntactic shape that switches the scan out of its prose/HTML default
|
|
318
|
+
* into XML-strict CSS-string context. It is anchored on exactly one form:
|
|
319
|
+
*
|
|
320
|
+
* `[` ident (optional one of `~ | ^ $ *`) `=` `"`/`'`
|
|
321
|
+
*
|
|
322
|
+
* The quote must be preceded by `=` (optionally one operator char), then an
|
|
323
|
+
* identifier (the attribute name), then an unclosed `[`. Nothing else opens CSS
|
|
324
|
+
* context. A bare prose `=` (`x = "…"`, or `said, x="…"` with no bracket) does
|
|
325
|
+
* NOT qualify — there is no enclosing `[`. Function-call notation (`url("…")`,
|
|
326
|
+
* `foo("…")`) is DELIBERATELY not recognized: `fn("…")` is inherently ambiguous
|
|
327
|
+
* between CSS functional notation and ordinary prose, so it stays prose and its
|
|
328
|
+
* nested markup is neutralized with faithful HTML tokenization.
|
|
329
|
+
*
|
|
330
|
+
* Why anchoring here is closed-form, not another heuristic (the safety argument
|
|
331
|
+
* is asymmetric):
|
|
332
|
+
* - `<` is escaped UNCONDITIONALLY in every context, so no output can ever
|
|
333
|
+
* carry a tag whose opener is live. This `>`-marking machinery exists only
|
|
334
|
+
* to avoid the half-escaped `<tag …>` shape.
|
|
335
|
+
* - Misclassifying a real CSS string as prose (the only direction this shrunk
|
|
336
|
+
* recognizer can err for genuine selectors, e.g. `url("<value\"") > span`)
|
|
337
|
+
* at worst OVER-escapes a later combinator `>` — a copy-paste nit, never a
|
|
338
|
+
* security hole.
|
|
339
|
+
* - Misclassifying prose as CSS is now only possible when the prose LITERALLY
|
|
340
|
+
* contains `[ident="…"]` attribute-selector syntax. Inside that shape the
|
|
341
|
+
* behavior is the required copy-pastable-selector behavior: `<` is dead, an
|
|
342
|
+
* XML-tokenizable buried tag still gets its `>` escaped, and only a
|
|
343
|
+
* non-XML-tokenizable span (`<value\"`) stays inert.
|
|
344
|
+
*
|
|
345
|
+
* So the boundary is: prose ⇒ full HTML tokenization (escape-happy); a
|
|
346
|
+
* syntactic `[ident="…"]` string ⇒ XML-strict (copy-paste-preserving). There is
|
|
347
|
+
* no third context and no punctuation heuristic left to relitigate. */
|
|
348
|
+
function opensAttrSelectorString(s: string, quoteIndex: number): boolean {
|
|
349
|
+
let j = quoteIndex - 1;
|
|
350
|
+
while (j >= 0 && (s[j] === ' ' || s[j] === '\t')) j--;
|
|
351
|
+
if (j < 0) return false;
|
|
352
|
+
if (s[j] !== '=') return false;
|
|
353
|
+
// Attribute selector `[name="v"]`: walk back over an optional operator char,
|
|
354
|
+
// the attribute name, and require an unclosed `[`.
|
|
355
|
+
let k = j - 1;
|
|
356
|
+
if (k >= 0 && '~|^$*'.includes(s[k]!)) k--;
|
|
357
|
+
while (k >= 0 && (s[k] === ' ' || s[k] === '\t')) k--;
|
|
358
|
+
if (!isCssIdentChar(s[k])) return false; // no attribute name → prose `=`
|
|
359
|
+
while (k >= 0 && isCssIdentChar(s[k])) k--;
|
|
360
|
+
while (k >= 0 && (s[k] === ' ' || s[k] === '\t')) k--;
|
|
361
|
+
return k >= 0 && s[k] === '[';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Scan over the ORIGINAL (pre-escape) text returning the set of `>` indices
|
|
365
|
+
* that must be escaped. A `>` qualifies iff it is either:
|
|
366
|
+
* - the REAL closing delimiter of a markup-like span (found via
|
|
367
|
+
* `scanMarkupClose`, which skips a `>` inside a quoted attribute so only
|
|
368
|
+
* the tag's true terminator is marked); or
|
|
369
|
+
* - the `>` completing `]]>`, which XML forbids as literal character data.
|
|
370
|
+
* Because the scan runs on the original text, a later `<` (still a literal `<`
|
|
371
|
+
* here, not yet `<`) correctly delimits spans.
|
|
372
|
+
*
|
|
373
|
+
* The scan tracks ONE piece of outer context: whether the cursor is inside a
|
|
374
|
+
* CSS attribute-selector quoted string, decided by `opensAttrSelectorString`
|
|
375
|
+
* from the one unambiguous syntactic shape `[ident="…"]` — NOT prose
|
|
376
|
+
* punctuation and NOT function-call notation. That is the distinction the
|
|
377
|
+
* security fix turns on — a `<` inside an attribute-selector string obeys the
|
|
378
|
+
* strict-XML-name gate with CSS backslash semantics, a `<` anywhere else (the
|
|
379
|
+
* prose/HTML default, including one nested inside an ordinary prose quote or a
|
|
380
|
+
* `fn("…")` argument) obeys faithful HTML tag-token semantics (see
|
|
381
|
+
* `scanMarkupClose`). The acceptance cases:
|
|
382
|
+
* - #1 `The function foo("<img\foo>payload") returned` — `foo("` is prose, not
|
|
383
|
+
* selector syntax, so `<img\foo>` is HTML-tokenized: the `\` is part of the
|
|
384
|
+
* name and the `>` is the real terminator, marked (fully neutralized).
|
|
385
|
+
* - #2 `foo("<img " >payload")` and #3 `foo("<img alt=foo" >payload")` — also
|
|
386
|
+
* prose; a quote that does not open a value after `=` never hides the real
|
|
387
|
+
* close, so the `>` is marked.
|
|
388
|
+
* - #4 `div[data-x="<value\""] > span` — the `"` sits inside `[data-x=…]`, so
|
|
389
|
+
* it IS an attribute-selector string, but `<value\"` is not real tag syntax:
|
|
390
|
+
* its name run ends on `\`, so the strict-XML name-boundary gate rejects it
|
|
391
|
+
* as inert CSS data. The `<` is still escaped, but the child-combinator `>`
|
|
392
|
+
* after `]` stays raw/copy-pastable.
|
|
393
|
+
* - #5 `a[data-x="<img alt="1 > 0\">"] > b` — also a real selector string, but
|
|
394
|
+
* here `<img …>` IS genuine tag syntax (name terminated by whitespace). The
|
|
395
|
+
* scan finds the real closing `>` (right after the literal `\"`, since XML
|
|
396
|
+
* attributes do not backslash-escape) and marks it, while the
|
|
397
|
+
* child-combinator `>` after `]` stays raw.
|
|
398
|
+
* - #8 `<img alt="1 > 0" onerror="x">` — top-level markup; the `>` inside a
|
|
399
|
+
* quoted attribute stays raw and only the true terminator is marked.
|
|
400
|
+
* - #10 `</response> matched main > section` — the tag close is marked; the
|
|
401
|
+
* `main > section` combinator stays raw.
|
|
402
|
+
* - #12 `x[a="<img alt=foo" >payload"]` — CSS context by design (it has
|
|
403
|
+
* `[a=`): under the XML gate the span is inert, `<` escaped, the `>` stays
|
|
404
|
+
* raw. The boundary carve-out, pinned so it is not rediscovered.
|
|
405
|
+
* - #13 `url("<value\"") > span` — prose (function-call notation is not
|
|
406
|
+
* recognized), so the trailing combinator `>` MAY be over-escaped; safe
|
|
407
|
+
* direction, no live markup survives.
|
|
408
|
+
* Crucially, a genuine tag inside an attribute-selector string is still marked
|
|
409
|
+
* — a hostile `<tag>` wrapped in a real selector string is neutralized rather
|
|
410
|
+
* than swallowed by the quote and left half-escaped. */
|
|
411
|
+
function markupCloseIndices(s: string): ReadonlySet<number> {
|
|
412
|
+
const marks = new Set<number>();
|
|
413
|
+
const n = s.length;
|
|
414
|
+
let i = 0;
|
|
415
|
+
while (i < n) {
|
|
416
|
+
const c = s[i];
|
|
417
|
+
if ((c === '"' || c === "'") && opensAttrSelectorString(s, i)) {
|
|
418
|
+
// A CSS attribute-selector quoted string (CSS backslash semantics). A `<` buried
|
|
419
|
+
// inside is markup only when it genuinely self-closes (its own attribute
|
|
420
|
+
// quotes toggle and a top-level `>` is reached); otherwise it is inert
|
|
421
|
+
// CSS data and any `>` outside stays raw.
|
|
422
|
+
const delim = c;
|
|
423
|
+
let k = i + 1;
|
|
424
|
+
while (k < n) {
|
|
425
|
+
const ck = s[k];
|
|
426
|
+
if (ck === '\\') {
|
|
427
|
+
k += 2;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (ck === delim) {
|
|
431
|
+
k++;
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
if (ck === '<' && startsMarkupName(s, k)) {
|
|
435
|
+
const close = scanMarkupClose(s, k, true);
|
|
436
|
+
if (close >= 0) {
|
|
437
|
+
marks.add(close);
|
|
438
|
+
k = close + 1;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
k++;
|
|
443
|
+
}
|
|
444
|
+
i = k;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (c === '<' && startsMarkupName(s, i)) {
|
|
448
|
+
const close = scanMarkupClose(s, i, false);
|
|
449
|
+
if (close >= 0) {
|
|
450
|
+
marks.add(close);
|
|
451
|
+
i = close + 1;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
i++;
|
|
456
|
+
}
|
|
457
|
+
for (let p = 0; p + 2 < n; p++) {
|
|
458
|
+
if (s[p] === ']' && s[p + 1] === ']' && s[p + 2] === '>') marks.add(p + 2);
|
|
459
|
+
}
|
|
460
|
+
return marks;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Escape for use inside XML-ish element text/attribute content. `&` and `<`
|
|
464
|
+
* are always escaped — `<` is what could open a forged tag and `&` what could
|
|
465
|
+
* open an entity. `>` is escaped SELECTIVELY, resolving two competing needs:
|
|
466
|
+
* - A bare `>` cannot break out of text content and, left verbatim, keeps
|
|
467
|
+
* page-derived CSS selectors (`div#root > main`, even `div[data-x="<value"]
|
|
468
|
+
* > span`) copy-pastable straight out of the rendered prose instead of
|
|
469
|
+
* printing as `div#root > main`. So an ordinary combinator `>` (one that
|
|
470
|
+
* does NOT close a markup-like tag span) is emitted raw.
|
|
471
|
+
* - The `>` that CLOSES a markup-like tag span (`</response>`, `<script>`,
|
|
472
|
+
* `<img alt="1 > 0" onerror="x">`, `<_hostile>`) — whose opening `<` is
|
|
473
|
+
* already `<` — is escaped to `>`, so hostile, page-derived markup can
|
|
474
|
+
* never render as a live tag or leave a half-escaped `<tag>` that a
|
|
475
|
+
* lenient XML/HTML reader might still parse. The real closing delimiter is
|
|
476
|
+
* found by a quote-aware scan (`markupCloseIndices`), so a `>` inside a
|
|
477
|
+
* quoted attribute is left raw and only the tag's true terminator escapes.
|
|
478
|
+
* - The sequence `]]>`, which XML forbids as literal character data even
|
|
479
|
+
* outside CDATA, is neutralized to `]]>` wherever it survives, so
|
|
480
|
+
* page-derived text carrying it cannot make an XML consumer reject the
|
|
481
|
+
* block.
|
|
482
|
+
* The net rule: a `>` is escaped iff it terminates a `<…>` tag-like span or
|
|
483
|
+
* completes `]]>`; every other `>` (the selector-combinator case) stays raw. */
|
|
99
484
|
function escapeXmlText(s: string): string {
|
|
100
|
-
|
|
485
|
+
const marks = markupCloseIndices(s);
|
|
486
|
+
let out = '';
|
|
487
|
+
for (let i = 0; i < s.length; i++) {
|
|
488
|
+
const c = s[i];
|
|
489
|
+
if (c === '&') out += '&';
|
|
490
|
+
else if (c === '<') out += '<';
|
|
491
|
+
else if (c === '>') out += marks.has(i) ? '>' : '>';
|
|
492
|
+
else out += c;
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
101
495
|
}
|
|
102
496
|
|
|
103
497
|
/** Escape for use inside a double-quoted XML-ish attribute value. */
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unreachable structured-output owner for the hard cut. Leaves give this
|
|
3
|
+
* module one semantic projection builder and two canonical encoders; this
|
|
4
|
+
* module alone decides which optional records are admitted.
|
|
5
|
+
*/
|
|
6
|
+
import { MAX_BOUNDED_BYTES, utf8ByteLength } from '../contracts/primitives.js';
|
|
7
|
+
|
|
8
|
+
export type OutputEncoding = 'prose' | 'json';
|
|
9
|
+
|
|
10
|
+
export interface CollectionOmittedBy {
|
|
11
|
+
readonly count_limit: number;
|
|
12
|
+
readonly byte_budget: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DisplayedCollection<T> {
|
|
16
|
+
readonly retained_total: number;
|
|
17
|
+
readonly displayed: number;
|
|
18
|
+
readonly omitted: number;
|
|
19
|
+
readonly limit: number;
|
|
20
|
+
readonly omitted_by: CollectionOmittedBy;
|
|
21
|
+
readonly records: readonly T[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProjectionCandidate<T> {
|
|
25
|
+
/** Stable ID used by both encoders and later artifact lookup. */
|
|
26
|
+
readonly id: string;
|
|
27
|
+
/** Canonical per-collection display order, independent of admission priority. */
|
|
28
|
+
readonly displayOrder: number;
|
|
29
|
+
readonly record: T;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ProjectionCollection<T> {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly retainedTotal: number;
|
|
35
|
+
readonly limit: number;
|
|
36
|
+
/** Eligible candidates in this collection's canonical record order. */
|
|
37
|
+
readonly candidates: readonly ProjectionCandidate<T>[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type DisplayedCollections<T> = Readonly<Record<string, DisplayedCollection<T>>>;
|
|
41
|
+
|
|
42
|
+
export interface CanonicalStructuredEncoders<S> {
|
|
43
|
+
readonly prose: (semantic: S) => string;
|
|
44
|
+
readonly json: (semantic: S) => string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AllocateStructuredProjection<S, T> {
|
|
48
|
+
readonly collections: readonly ProjectionCollection<T>[];
|
|
49
|
+
/**
|
|
50
|
+
* Every eligible candidate exactly once, in the leaf's declared global
|
|
51
|
+
* consideration order. This represents priority groups and cross-collection
|
|
52
|
+
* round robin without letting the allocator invent command semantics.
|
|
53
|
+
*/
|
|
54
|
+
readonly candidateOrder: readonly { readonly collection: string; readonly candidate: ProjectionCandidate<T> }[];
|
|
55
|
+
/** Builds the one semantic input shared by the two encoders. */
|
|
56
|
+
readonly build: (collections: DisplayedCollections<T>) => S;
|
|
57
|
+
readonly encoders: CanonicalStructuredEncoders<S>;
|
|
58
|
+
readonly maxBytes?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AllocatedStructuredProjection<S, T> {
|
|
62
|
+
readonly semantic: S;
|
|
63
|
+
readonly collections: DisplayedCollections<T>;
|
|
64
|
+
readonly prose: string;
|
|
65
|
+
readonly json: string;
|
|
66
|
+
readonly bytes: Readonly<Record<OutputEncoding, number>>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface MutableCollection<T> {
|
|
70
|
+
readonly definition: ProjectionCollection<T>;
|
|
71
|
+
readonly eligible: readonly ProjectionCandidate<T>[];
|
|
72
|
+
readonly displayed: ProjectionCandidate<T>[];
|
|
73
|
+
byteBudget: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assertSafeUint(value: number, field: string): void {
|
|
77
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${field} must be a nonnegative safe integer`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function verifyDefinitions<T>(collections: readonly ProjectionCollection<T>[]): void {
|
|
81
|
+
const names = new Set<string>();
|
|
82
|
+
const ids = new Set<string>();
|
|
83
|
+
for (const collection of collections) {
|
|
84
|
+
if (!collection.name) throw new Error('collection name must not be empty');
|
|
85
|
+
if (names.has(collection.name)) throw new Error(`duplicate collection name: ${collection.name}`);
|
|
86
|
+
names.add(collection.name);
|
|
87
|
+
assertSafeUint(collection.retainedTotal, `${collection.name}.retainedTotal`);
|
|
88
|
+
assertSafeUint(collection.limit, `${collection.name}.limit`);
|
|
89
|
+
if (collection.candidates.length > collection.retainedTotal) throw new Error(`${collection.name} has more candidates than retainedTotal`);
|
|
90
|
+
const eligible = Math.min(collection.retainedTotal, collection.limit);
|
|
91
|
+
if (collection.candidates.length !== eligible) throw new Error(`${collection.name} candidates must contain exactly min(retainedTotal, limit) records`);
|
|
92
|
+
const displayOrders = new Set<number>();
|
|
93
|
+
for (const candidate of collection.candidates) {
|
|
94
|
+
if (!candidate.id) throw new Error(`${collection.name} candidate has empty stable id`);
|
|
95
|
+
assertSafeUint(candidate.displayOrder, `${collection.name}.${candidate.id}.displayOrder`);
|
|
96
|
+
if (displayOrders.has(candidate.displayOrder)) throw new Error(`${collection.name} repeats displayOrder ${candidate.displayOrder}`);
|
|
97
|
+
displayOrders.add(candidate.displayOrder);
|
|
98
|
+
if (ids.has(candidate.id)) throw new Error(`duplicate stable candidate id: ${candidate.id}`);
|
|
99
|
+
ids.add(candidate.id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function displayedCollections<T>(states: readonly MutableCollection<T>[]): DisplayedCollections<T> {
|
|
105
|
+
return Object.fromEntries(states.map((state) => {
|
|
106
|
+
const { retainedTotal, limit, name } = state.definition;
|
|
107
|
+
const displayed = state.displayed.length;
|
|
108
|
+
const countLimit = Math.max(0, retainedTotal - limit);
|
|
109
|
+
const omitted = retainedTotal - displayed;
|
|
110
|
+
const byteBudget = Math.min(retainedTotal, limit) - displayed;
|
|
111
|
+
if (byteBudget !== state.byteBudget) throw new Error(`allocator corruption for ${name}`);
|
|
112
|
+
return [name, {
|
|
113
|
+
retained_total: retainedTotal,
|
|
114
|
+
displayed,
|
|
115
|
+
omitted,
|
|
116
|
+
limit,
|
|
117
|
+
omitted_by: { count_limit: countLimit, byte_budget: byteBudget },
|
|
118
|
+
records: [...state.displayed]
|
|
119
|
+
.sort((left, right) => left.displayOrder - right.displayOrder)
|
|
120
|
+
.map((candidate) => candidate.record),
|
|
121
|
+
} satisfies DisplayedCollection<T>];
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function encode<S, T>(states: readonly MutableCollection<T>, build: (collections: DisplayedCollections<T>) => S, encoders: CanonicalStructuredEncoders<S>): AllocatedStructuredProjection<S, T> {
|
|
126
|
+
const collections = displayedCollections(states);
|
|
127
|
+
const semantic = build(collections);
|
|
128
|
+
const prose = encoders.prose(semantic);
|
|
129
|
+
const json = encoders.json(semantic);
|
|
130
|
+
return { semantic, collections, prose, json, bytes: { prose: utf8ByteLength(prose), json: utf8ByteLength(json) } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function fits<S, T>(projection: AllocatedStructuredProjection<S, T>, maxBytes: number): boolean {
|
|
134
|
+
return projection.bytes.prose <= maxBytes && projection.bytes.json <= maxBytes;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Allocate whole records once for both encodings. A candidate is tested against
|
|
139
|
+
* the projection with every not-yet-considered eligible record counted as a
|
|
140
|
+
* byte-budget omission; this makes the final omission metadata part of every
|
|
141
|
+
* admission decision. A record that does not fit never blocks later records.
|
|
142
|
+
*/
|
|
143
|
+
export function allocateStructuredProjection<S, T>(input: AllocateStructuredProjection<S, T>): AllocatedStructuredProjection<S, T> {
|
|
144
|
+
const maxBytes = input.maxBytes ?? MAX_BOUNDED_BYTES;
|
|
145
|
+
assertSafeUint(maxBytes, 'maxBytes');
|
|
146
|
+
if (maxBytes > MAX_BOUNDED_BYTES) throw new Error(`maxBytes must not exceed ${MAX_BOUNDED_BYTES}`);
|
|
147
|
+
verifyDefinitions(input.collections);
|
|
148
|
+
const states = input.collections.map<MutableCollection<T>>((definition) => ({
|
|
149
|
+
definition,
|
|
150
|
+
eligible: definition.candidates,
|
|
151
|
+
displayed: [],
|
|
152
|
+
byteBudget: definition.candidates.length,
|
|
153
|
+
}));
|
|
154
|
+
const stateByName = new Map(states.map((state) => [state.definition.name, state]));
|
|
155
|
+
const expectedCandidates = new Map(states.flatMap((state) => state.eligible.map((candidate) => [candidate.id, { state, candidate }] as const)));
|
|
156
|
+
const orderedIds = new Set<string>();
|
|
157
|
+
for (const entry of input.candidateOrder) {
|
|
158
|
+
const expected = expectedCandidates.get(entry.candidate.id);
|
|
159
|
+
if (!expected || expected.state !== stateByName.get(entry.collection) || expected.candidate !== entry.candidate) {
|
|
160
|
+
throw new Error(`candidateOrder contains an unknown or mismatched candidate: ${entry.candidate.id}`);
|
|
161
|
+
}
|
|
162
|
+
if (orderedIds.has(entry.candidate.id)) throw new Error(`candidateOrder repeats candidate: ${entry.candidate.id}`);
|
|
163
|
+
orderedIds.add(entry.candidate.id);
|
|
164
|
+
}
|
|
165
|
+
if (orderedIds.size !== expectedCandidates.size) throw new Error('candidateOrder must contain every eligible candidate exactly once');
|
|
166
|
+
|
|
167
|
+
for (const { collection, candidate } of input.candidateOrder) {
|
|
168
|
+
const state = stateByName.get(collection)!;
|
|
169
|
+
state.displayed.push(candidate);
|
|
170
|
+
state.byteBudget--;
|
|
171
|
+
if (!fits(encode(states, input.build, input.encoders), maxBytes)) {
|
|
172
|
+
state.displayed.pop();
|
|
173
|
+
state.byteBudget++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let projection = encode(states, input.build, input.encoders);
|
|
178
|
+
// Decimal omission metadata can grow after later candidates are rejected.
|
|
179
|
+
// Remove whole records (never encoded prefixes) until the final semantic
|
|
180
|
+
// projection itself satisfies both independent caps.
|
|
181
|
+
while (!fits(projection, maxBytes)) {
|
|
182
|
+
const state = [...states].reverse().find((candidate) => candidate.displayed.length > 0);
|
|
183
|
+
if (!state) throw new Error(`mandatory structured projection exceeds ${maxBytes} bytes`);
|
|
184
|
+
state.displayed.pop();
|
|
185
|
+
state.byteBudget++;
|
|
186
|
+
projection = encode(states, input.build, input.encoders);
|
|
187
|
+
}
|
|
188
|
+
return projection;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Cap a UTF-8 string at a code-point boundary and report exact byte omission arithmetic. */
|
|
192
|
+
export function capUtf8(value: string, maxBytes: number): { readonly value: string; readonly bytes_omitted: number } {
|
|
193
|
+
assertSafeUint(maxBytes, 'maxBytes');
|
|
194
|
+
const sourceBytes = utf8ByteLength(value);
|
|
195
|
+
if (sourceBytes <= maxBytes) return { value, bytes_omitted: 0 };
|
|
196
|
+
let end = 0;
|
|
197
|
+
let bytes = 0;
|
|
198
|
+
for (const codePoint of value) {
|
|
199
|
+
const next = utf8ByteLength(codePoint);
|
|
200
|
+
if (bytes + next > maxBytes) break;
|
|
201
|
+
bytes += next;
|
|
202
|
+
end += codePoint.length;
|
|
203
|
+
}
|
|
204
|
+
return { value: value.slice(0, end), bytes_omitted: sourceBytes - bytes };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Exact scalar and collection exhaustion calculation shared by both encoders. */
|
|
208
|
+
export function projectionExhaustive<T>(collections: DisplayedCollections<T>, scalarBytesOmittedTotal: number): boolean {
|
|
209
|
+
assertSafeUint(scalarBytesOmittedTotal, 'scalarBytesOmittedTotal');
|
|
210
|
+
return scalarBytesOmittedTotal === 0 && Object.values(collections).every((collection) => collection.omitted === 0);
|
|
211
|
+
}
|