@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.
@@ -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
- const after = (i) => { for (let j = i + 1; j < nodes.length; j++) if (named[j]) return named[j]; return null; };
878
- const before = (i) => { for (let j = i - 1; j >= 0; j--) if (named[j]) return named[j]; return null; };
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 ? after(i) ?? before(i) : before(i) ?? after(i))
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);
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/runtime",
3
- "version": "5.0.3",
3
+ "version": "5.1.1",
4
4
  "description": "Browser runtime for Zubra compiled components, built on the TC39 Signals API",
5
5
  "license": "ISC",
6
6
  "repository": {
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
- const after = (i) => { for (let j = i + 1; j < nodes.length; j++) if (named[j]) return named[j]; return null; };
893
- const before = (i) => { for (let j = i - 1; j >= 0; j--) if (named[j]) return named[j]; return null; };
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 ? after(i) ?? before(i) : before(i) ?? after(i))
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. */