jq79 0.4.2 → 0.4.4
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 +13 -11
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.global.js +13 -11
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +13 -11
- package/dist/jq79.js.map +1 -1
- package/package.json +1 -1
- package/src/dom.ts +75 -7
- package/src/jq79.ts +205 -13
- 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"
|
|
@@ -66,7 +67,11 @@ const compileExpr = (expr: string, params: string[]): Function | null => {
|
|
|
66
67
|
let fn = compiled.get(key)
|
|
67
68
|
if (fn === undefined) {
|
|
68
69
|
try {
|
|
69
|
-
|
|
70
|
+
// the newline before `)` ends a trailing line comment in the
|
|
71
|
+
// expression ({{ msg // greeting }}); ASI doesn't apply inside parens,
|
|
72
|
+
// so everything else is untouched. Without it the comment eats the
|
|
73
|
+
// rest of this single-line body and the expression never compiles
|
|
74
|
+
fn = new Function("$scope", ...params, `with ($scope) { return (${expr}\n); }`)
|
|
70
75
|
} catch {
|
|
71
76
|
fn = null // a syntax error: it will never compile, so don't try again
|
|
72
77
|
}
|
|
@@ -91,7 +96,7 @@ const interpolate = (template: string, scope: Record<string, any>): string =>
|
|
|
91
96
|
template.replace(/{{\s*([\s\S]+?)\s*}}/g, (_, expr) => evalExpr(expr, scope) ?? "")
|
|
92
97
|
|
|
93
98
|
|
|
94
|
-
const CONTROL_ATTRS = new Set([":attrs", ":class", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html"])
|
|
99
|
+
const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed"])
|
|
95
100
|
// `item in items`, `item, i in items`, `(value, key) in props` - the second
|
|
96
101
|
// binding is the array index or the object key, parens optional (Vue-style).
|
|
97
102
|
// The list expression can span lines, so it matches [\s\S] rather than `.`
|
|
@@ -120,6 +125,39 @@ const bindEvent = (el: Element, attr: string, expr: string, scope: Record<string
|
|
|
120
125
|
}, { once: mods.has("once"), capture: mods.has("capture") })
|
|
121
126
|
}
|
|
122
127
|
|
|
128
|
+
// @event on a component tag: the tag renders as comment anchors, so there is
|
|
129
|
+
// no element to listen on - the attribute subscribes to the child instance's
|
|
130
|
+
// $emit channel (instance.on) instead, which survives the child's re-renders
|
|
131
|
+
// and works detached. Native DOM events from the child's inner DOM never
|
|
132
|
+
// arrive here: they bubble past the anchors to shared ancestors (a listener
|
|
133
|
+
// on a wrapping element hears those); a child that wants its native event
|
|
134
|
+
// heard on its tag re-emits it. .prevent flips the child's $emit() return to
|
|
135
|
+
// false, .stop keeps the emit off the DOM dispatch, .once unsubscribes after
|
|
136
|
+
// one call; .self and .capture have no meaning on this channel and are ignored
|
|
137
|
+
const wireTagEvent = (instance: Component79, attr: string, expr: string, scope: Record<string, any>) => {
|
|
138
|
+
const [name, ...modifiers] = attr.slice(1).split(".")
|
|
139
|
+
const mods = new Set(modifiers)
|
|
140
|
+
|
|
141
|
+
const listener = (event: CustomEvent) => {
|
|
142
|
+
if (mods.has("prevent")) event.preventDefault()
|
|
143
|
+
if (mods.has("stop")) event.stopPropagation()
|
|
144
|
+
if (mods.has("once")) instance.off(name, listener)
|
|
145
|
+
|
|
146
|
+
// untracked, so a tag handler behaves like an element handler no matter
|
|
147
|
+
// when the emit fires: an element handler never runs inside an effect,
|
|
148
|
+
// but $emit can (a $: that emits, a setup-script emit inside the parent's
|
|
149
|
+
// creation effect), and the handler's reads would land in that effect's
|
|
150
|
+
// deps. Today that is contained - cross-store deps are never notified,
|
|
151
|
+
// and the creation effect's definition guard no-ops a spurious wake - but
|
|
152
|
+
// "what a handler reads is nobody's dependency" shouldn't hinge on either
|
|
153
|
+
untracked(() => {
|
|
154
|
+
const handler = evalExpr(expr, scope, { $event: event })
|
|
155
|
+
if (typeof handler === "function") handler(event)
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
instance.on(name, listener)
|
|
159
|
+
}
|
|
160
|
+
|
|
123
161
|
const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
|
|
124
162
|
|
|
125
163
|
// the stable boundaries of a rendered chunk. An element is its own handle, but
|
|
@@ -195,11 +233,22 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
195
233
|
wrapper.append(anchor, endAnchor)
|
|
196
234
|
|
|
197
235
|
const props: Record<string, string> = {} // prop name -> expression in parent scope
|
|
236
|
+
const models: Record<string, string> = {} // model name -> assignable expression in parent scope
|
|
237
|
+
const events: Array<[string, string]> = [] // @attr (modifiers included) -> handler expression
|
|
198
238
|
Object.entries(node.attrs).forEach(([attr, value]) => {
|
|
199
239
|
// the parent's scope stamp is stamped on every template element, this tag
|
|
200
240
|
// included - it's not a prop, and the child renders under its own scope
|
|
201
|
-
if (attr === SCOPE_ATTR || CONTROL_ATTRS.has(attr)
|
|
202
|
-
if (attr.startsWith("
|
|
241
|
+
if (attr === SCOPE_ATTR || CONTROL_ATTRS.has(attr)) return
|
|
242
|
+
if (attr.startsWith("@")) {
|
|
243
|
+
events.push([attr, value])
|
|
244
|
+
} else if (attr === ":model" || attr.startsWith(":model.")) {
|
|
245
|
+
// :model[.name]="expr" - two-way: a prop down plus a writeback listener
|
|
246
|
+
// (wired below, once the instance exists). The modifier arrives
|
|
247
|
+
// lowercased from the HTML parser, so names are declared kebab-case;
|
|
248
|
+
// the bare :model binds the name "default"
|
|
249
|
+
const name = attr === ":model" ? "default" : kebabToCamel(attr.slice(":model.".length))
|
|
250
|
+
models[name] = value || (attr === ":model" ? "model" : name)
|
|
251
|
+
} else if (attr.startsWith(":")) {
|
|
203
252
|
const name = kebabToCamel(attr.slice(1))
|
|
204
253
|
props[name] = value || name
|
|
205
254
|
} else {
|
|
@@ -207,6 +256,31 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
207
256
|
}
|
|
208
257
|
})
|
|
209
258
|
|
|
259
|
+
// each model is also a prop down: the child reads the value under the
|
|
260
|
+
// model's name - `model` for the default, because a prop named `default`
|
|
261
|
+
// could never be read from a child expression (reserved word). Without the
|
|
262
|
+
// prop this isn't two-way, it's upward collection: a parent reset or an
|
|
263
|
+
// initial value would never reach the child
|
|
264
|
+
const modelAttr = (name: string) => (name === "default" ? ":model" : `:model.${name}`)
|
|
265
|
+
const modelProp = (name: string) => (name === "default" ? "model" : name)
|
|
266
|
+
// the newline keeps `= $value` out of a trailing line comment in the
|
|
267
|
+
// expression (:model="uname // the username") - glued on the same line,
|
|
268
|
+
// the assignment would vanish into the comment and compile as a bare read,
|
|
269
|
+
// dropping every update without a word
|
|
270
|
+
const assignment = (expr: string) => `${expr}\n= $value`
|
|
271
|
+
Object.entries(models).forEach(([name, expr]) => {
|
|
272
|
+
const prop = modelProp(name)
|
|
273
|
+
if (props[prop] !== undefined) {
|
|
274
|
+
console.warn(`jq79: <${node.tag}> binds prop "${prop}" through both :${prop} and ${modelAttr(name)} - ${modelAttr(name)} wins`)
|
|
275
|
+
}
|
|
276
|
+
props[prop] = expr
|
|
277
|
+
// an expression that can't be an assignment target is a wiring mistake -
|
|
278
|
+
// say so now, not on the first update that silently goes nowhere
|
|
279
|
+
if (compileExpr(assignment(expr), ["$value"]) === null) {
|
|
280
|
+
console.warn(`jq79: ${modelAttr(name)}="${expr}" is not assignable - updates from <${node.tag}> will be dropped`)
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
210
284
|
let current: Component79 | null = null
|
|
211
285
|
let currentDef: Component79 | null = null
|
|
212
286
|
let childFx: EffectScope | null = null
|
|
@@ -232,6 +306,46 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
232
306
|
modules: nextDef.modules,
|
|
233
307
|
filename: nextDef.filename,
|
|
234
308
|
})
|
|
309
|
+
// the writeback half of :model - one event, one contract. The name is
|
|
310
|
+
// normalized like the attribute was (kebab->camel; absent means default),
|
|
311
|
+
// and everything off-contract warns and does nothing: an event protocol's
|
|
312
|
+
// failure mode has to be loud, or a typo'd name is an input that types
|
|
313
|
+
// into the void
|
|
314
|
+
if (Object.keys(models).length) {
|
|
315
|
+
// each mistake is warned once per instance, not once per keystroke: an
|
|
316
|
+
// input emitting a typo'd name would otherwise flood the console on
|
|
317
|
+
// every character typed into it
|
|
318
|
+
const warned = new Set<string>()
|
|
319
|
+
const warnOnce = (key: string, message: string) => {
|
|
320
|
+
if (warned.has(key)) return
|
|
321
|
+
warned.add(key)
|
|
322
|
+
console.warn(message)
|
|
323
|
+
}
|
|
324
|
+
instance.on("model:update", (_event, payload) => {
|
|
325
|
+
if (payload === null || typeof payload !== "object") {
|
|
326
|
+
warnOnce("payload", `jq79: model:update expects a { name?, value } payload, got ${payload === null ? "null" : typeof payload}`)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const name = payload.name == null ? "default" : kebabToCamel(String(payload.name))
|
|
330
|
+
const expr = models[name]
|
|
331
|
+
if (expr === undefined) {
|
|
332
|
+
warnOnce(name, `jq79: <${node.tag}> has no ${modelAttr(name)} - bound: ${Object.keys(models).map(modelAttr).join(", ")}`)
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
// untracked, like the tag handlers: a child emitting from its setup
|
|
336
|
+
// script runs inside the parent's *creation* effect, and the reads a
|
|
337
|
+
// path assignment makes (`user` in `user.name = $value`) would land
|
|
338
|
+
// in its deps - donating that effect one wasted (guard-stopped) wake
|
|
339
|
+
// per later write. An imperative writeback is nobody's dependency
|
|
340
|
+
untracked(() => evalExpr(assignment(expr), scope, { $value: payload.value }))
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// @event on the tag listens to this instance's $emit channel (and only
|
|
345
|
+
// this instance's - a grandchild's emit arrives here solely as an
|
|
346
|
+
// explicit re-emit)
|
|
347
|
+
events.forEach(([attr, expr]) => wireTagEvent(instance, attr, expr, scope))
|
|
348
|
+
|
|
235
349
|
const seed = untracked(() =>
|
|
236
350
|
Object.fromEntries(Object.entries(props).map(([name, expr]) => [name, evalExpr(expr, scope)]))
|
|
237
351
|
)
|
|
@@ -308,6 +422,26 @@ const classNames = (value: any): string[] => {
|
|
|
308
422
|
return []
|
|
309
423
|
}
|
|
310
424
|
|
|
425
|
+
// what :html.allowed accepts, normalized to an AllowUrl predicate: host
|
|
426
|
+
// patterns (a comma-separated string or an array - see allowedHosts in
|
|
427
|
+
// ./dom) or a function (url: URL, tag, attr) => boolean. Anything else -
|
|
428
|
+
// including a policy expression that evaluates to undefined - denies every
|
|
429
|
+
// destination: the attribute declares the intent to restrict, so a broken
|
|
430
|
+
// policy fails closed, and so does a predicate that throws
|
|
431
|
+
const normalizeAllowUrl = (policy: any): AllowUrl => {
|
|
432
|
+
if (typeof policy === "function") {
|
|
433
|
+
return (url, tag, attr) => {
|
|
434
|
+
try {
|
|
435
|
+
return !!policy(url, tag, attr)
|
|
436
|
+
} catch {
|
|
437
|
+
return false
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (typeof policy === "string" || Array.isArray(policy)) return allowedHosts(policy)
|
|
442
|
+
return () => false
|
|
443
|
+
}
|
|
444
|
+
|
|
311
445
|
// renders a single element node: static attrs, @event listeners, a reactive
|
|
312
446
|
// :attrs object, and its content - :text/:html override the element's own
|
|
313
447
|
// children with a reactive textContent/innerHTML, otherwise children render
|
|
@@ -349,7 +483,15 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
349
483
|
|
|
350
484
|
Object.entries(node.attrs).forEach(([key, value]) => {
|
|
351
485
|
if (key.startsWith("@")) bindEvent(el, key, value, scope)
|
|
352
|
-
else if (
|
|
486
|
+
else if (key === ":model" || key.startsWith(":model.")) {
|
|
487
|
+
// :model binds component tags only (see TODOS/2026-07-15.model-directive.md;
|
|
488
|
+
// the native-element form is parked there). Warn on a real element, but
|
|
489
|
+
// not on a tag that may still upgrade into a component - the upgrade
|
|
490
|
+
// re-renders through renderNestedComponent, models and all
|
|
491
|
+
if (!(el instanceof HTMLUnknownElement || node.tag.includes("-"))) {
|
|
492
|
+
console.warn(`jq79: ${key} on <${node.tag}> does nothing - :model binds component tags only (for now)`)
|
|
493
|
+
}
|
|
494
|
+
} else if (!CONTROL_ATTRS.has(key)) el.setAttribute(key, value)
|
|
353
495
|
})
|
|
354
496
|
|
|
355
497
|
const bindExpr = node.attrs[":attrs"]
|
|
@@ -389,17 +531,49 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
389
531
|
// :text="expr" sets textContent reactively, replacing any children.
|
|
390
532
|
// :html="expr" sets innerHTML reactively, sanitizing the value first so
|
|
391
533
|
// untrusted content can't inject scripts/attributes (see sanitizeHTML in
|
|
392
|
-
// ./dom). Both skip rendering the element's own children/interpolation
|
|
534
|
+
// ./dom). Both skip rendering the element's own children/interpolation.
|
|
535
|
+
// :html.allowed="expr" adds a destination policy for the content's
|
|
536
|
+
// href/src URLs - evaluated in the same effect, so a policy held in the
|
|
537
|
+
// store is as reactive as the content itself
|
|
393
538
|
const textExpr = node.attrs[":text"]
|
|
394
539
|
const htmlExpr = node.attrs[":html"]
|
|
540
|
+
const allowedExpr = node.attrs[":html.allowed"]
|
|
541
|
+
if (allowedExpr !== undefined && htmlExpr === undefined) {
|
|
542
|
+
console.warn("jq79: :html.allowed without :html on the same element does nothing")
|
|
543
|
+
}
|
|
395
544
|
if (textExpr !== undefined) {
|
|
396
545
|
fx.effect(() => { el.textContent = String(evalExpr(textExpr, scope) ?? "") })
|
|
397
546
|
} else if (htmlExpr !== undefined) {
|
|
398
|
-
fx.effect(() => {
|
|
547
|
+
fx.effect(() => {
|
|
548
|
+
const options = allowedExpr !== undefined ? { allowUrl: normalizeAllowUrl(evalExpr(allowedExpr, scope)) } : undefined
|
|
549
|
+
el.innerHTML = sanitizeHTML(String(evalExpr(htmlExpr, scope) ?? ""), options)
|
|
550
|
+
})
|
|
399
551
|
} else {
|
|
400
552
|
el.appendChild(renderNodes(node.children, scope, fx, shadow))
|
|
401
553
|
}
|
|
402
554
|
|
|
555
|
+
// :value / :checked / :selected write the DOM *property*, not the
|
|
556
|
+
// attribute - the attribute is only a form control's default, and detaches
|
|
557
|
+
// the moment the user interacts (which is why :attrs="{ value }" stops
|
|
558
|
+
// driving a typed-in input). One-way, store -> DOM: the way back stays an
|
|
559
|
+
// explicit @input/@change. :value skips the write when the property
|
|
560
|
+
// already holds the string, so an unrelated re-run can't move the caret of
|
|
561
|
+
// the input the user is typing into. Registered after the children render:
|
|
562
|
+
// :value on a <select> can only pick an <option> that already exists
|
|
563
|
+
const valueExpr = node.attrs[":value"]
|
|
564
|
+
if (valueExpr !== undefined) {
|
|
565
|
+
fx.effect(() => {
|
|
566
|
+
const value = String(evalExpr(valueExpr, scope) ?? "")
|
|
567
|
+
if ((el as HTMLInputElement).value !== value) (el as HTMLInputElement).value = value
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
;([":checked", ":selected"] as const).forEach(attr => {
|
|
571
|
+
const expr = node.attrs[attr]
|
|
572
|
+
if (expr === undefined) return
|
|
573
|
+
const prop = attr.slice(1) as "checked" | "selected"
|
|
574
|
+
fx.effect(() => { (el as any)[prop] = !!evalExpr(expr, scope) })
|
|
575
|
+
})
|
|
576
|
+
|
|
403
577
|
return el
|
|
404
578
|
}
|
|
405
579
|
|
|
@@ -1197,15 +1371,22 @@ export class Component79 {
|
|
|
1197
1371
|
// listen on any ancestor (or with @event-name on a wrapping element).
|
|
1198
1372
|
// Captures the marker rather than `this` so a later re-render's scripts
|
|
1199
1373
|
// can't dispatch from the wrong generation - the same guard keeps stale
|
|
1200
|
-
// generations from reaching the instance's on() listeners
|
|
1374
|
+
// generations from reaching the instance's on() listeners.
|
|
1375
|
+
// The on() channel runs *first* (it's where @event on a component tag is
|
|
1376
|
+
// wired - see wireTagEvent) so its listeners can shape the DOM dispatch:
|
|
1377
|
+
// stopPropagation() there keeps the event off the DOM entirely, and the
|
|
1378
|
+
// event is cancelable so preventDefault() - from either channel - flips
|
|
1379
|
+
// the return to false, telling the emitting child "the parent vetoed"
|
|
1201
1380
|
const marker = this.startMarker
|
|
1202
1381
|
const $emit = (eventName: string, payload?: any): boolean => {
|
|
1203
|
-
const event = new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true })
|
|
1204
|
-
const result = marker.dispatchEvent(event)
|
|
1382
|
+
const event = new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true, cancelable: true })
|
|
1205
1383
|
if (marker === this.startMarker) {
|
|
1206
1384
|
this.emitListeners.get(eventName)?.forEach(listener => listener(event, payload))
|
|
1207
1385
|
}
|
|
1208
|
-
|
|
1386
|
+
// cancelBubble is the spec's legacy name, but it's the only *readable*
|
|
1387
|
+
// accessor for the stop-propagation flag - hence the deprecation hint
|
|
1388
|
+
if (!event.cancelBubble) marker.dispatchEvent(event)
|
|
1389
|
+
return !event.defaultPrevented
|
|
1209
1390
|
}
|
|
1210
1391
|
|
|
1211
1392
|
// `await $mounted()` suspends a setup script until mount() attaches the
|
|
@@ -1271,7 +1452,18 @@ export class Component79 {
|
|
|
1271
1452
|
})
|
|
1272
1453
|
|
|
1273
1454
|
const content = document.createDocumentFragment()
|
|
1274
|
-
|
|
1455
|
+
// the template renders under a scope that also answers $emit (unless the
|
|
1456
|
+
// store shadows the name), so an inline handler can emit without routing
|
|
1457
|
+
// through a setup function: @input="$emit('update', $event.target.value)".
|
|
1458
|
+
// has/get only - never an own key - so Object.keys, snapshot spreads and
|
|
1459
|
+
// the component-key scan don't see it, and every read still forwards
|
|
1460
|
+
// through the reactive store, keeping dependency tracking intact
|
|
1461
|
+
const templateScope = new Proxy(store as Record<string, any>, {
|
|
1462
|
+
has: (target, key) => key === "$emit" || Reflect.has(target, key),
|
|
1463
|
+
get: (target, key, receiver) =>
|
|
1464
|
+
key === "$emit" && !Reflect.has(target, key) ? $emit : Reflect.get(target, key, receiver),
|
|
1465
|
+
})
|
|
1466
|
+
content.append(this.startMarker, renderNodes(this.template, templateScope, fx, shadow), this.endMarker)
|
|
1275
1467
|
this.content = content
|
|
1276
1468
|
|
|
1277
1469
|
if (shadow) {
|