jq79 0.4.1 → 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/dist/dom.d.ts +6 -1
- package/dist/jq79.cjs +11 -11
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.global.js +11 -11
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +11 -11
- package/dist/jq79.js.map +1 -1
- package/package.json +1 -1
- package/src/dom.ts +75 -7
- package/src/jq79.ts +90 -4
- package/src/transform.ts +163 -19
package/package.json
CHANGED
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 (
|
|
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", ":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 `.`
|
|
@@ -294,6 +295,40 @@ const createWithScope = (expr: string, scope: Record<string, any>): Record<strin
|
|
|
294
295
|
})
|
|
295
296
|
}
|
|
296
297
|
|
|
298
|
+
// what :class accepts, flattened to single class tokens: a string of
|
|
299
|
+
// space-separated names, an array (entries normalized recursively), or an
|
|
300
|
+
// object whose truthy-valued keys are the names (a key may itself hold
|
|
301
|
+
// several). Everything else - null, false, numbers - contributes nothing, so
|
|
302
|
+
// `cond && 'active'` reads naturally. The object form reads each value, so a
|
|
303
|
+
// store-backed flag is tracked per key
|
|
304
|
+
const classNames = (value: any): string[] => {
|
|
305
|
+
if (typeof value === "string") return value.split(/\s+/).filter(Boolean)
|
|
306
|
+
if (Array.isArray(value)) return value.flatMap(classNames)
|
|
307
|
+
if (value !== null && typeof value === "object")
|
|
308
|
+
return Object.entries(value).flatMap(([name, on]) => (on ? classNames(name) : []))
|
|
309
|
+
return []
|
|
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
|
+
|
|
297
332
|
// renders a single element node: static attrs, @event listeners, a reactive
|
|
298
333
|
// :attrs object, and its content - :text/:html override the element's own
|
|
299
334
|
// children with a reactive textContent/innerHTML, otherwise children render
|
|
@@ -353,20 +388,71 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
353
388
|
})
|
|
354
389
|
}
|
|
355
390
|
|
|
391
|
+
// :class="expr" adds classes on top of the static `class` attribute. Only
|
|
392
|
+
// classes this binding added are ever removed: the static list survives
|
|
393
|
+
// every re-run, even when the expression names one of its classes and then
|
|
394
|
+
// drops it (class="btn" :class="{ btn: cond }" keeps btn on false)
|
|
395
|
+
const classExpr = node.attrs[":class"]
|
|
396
|
+
if (classExpr !== undefined) {
|
|
397
|
+
const staticClasses = new Set(classNames(node.attrs.class ?? ""))
|
|
398
|
+
let bound: string[] = []
|
|
399
|
+
|
|
400
|
+
fx.effect(() => {
|
|
401
|
+
const next = classNames(evalExpr(classExpr, scope))
|
|
402
|
+
bound.forEach(name => {
|
|
403
|
+
if (!next.includes(name) && !staticClasses.has(name)) el.classList.remove(name)
|
|
404
|
+
})
|
|
405
|
+
el.classList.add(...next)
|
|
406
|
+
bound = next
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
356
410
|
// :text="expr" sets textContent reactively, replacing any children.
|
|
357
411
|
// :html="expr" sets innerHTML reactively, sanitizing the value first so
|
|
358
412
|
// untrusted content can't inject scripts/attributes (see sanitizeHTML in
|
|
359
|
-
// ./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
|
|
360
417
|
const textExpr = node.attrs[":text"]
|
|
361
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
|
+
}
|
|
362
423
|
if (textExpr !== undefined) {
|
|
363
424
|
fx.effect(() => { el.textContent = String(evalExpr(textExpr, scope) ?? "") })
|
|
364
425
|
} else if (htmlExpr !== undefined) {
|
|
365
|
-
fx.effect(() => {
|
|
426
|
+
fx.effect(() => {
|
|
427
|
+
const options = allowedExpr !== undefined ? { allowUrl: normalizeAllowUrl(evalExpr(allowedExpr, scope)) } : undefined
|
|
428
|
+
el.innerHTML = sanitizeHTML(String(evalExpr(htmlExpr, scope) ?? ""), options)
|
|
429
|
+
})
|
|
366
430
|
} else {
|
|
367
431
|
el.appendChild(renderNodes(node.children, scope, fx, shadow))
|
|
368
432
|
}
|
|
369
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
|
+
|
|
370
456
|
return el
|
|
371
457
|
}
|
|
372
458
|
|
package/src/transform.ts
CHANGED
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
|
|
23
23
|
type SetupTransform = { vars: string[]; code: string }
|
|
24
24
|
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
const decl =
|
|
354
|
+
DECLARATION_START_RE.lastIndex = i
|
|
355
|
+
const decl = DECLARATION_START_RE.exec(src)
|
|
222
356
|
if (decl) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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[] = []
|