@zubra/runtime 5.0.3 → 5.1.1
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/production/element.js +25 -3
- package/dist/production/index.js +1 -1
- package/dist/production/spread.js +8 -1
- package/package.json +1 -1
- package/src/element.js +25 -3
- package/src/index.js +1 -1
- package/src/spread.js +8 -1
- package/types/index.d.ts +2 -0
|
@@ -49,6 +49,12 @@ export function isSafeUrl(value) {
|
|
|
49
49
|
return ['http', 'https', 'mailto', 'tel'].includes(beforePath.slice(0, colon).toLowerCase());
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** A srcset whose every candidate's URL is safe (src/validate.rs `is_safe_srcset`). */
|
|
53
|
+
export function isSafeSrcset(value) {
|
|
54
|
+
const urls = String(value).split(',').map((candidate) => candidate.trim().split(/\s+/)[0]).filter(Boolean);
|
|
55
|
+
return urls.length > 0 && urls.every(isSafeUrl);
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
const clone = (value) => (value && typeof value === 'object' ? structuredClone(value) : value);
|
|
53
59
|
|
|
54
60
|
/** Attribute text → prop value, typed by the prop's default (JSON for arrays and objects). */
|
|
@@ -871,13 +877,24 @@ const isContent = (node) => node.nodeType === 1 || (node.nodeType === TEXT && no
|
|
|
871
877
|
* The slot for each of `nodes` (an element's children, in order). Comments and whitespace are
|
|
872
878
|
* frameworks' markers (Lit's parts, Svelte's anchors): a comment goes with the content after it
|
|
873
879
|
* and whitespace with the content before it, so a framework's range stays together in one slot.
|
|
880
|
+
* Empty text nodes come in pairs (Vue brackets each fragment, a v-for, with two): both go with
|
|
881
|
+
* the content between them, since Vue moves that content through the start anchor's parent.
|
|
874
882
|
*/
|
|
875
883
|
function assignSlots(nodes) {
|
|
876
884
|
const named = nodes.map((node) => (isContent(node) ? slotName(node) : null));
|
|
877
|
-
|
|
878
|
-
const
|
|
885
|
+
// The slot of the nearest content after (step 1) or before (step -1) node i.
|
|
886
|
+
const near = (i, step) => { for (let j = i + step; nodes[j]; j += step) if (named[j]) return named[j]; return null; };
|
|
887
|
+
// An empty text node right before content opens a range; otherwise it closes the last one,
|
|
888
|
+
// and both take the slot of the first content between them.
|
|
889
|
+
const open = [];
|
|
890
|
+
nodes.forEach((node, i) => {
|
|
891
|
+
if (node.nodeType !== TEXT || node.data) return;
|
|
892
|
+
if (!open.length || named[i + 1] && nodes[i + 1].data == null) return open.push(i);
|
|
893
|
+
const start = open.pop();
|
|
894
|
+
named[start] = named[i] = named.slice(start + 1, i).find(Boolean);
|
|
895
|
+
});
|
|
879
896
|
return nodes.map((node, i) => named[i]
|
|
880
|
-
?? (node.nodeType === COMMENT ?
|
|
897
|
+
?? (node.nodeType === COMMENT ? near(i, 1) ?? near(i, -1) : near(i, -1) ?? near(i, 1))
|
|
881
898
|
?? 'default');
|
|
882
899
|
}
|
|
883
900
|
let nativeText, nativeHTML; // looked up on first use, so the module loads without a DOM
|
|
@@ -1075,6 +1092,11 @@ function bindAttribute(part, node) {
|
|
|
1075
1092
|
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
1076
1093
|
// No URL yet (a prop's "" default, before the app sets it): no attribute, and nothing to report.
|
|
1077
1094
|
if (part.url && text.trim() === '') return node.removeAttribute(name);
|
|
1095
|
+
if (part.srcset && text.trim() === '') return node.removeAttribute(name);
|
|
1096
|
+
if (part.srcset && !isSafeSrcset(text)) {
|
|
1097
|
+
console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
|
|
1098
|
+
return node.removeAttribute(name);
|
|
1099
|
+
}
|
|
1078
1100
|
if (part.url && !isSafeUrl(text)) {
|
|
1079
1101
|
console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
|
|
1080
1102
|
return node.removeAttribute(name);
|
package/dist/production/index.js
CHANGED
|
@@ -8,5 +8,5 @@ export {
|
|
|
8
8
|
createContext, provide, inject, ContextRequestEvent, ContextProviderEvent,
|
|
9
9
|
formValue, validity, onFormReset, onFormRestore, onFormDisabled,
|
|
10
10
|
prevent, stop, selfOnly, submit, keys, model, hasSlot, hydrated, signal, from, raw,
|
|
11
|
-
isSafeUrl, $r, $t,
|
|
11
|
+
isSafeUrl, isSafeSrcset, $r, $t,
|
|
12
12
|
} from './element.js';
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// {...spread} in compiled components: an object's entries as an element's attributes,
|
|
3
3
|
// properties and listeners. Its own entry (`@zubra/runtime/spread`), which a compiled module imports
|
|
4
4
|
// only when its template spreads, so components that don't, don't load it.
|
|
5
|
-
import { $r, isSafeUrl } from './element.js';
|
|
5
|
+
import { $r, isSafeSrcset, isSafeUrl } from './element.js';
|
|
6
6
|
import { effect } from './effect.js';
|
|
7
7
|
|
|
8
8
|
const see = (section) => ` (see https://github.com/journey-to-code-org/zubra#${section})`;
|
|
@@ -130,6 +130,13 @@ export function spread(part, node) {
|
|
|
130
130
|
continue;
|
|
131
131
|
}
|
|
132
132
|
const text = String(value);
|
|
133
|
+
if (name.toLowerCase() === 'srcset') {
|
|
134
|
+
if (text.trim() === '' || !isSafeSrcset(text)) {
|
|
135
|
+
if (text.trim() !== '') console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
|
|
136
|
+
node.removeAttribute(name);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
133
140
|
if (SPREAD_URLS.has(name.toLowerCase())) {
|
|
134
141
|
if (text.trim() === '') {
|
|
135
142
|
node.removeAttribute(name);
|
package/package.json
CHANGED
package/src/element.js
CHANGED
|
@@ -48,6 +48,12 @@ export function isSafeUrl(value) {
|
|
|
48
48
|
return ['http', 'https', 'mailto', 'tel'].includes(beforePath.slice(0, colon).toLowerCase());
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** A srcset whose every candidate's URL is safe (src/validate.rs `is_safe_srcset`). */
|
|
52
|
+
export function isSafeSrcset(value) {
|
|
53
|
+
const urls = String(value).split(',').map((candidate) => candidate.trim().split(/\s+/)[0]).filter(Boolean);
|
|
54
|
+
return urls.length > 0 && urls.every(isSafeUrl);
|
|
55
|
+
}
|
|
56
|
+
|
|
51
57
|
const clone = (value) => (value && typeof value === 'object' ? structuredClone(value) : value);
|
|
52
58
|
|
|
53
59
|
/** Attribute text → prop value, typed by the prop's default (JSON for arrays and objects). */
|
|
@@ -886,13 +892,24 @@ const isContent = (node) => node.nodeType === 1 || (node.nodeType === TEXT && no
|
|
|
886
892
|
* The slot for each of `nodes` (an element's children, in order). Comments and whitespace are
|
|
887
893
|
* frameworks' markers (Lit's parts, Svelte's anchors): a comment goes with the content after it
|
|
888
894
|
* and whitespace with the content before it, so a framework's range stays together in one slot.
|
|
895
|
+
* Empty text nodes come in pairs (Vue brackets each fragment, a v-for, with two): both go with
|
|
896
|
+
* the content between them, since Vue moves that content through the start anchor's parent.
|
|
889
897
|
*/
|
|
890
898
|
function assignSlots(nodes) {
|
|
891
899
|
const named = nodes.map((node) => (isContent(node) ? slotName(node) : null));
|
|
892
|
-
|
|
893
|
-
const
|
|
900
|
+
// The slot of the nearest content after (step 1) or before (step -1) node i.
|
|
901
|
+
const near = (i, step) => { for (let j = i + step; nodes[j]; j += step) if (named[j]) return named[j]; return null; };
|
|
902
|
+
// An empty text node right before content opens a range; otherwise it closes the last one,
|
|
903
|
+
// and both take the slot of the first content between them.
|
|
904
|
+
const open = [];
|
|
905
|
+
nodes.forEach((node, i) => {
|
|
906
|
+
if (node.nodeType !== TEXT || node.data) return;
|
|
907
|
+
if (!open.length || named[i + 1] && nodes[i + 1].data == null) return open.push(i);
|
|
908
|
+
const start = open.pop();
|
|
909
|
+
named[start] = named[i] = named.slice(start + 1, i).find(Boolean);
|
|
910
|
+
});
|
|
894
911
|
return nodes.map((node, i) => named[i]
|
|
895
|
-
?? (node.nodeType === COMMENT ?
|
|
912
|
+
?? (node.nodeType === COMMENT ? near(i, 1) ?? near(i, -1) : near(i, -1) ?? near(i, 1))
|
|
896
913
|
?? 'default');
|
|
897
914
|
}
|
|
898
915
|
let nativeText, nativeHTML; // looked up on first use, so the module loads without a DOM
|
|
@@ -1090,6 +1107,11 @@ function bindAttribute(part, node) {
|
|
|
1090
1107
|
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
1091
1108
|
// No URL yet (a prop's "" default, before the app sets it): no attribute, and nothing to report.
|
|
1092
1109
|
if (part.url && text.trim() === '') return node.removeAttribute(name);
|
|
1110
|
+
if (part.srcset && text.trim() === '') return node.removeAttribute(name);
|
|
1111
|
+
if (part.srcset && !isSafeSrcset(text)) {
|
|
1112
|
+
console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
|
|
1113
|
+
return node.removeAttribute(name);
|
|
1114
|
+
}
|
|
1093
1115
|
if (part.url && !isSafeUrl(text)) {
|
|
1094
1116
|
console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
|
|
1095
1117
|
return node.removeAttribute(name);
|
package/src/index.js
CHANGED
|
@@ -7,5 +7,5 @@ export {
|
|
|
7
7
|
createContext, provide, inject, ContextRequestEvent, ContextProviderEvent,
|
|
8
8
|
formValue, validity, onFormReset, onFormRestore, onFormDisabled,
|
|
9
9
|
prevent, stop, selfOnly, submit, keys, model, hasSlot, hydrated, signal, from, raw,
|
|
10
|
-
isSafeUrl, $r, $t,
|
|
10
|
+
isSafeUrl, isSafeSrcset, $r, $t,
|
|
11
11
|
} from './element.js';
|
package/src/spread.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// {...spread} in compiled components: an object's entries as an element's attributes,
|
|
2
2
|
// properties and listeners. Its own entry (`@zubra/runtime/spread`), which a compiled module imports
|
|
3
3
|
// only when its template spreads, so components that don't, don't load it.
|
|
4
|
-
import { $r, isSafeUrl } from './element.js';
|
|
4
|
+
import { $r, isSafeSrcset, isSafeUrl } from './element.js';
|
|
5
5
|
import { effect } from './effect.js';
|
|
6
6
|
|
|
7
7
|
const see = (section) => ` (see https://github.com/journey-to-code-org/zubra#${section})`;
|
|
@@ -129,6 +129,13 @@ export function spread(part, node) {
|
|
|
129
129
|
continue;
|
|
130
130
|
}
|
|
131
131
|
const text = String(value);
|
|
132
|
+
if (name.toLowerCase() === 'srcset') {
|
|
133
|
+
if (text.trim() === '' || !isSafeSrcset(text)) {
|
|
134
|
+
if (text.trim() !== '') console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
|
|
135
|
+
node.removeAttribute(name);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
132
139
|
if (SPREAD_URLS.has(name.toLowerCase())) {
|
|
133
140
|
if (text.trim() === '') {
|
|
134
141
|
node.removeAttribute(name);
|
package/types/index.d.ts
CHANGED
|
@@ -199,6 +199,8 @@ export declare const ABI: number;
|
|
|
199
199
|
|
|
200
200
|
/** Whether a URL is safe for `href`/`src`: relative, or http(s), mailto or tel. */
|
|
201
201
|
export declare function isSafeUrl(value: string): boolean;
|
|
202
|
+
/** True when every candidate in a srcset has a safe URL (the check a dynamic `srcset` gets). */
|
|
203
|
+
export declare function isSafeSrcset(value: string): boolean;
|
|
202
204
|
/** @internal Read a value that may be a signal. */
|
|
203
205
|
export declare function $r<T>(value: MaybeSignal<T>): T;
|
|
204
206
|
/** @internal A value as template text. */
|