jq79 0.4.2 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jq79",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Mini reactive component library: single-file components, Svelte-style setup scripts, fine-grained proxy reactivity. Single-file build, zero dependencies.",
5
5
  "keywords": [
6
6
  "reactive",
package/src/dom.ts CHANGED
@@ -65,12 +65,77 @@ export function isSafeUrl(value: string): boolean {
65
65
  }
66
66
  }
67
67
 
68
+ // la política de destinos: decide si un href/src ya seguro por protocolo
69
+ // puede apuntar a donde apunta. Restringe *sobre* el chequeo de protocolo,
70
+ // nunca en su lugar
71
+ export type AllowUrl = (url: URL, tag: string, attr: string) => boolean;
72
+
73
+ export type SanitizeOptions = { allowUrl?: AllowUrl };
74
+
75
+ const DEFAULT_PORTS: Record<string, string> = { 'https:': '443', 'http:': '80' };
76
+
77
+ type HostPattern = { host: RegExp; port: string | null };
78
+
79
+ // "host[:puerto]": `*` casa exactamente UNA etiqueta dns - la regla de los
80
+ // certificados TLS, no la de CSP: *.germade.dev casa a.germade.dev, pero ni
81
+ // germade.dev (escribe los dos para incluir el apex) ni a.b.germade.dev.
82
+ // Sin puerto casa cualquiera; un patrón inválido devuelve null y no casa
83
+ // nada - una política rota cierra, no abre
84
+ function compileHostPattern(pattern: string): HostPattern | null {
85
+ const match = pattern.trim().toLowerCase().match(/^([a-z\d*][a-z\d.*-]*?)(?::(\d{1,5}|\*))?$/);
86
+ if (!match) return null;
87
+ const [, host, port] = match;
88
+ const labels = host.split('.');
89
+ if (labels.some(label => label !== '*' && !/^[a-z\d-]+$/.test(label))) return null;
90
+ // las etiquetas validadas no llevan metacaracteres de regex, así que no
91
+ // hay nada que escapar; los puntos los pone el join
92
+ const re = new RegExp(`^${labels.map(label => (label === '*' ? '[^.]+' : label)).join('\\.')}$`);
93
+ return { host: re, port: !port || port === '*' ? null : port };
94
+ }
95
+
96
+ // compila una lista de patrones (string separado por comas, o array) en un
97
+ // predicado AllowUrl. El puerto comparado es el *efectivo* de la URL (el
98
+ // explícito, o el del esquema), así "germade.dev:443" casa https://germade.dev.
99
+ // Una URL sin host (mailto:) no casa ningún patrón: los patrones hablan de
100
+ // hosts - la forma función de la política puede admitirla si quiere
101
+ export const allowedHosts = (patterns: string | string[]): AllowUrl => {
102
+ const compiled = (Array.isArray(patterns) ? patterns : patterns.split(','))
103
+ .map(compileHostPattern)
104
+ .filter((p): p is HostPattern => p !== null);
105
+ return url => {
106
+ const host = url.hostname.toLowerCase();
107
+ const port = url.port || DEFAULT_PORTS[url.protocol] || '';
108
+ return compiled.some(p => p.host.test(host) && (p.port === null || p.port === port));
109
+ };
110
+ };
111
+
112
+ // consulta la política con la URL resuelta contra la página, para que una
113
+ // URL relativa se juzgue como el destino same-origin que realmente es. Un
114
+ // predicado que lanza, o una URL que no parsea, es un no
115
+ function consultAllowUrl(allowUrl: AllowUrl, value: string, tag: string, attr: string): boolean {
116
+ try {
117
+ return !!allowUrl(new URL(value, document.baseURI), tag, attr);
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ // el saneado es recursivo, así que la profundidad del input es profundidad
124
+ // de pila. 512 es lo que toleran los parsers de los navegadores antes de
125
+ // aplanar el anidamiento, con lo que ningún documento legítimo pierde nada -
126
+ // y superar el límite lanza un RangeError con nombre, en vez de reventar la
127
+ // pila en algún punto indeterminado más arriba
128
+ const MAX_SANITIZE_DEPTH = 512;
129
+
68
130
  // copia los hijos de `source` en `target`, saneando los elementos y clonando
69
131
  // el texto; cualquier otra cosa (comentarios, etc.) se descarta
70
- function appendSanitizedChildren(source: ParentNode, target: HTMLElement): void {
132
+ function appendSanitizedChildren(source: ParentNode, target: HTMLElement, depth: number, allowUrl?: AllowUrl): void {
133
+ if (depth > MAX_SANITIZE_DEPTH) {
134
+ throw new RangeError(`jq79: sanitizeHTML input nests deeper than ${MAX_SANITIZE_DEPTH} elements`);
135
+ }
71
136
  for (const child of Array.from(source.childNodes)) {
72
137
  if (child.nodeType === Node.ELEMENT_NODE) {
73
- const sanitizedChild = sanitizeNode(child as HTMLElement);
138
+ const sanitizedChild = sanitizeNode(child as HTMLElement, depth, allowUrl);
74
139
  if (sanitizedChild) target.appendChild(sanitizedChild);
75
140
  } else if (child.nodeType === Node.TEXT_NODE) {
76
141
  target.appendChild(child.cloneNode());
@@ -79,7 +144,7 @@ function appendSanitizedChildren(source: ParentNode, target: HTMLElement): void
79
144
  }
80
145
 
81
146
  // sanea un elemento (los llamadores solo pasan nodos ELEMENT_NODE)
82
- function sanitizeNode(node: HTMLElement): HTMLElement | null {
147
+ function sanitizeNode(node: HTMLElement, depth: number, allowUrl?: AllowUrl): HTMLElement | null {
83
148
  const tag = node.tagName.toLowerCase();
84
149
  if (!ALLOWED_TAGS.has(tag)) return null; // tag no permitido → se descarta el nodo entero
85
150
 
@@ -91,7 +156,10 @@ function sanitizeNode(node: HTMLElement): HTMLElement | null {
91
156
  const allowedGlobal = ALLOWED_ATTR['*']?.has(name);
92
157
  if (!allowedForTag && !allowedGlobal) continue;
93
158
 
94
- if ((name === 'href' || name === 'src') && !isSafeUrl(attr.value)) continue;
159
+ if (name === 'href' || name === 'src') {
160
+ if (!isSafeUrl(attr.value)) continue;
161
+ if (allowUrl && !consultAllowUrl(allowUrl, attr.value, tag, name)) continue;
162
+ }
95
163
 
96
164
  clean.setAttribute(name, attr.value);
97
165
  }
@@ -99,16 +167,16 @@ function sanitizeNode(node: HTMLElement): HTMLElement | null {
99
167
  // fuerza rel seguro en enlaces (target nunca se copia: no está permitido)
100
168
  if (tag === 'a') clean.setAttribute('rel', 'noopener noreferrer');
101
169
 
102
- appendSanitizedChildren(node, clean);
170
+ appendSanitizedChildren(node, clean, depth + 1, allowUrl);
103
171
 
104
172
  return clean;
105
173
  }
106
174
 
107
- export function sanitizeHTML(html: string): string {
175
+ export function sanitizeHTML(html: string, options?: SanitizeOptions): string {
108
176
  const doc = new DOMParser().parseFromString(html, 'text/html');
109
177
  const container = document.createElement('div');
110
178
 
111
- appendSanitizedChildren(doc.body, container);
179
+ appendSanitizedChildren(doc.body, container, 0, options?.allowUrl);
112
180
 
113
181
  return container.innerHTML;
114
182
  }
package/src/jq79.ts CHANGED
@@ -1,5 +1,6 @@
1
1
 
2
- import { $, $$, $create, sanitizeHTML } from "./dom"
2
+ import { $, $$, $create, sanitizeHTML, allowedHosts } from "./dom"
3
+ import type { AllowUrl } from "./dom"
3
4
  import { $reactive, untracked, createEffectScope } from "./reactive"
4
5
  import type { ReactiveDeepData, EffectScope } from "./reactive"
5
6
  import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps, type PropDecl } from "./transform"
@@ -91,7 +92,7 @@ const interpolate = (template: string, scope: Record<string, any>): string =>
91
92
  template.replace(/{{\s*([\s\S]+?)\s*}}/g, (_, expr) => evalExpr(expr, scope) ?? "")
92
93
 
93
94
 
94
- const CONTROL_ATTRS = new Set([":attrs", ":class", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html"])
95
+ const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed"])
95
96
  // `item in items`, `item, i in items`, `(value, key) in props` - the second
96
97
  // binding is the array index or the object key, parens optional (Vue-style).
97
98
  // The list expression can span lines, so it matches [\s\S] rather than `.`
@@ -308,6 +309,26 @@ const classNames = (value: any): string[] => {
308
309
  return []
309
310
  }
310
311
 
312
+ // what :html.allowed accepts, normalized to an AllowUrl predicate: host
313
+ // patterns (a comma-separated string or an array - see allowedHosts in
314
+ // ./dom) or a function (url: URL, tag, attr) => boolean. Anything else -
315
+ // including a policy expression that evaluates to undefined - denies every
316
+ // destination: the attribute declares the intent to restrict, so a broken
317
+ // policy fails closed, and so does a predicate that throws
318
+ const normalizeAllowUrl = (policy: any): AllowUrl => {
319
+ if (typeof policy === "function") {
320
+ return (url, tag, attr) => {
321
+ try {
322
+ return !!policy(url, tag, attr)
323
+ } catch {
324
+ return false
325
+ }
326
+ }
327
+ }
328
+ if (typeof policy === "string" || Array.isArray(policy)) return allowedHosts(policy)
329
+ return () => false
330
+ }
331
+
311
332
  // renders a single element node: static attrs, @event listeners, a reactive
312
333
  // :attrs object, and its content - :text/:html override the element's own
313
334
  // children with a reactive textContent/innerHTML, otherwise children render
@@ -389,17 +410,49 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
389
410
  // :text="expr" sets textContent reactively, replacing any children.
390
411
  // :html="expr" sets innerHTML reactively, sanitizing the value first so
391
412
  // untrusted content can't inject scripts/attributes (see sanitizeHTML in
392
- // ./dom). Both skip rendering the element's own children/interpolation
413
+ // ./dom). Both skip rendering the element's own children/interpolation.
414
+ // :html.allowed="expr" adds a destination policy for the content's
415
+ // href/src URLs - evaluated in the same effect, so a policy held in the
416
+ // store is as reactive as the content itself
393
417
  const textExpr = node.attrs[":text"]
394
418
  const htmlExpr = node.attrs[":html"]
419
+ const allowedExpr = node.attrs[":html.allowed"]
420
+ if (allowedExpr !== undefined && htmlExpr === undefined) {
421
+ console.warn("jq79: :html.allowed without :html on the same element does nothing")
422
+ }
395
423
  if (textExpr !== undefined) {
396
424
  fx.effect(() => { el.textContent = String(evalExpr(textExpr, scope) ?? "") })
397
425
  } else if (htmlExpr !== undefined) {
398
- fx.effect(() => { el.innerHTML = sanitizeHTML(String(evalExpr(htmlExpr, scope) ?? "")) })
426
+ fx.effect(() => {
427
+ const options = allowedExpr !== undefined ? { allowUrl: normalizeAllowUrl(evalExpr(allowedExpr, scope)) } : undefined
428
+ el.innerHTML = sanitizeHTML(String(evalExpr(htmlExpr, scope) ?? ""), options)
429
+ })
399
430
  } else {
400
431
  el.appendChild(renderNodes(node.children, scope, fx, shadow))
401
432
  }
402
433
 
434
+ // :value / :checked / :selected write the DOM *property*, not the
435
+ // attribute - the attribute is only a form control's default, and detaches
436
+ // the moment the user interacts (which is why :attrs="{ value }" stops
437
+ // driving a typed-in input). One-way, store -> DOM: the way back stays an
438
+ // explicit @input/@change. :value skips the write when the property
439
+ // already holds the string, so an unrelated re-run can't move the caret of
440
+ // the input the user is typing into. Registered after the children render:
441
+ // :value on a <select> can only pick an <option> that already exists
442
+ const valueExpr = node.attrs[":value"]
443
+ if (valueExpr !== undefined) {
444
+ fx.effect(() => {
445
+ const value = String(evalExpr(valueExpr, scope) ?? "")
446
+ if ((el as HTMLInputElement).value !== value) (el as HTMLInputElement).value = value
447
+ })
448
+ }
449
+ ;([":checked", ":selected"] as const).forEach(attr => {
450
+ const expr = node.attrs[attr]
451
+ if (expr === undefined) return
452
+ const prop = attr.slice(1) as "checked" | "selected"
453
+ fx.effect(() => { (el as any)[prop] = !!evalExpr(expr, scope) })
454
+ })
455
+
403
456
  return el
404
457
  }
405
458
 
package/src/transform.ts CHANGED
@@ -22,7 +22,9 @@
22
22
 
23
23
  type SetupTransform = { vars: string[]; code: string }
24
24
 
25
- const DECLARATION_RE = /(?:let|var|const)\s+([A-Za-z_$][\w$]*)/y
25
+ // a declaration whose target is an identifier (`let x`), an object pattern
26
+ // (`let { a }`, space optional) or an array pattern (`let [x]`)
27
+ const DECLARATION_START_RE = /(?:let|var|const)(?:\s+(?=[A-Za-z_$])|\s*(?=[{[]))/y
26
28
  const REACTIVE_LABEL_RE = /\$:\s*/y
27
29
  const IMPORT_CALL_RE = /import(?=\s*\()/y
28
30
  const REACTIVE_ASSIGN_RE = /\$:\s*([A-Za-z_$][\w$]*)\s*=(?!=)/y
@@ -139,6 +141,28 @@ const skipRegex = (src: string, start: number): number => {
139
141
  // inserts the semicolon before them
140
142
  const CONTINUATION_RE = /^(\?\.|\?\?|&&|\|\||\*\*|[.,+\-*/%&|^<>=?:([])/
141
143
 
144
+ // the last meaningful character before `at`: walks back past whitespace and
145
+ // block comments, the way regexAllowed does. A line comment can't be skipped
146
+ // from behind (its start is only findable forwards), so a line ending in one
147
+ // reports the comment's text instead - callers treat that as "not the char I
148
+ // was looking for", which degrades to ending the statement, exactly as
149
+ // before this helper existed
150
+ const lastMeaningfulBefore = (src: string, at: number): string => {
151
+ let i = at - 1
152
+ while (i >= 0) {
153
+ const ch = src[i]
154
+ if (/\s/.test(ch)) { i--; continue }
155
+ if (ch === "/" && src[i - 1] === "*") {
156
+ const open = src.lastIndexOf("/*", i - 2)
157
+ if (open === -1) return ""
158
+ i = open - 1
159
+ continue
160
+ }
161
+ return ch
162
+ }
163
+ return ""
164
+ }
165
+
142
166
  // end of a statement starting at `start`: the first `;` or line break that
143
167
  // isn't inside a string/comment or unbalanced brackets. A line break only
144
168
  // ends the statement if the next line can't continue it, so leading-dot
@@ -147,6 +171,11 @@ const CONTINUATION_RE = /^(\?\.|\?\?|&&|\|\||\*\*|[.,+\-*/%&|^<>=?:([])/
147
171
  // $: total = items
148
172
  // .filter(item => item.active) <- still the same statement
149
173
  // .length
174
+ //
175
+ // ...and only if the current line *can* end it: a line whose last meaningful
176
+ // character is `,` or `=` left its expression incomplete (a multi-line
177
+ // declarator list writes exactly this), so the statement continues - the
178
+ // same ASI call as the leading-token check, made from the other side
150
179
  const findStatementEnd = (src: string, start: number): number => {
151
180
  let depth = 0
152
181
  let i = start
@@ -161,7 +190,10 @@ const findStatementEnd = (src: string, start: number): number => {
161
190
  else if (depth <= 0 && ch === ";") return i
162
191
  else if (depth <= 0 && ch === "\n") {
163
192
  const next = skipToToken(src, i + 1)
164
- if (next >= src.length || !CONTINUATION_RE.test(src.slice(next, next + 2))) return i
193
+ const continues =
194
+ next < src.length &&
195
+ (CONTINUATION_RE.test(src.slice(next, next + 2)) || [",", "="].includes(lastMeaningfulBefore(src, i)))
196
+ if (!continues) return i
165
197
  i = next
166
198
  continue
167
199
  }
@@ -170,6 +202,108 @@ const findStatementEnd = (src: string, start: number): number => {
170
202
  return src.length
171
203
  }
172
204
 
205
+ // ---------------------------------------------------------------------------
206
+ // top-level declarations. `let x = 1` loses its keyword and becomes a scope
207
+ // assignment (x pre-declared on the store). A destructuring declarator
208
+ // becomes an *assignment pattern*: inside `with`, `({ a, b } = obj)` writes
209
+ // every binding through the reactive proxy - which is what makes it reactive.
210
+ // The parens keep the `{` from opening a block, and a leading `;` keeps the
211
+ // `(` from gluing onto the previous line as a call. Multi-declarator
212
+ // statements (`let a = 1, b = 2`) register every binding, not just the first.
213
+ // These lean on the pattern helpers defined with the props signature below
214
+ // (splitTopLevel, indexOfTopLevel, defaultAssignIndex); the scanner only
215
+ // runs long after the module evaluates, so the order is cosmetic
216
+ // ---------------------------------------------------------------------------
217
+
218
+ type Declarator = { raw: string; codeEnd: number }
219
+
220
+ // splits a declarator list at top-level commas, keeping each segment's raw
221
+ // text (layout and comments included) and where its last meaningful token
222
+ // ends - the spot a closing paren must go, so a trailing comment can't
223
+ // swallow it
224
+ const splitDeclarators = (src: string): Declarator[] => {
225
+ const parts: Declarator[] = []
226
+ let depth = 0
227
+ let start = 0
228
+ let lastEnd = 0
229
+ const flush = (end: number) => {
230
+ parts.push({ raw: src.slice(start, end), codeEnd: Math.max(0, lastEnd - start) })
231
+ start = end + 1
232
+ lastEnd = start
233
+ }
234
+ let i = 0
235
+ while (i < src.length) {
236
+ const ch = src[i]
237
+ if (ch === "'" || ch === '"' || ch === "`") { i = skipString(src, i); lastEnd = i; continue }
238
+ if (ch === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
239
+ if (ch === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
240
+ if (ch === "/" && regexAllowed(src, i)) { i = skipRegex(src, i); lastEnd = i; continue }
241
+ if ("([{".includes(ch)) depth++
242
+ else if (")]}".includes(ch)) depth--
243
+ else if (ch === "," && depth <= 0) { flush(i); i++; continue }
244
+ if (!/\s/.test(ch)) lastEnd = i + 1
245
+ i++
246
+ }
247
+ flush(src.length)
248
+ return parts
249
+ }
250
+
251
+ // the *binding* names a destructuring pattern declares - unlike
252
+ // parsePropsPattern, which answers "which props" (the keys), this answers
253
+ // "which variables": `{ a: x }` binds x, `{ a: { b } }` binds b,
254
+ // `[x, ...rest]` binds x and rest. Defaults are stripped; what remains is a
255
+ // nested pattern (recurse) or the bound identifier
256
+ const patternBindings = (src: string): string[] => {
257
+ const pattern = src.trim()
258
+ if (!pattern.startsWith("{") && !pattern.startsWith("[")) {
259
+ return IDENTIFIER_RE.test(pattern) ? [pattern] : []
260
+ }
261
+ const names: string[] = []
262
+ for (let part of splitTopLevel(pattern.slice(1, patternCloseIndex(pattern)))) {
263
+ if (part.startsWith("...")) part = part.slice(3).trim()
264
+ const assign = defaultAssignIndex(part)
265
+ if (assign !== -1) part = part.slice(0, assign).trim()
266
+ if (pattern.startsWith("{")) {
267
+ const colon = indexOfTopLevel(part, ":")
268
+ if (colon !== -1) {
269
+ names.push(...patternBindings(part.slice(colon + 1)))
270
+ continue
271
+ }
272
+ }
273
+ names.push(...patternBindings(part))
274
+ }
275
+ return names
276
+ }
277
+
278
+ // one `let/var/const` declarator list, rewritten to scope assignments.
279
+ // Each segment's initializer is re-scanned so an `import()` inside it is
280
+ // rewritten like anywhere else - nothing else can match in there, since a
281
+ // nested top-level declaration inside a declarator is a SyntaxError in JS
282
+ const rewriteDeclarators = (src: string): SetupTransform => {
283
+ const vars: string[] = []
284
+ const rewritten = splitDeclarators(src).map(({ raw, codeEnd }) => {
285
+ const lead = raw.match(/^\s*/)![0]
286
+ if (codeEnd <= lead.length) return { text: raw, empty: true }
287
+ const body = raw.slice(lead.length, codeEnd)
288
+ const tail = raw.slice(codeEnd)
289
+ const assign = defaultAssignIndex(body)
290
+ const target = (assign === -1 ? body : body.slice(0, assign)).trim()
291
+ const isPattern = body[0] === "{" || body[0] === "["
292
+ if (isPattern) vars.push(...patternBindings(target))
293
+ else if (IDENTIFIER_RE.test(target)) vars.push(target)
294
+ const code = transformSetupScript(body).code
295
+ return { text: `${lead}${isPattern ? `(${code})` : code}${tail}`, empty: false }
296
+ })
297
+
298
+ // a trailing comment-only segment (`let a = 1, // note` cut at its line
299
+ // end) is re-attached without its comma, so the output stays a statement
300
+ const tail: string[] = []
301
+ while (rewritten.length && rewritten[rewritten.length - 1].empty) tail.unshift(rewritten.pop()!.text)
302
+ let code = rewritten.map(part => part.text).join(",") + tail.join("")
303
+ if (code.trimStart().startsWith("(")) code = `;${code}`
304
+ return { vars, code }
305
+ }
306
+
173
307
  export const transformSetupScript = (src: string): SetupTransform => {
174
308
  const vars: string[] = []
175
309
  let out = ""
@@ -217,12 +351,15 @@ export const transformSetupScript = (src: string): SetupTransform => {
217
351
  }
218
352
 
219
353
  if (depth === 0 && atStatementStart) {
220
- DECLARATION_RE.lastIndex = i
221
- const decl = DECLARATION_RE.exec(src)
354
+ DECLARATION_START_RE.lastIndex = i
355
+ const decl = DECLARATION_START_RE.exec(src)
222
356
  if (decl) {
223
- vars.push(decl[1])
224
- out += decl[1]
225
- i += decl[0].length
357
+ const start = i + decl[0].length
358
+ const end = findStatementEnd(src, start)
359
+ const { vars: names, code } = rewriteDeclarators(src.slice(start, end))
360
+ vars.push(...names)
361
+ out += code
362
+ i = end
226
363
  atStatementStart = false
227
364
  continue
228
365
  }
@@ -342,6 +479,24 @@ export type PropDecl = { name: string; default?: string }
342
479
 
343
480
  const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
344
481
 
482
+ // index of the bracket that closes the one opening at src[0], skipping
483
+ // strings, comments and regex literals; src.length when unbalanced
484
+ const patternCloseIndex = (src: string): number => {
485
+ let depth = 0
486
+ let i = 0
487
+ while (i < src.length) {
488
+ const ch = src[i]
489
+ if (ch === "'" || ch === '"' || ch === "`") { i = skipString(src, i); continue }
490
+ if (ch === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
491
+ if (ch === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
492
+ if (ch === "/" && regexAllowed(src, i)) { i = skipRegex(src, i); continue }
493
+ if ("([{".includes(ch)) depth++
494
+ else if (")]}".includes(ch) && --depth === 0) return i
495
+ i++
496
+ }
497
+ return src.length
498
+ }
499
+
345
500
  // index of the first `ch` at bracket depth 0, skipping strings and comments
346
501
  const indexOfTopLevel = (src: string, ch: string): number => {
347
502
  let depth = 0
@@ -407,18 +562,7 @@ export const parsePropsPattern = (pattern: string | undefined): PropDecl[] | nul
407
562
 
408
563
  // to the `}` that closes the pattern, so a parameter's own default value
409
564
  // (`({ label } = {})`) is left out of it
410
- let depth = 0
411
- let close = 0
412
- while (close < src.length) {
413
- const ch = src[close]
414
- if (ch === "'" || ch === '"' || ch === "`") { close = skipString(src, close); continue }
415
- if (ch === "/" && src[close + 1] === "/") { close = skipLineComment(src, close); continue }
416
- if (ch === "/" && src[close + 1] === "*") { close = skipBlockComment(src, close); continue }
417
- if (ch === "/" && regexAllowed(src, close)) { close = skipRegex(src, close); continue }
418
- if ("([{".includes(ch)) depth++
419
- else if (")]}".includes(ch) && --depth === 0) break
420
- close++
421
- }
565
+ const close = patternCloseIndex(src)
422
566
  if (close >= src.length) return null // unbalanced: not a pattern we can read
423
567
 
424
568
  const props: PropDecl[] = []