@lightspeed-js/core 0.4.4 → 0.4.6

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/compile.js CHANGED
@@ -999,7 +999,7 @@ class BindingEvaluator {
999
999
  }
1000
1000
  if (binding.kind === "prop") {
1001
1001
  const target = binding.target;
1002
- if (Object.is(target.props[binding.name], value))
1002
+ if (sameValue(target.props[binding.name], value))
1003
1003
  return;
1004
1004
  target.props[binding.name] = value;
1005
1005
  this.onPropChanged(target);
@@ -1112,7 +1112,51 @@ function setHidden(el, hidden) {
1112
1112
  else
1113
1113
  el.removeAttribute(HIDDEN_ATTR);
1114
1114
  }
1115
+ function sameValue(a, b, depth = 0) {
1116
+ if (Object.is(a, b))
1117
+ return true;
1118
+ if (depth > 20 || !isPlain(a) || !isPlain(b))
1119
+ return false;
1120
+ if (Array.isArray(a) !== Array.isArray(b))
1121
+ return false;
1122
+ const keys = Object.keys(a);
1123
+ if (keys.length !== Object.keys(b).length)
1124
+ return false;
1125
+ return keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && sameValue(a[key], b[key], depth + 1));
1126
+ }
1127
+ function isPlain(value) {
1128
+ if (value === null || typeof value !== "object")
1129
+ return false;
1130
+ if (Array.isArray(value))
1131
+ return true;
1132
+ const proto = Object.getPrototypeOf(value);
1133
+ return proto === Object.prototype || proto === null;
1134
+ }
1115
1135
  // src/runtime/dom/ForBlock.ts
1136
+ var TAG_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
1137
+ var REFUSED_TAGS = new Set([
1138
+ "script",
1139
+ "style",
1140
+ "template",
1141
+ "slot",
1142
+ "iframe",
1143
+ "object",
1144
+ "embed"
1145
+ ]);
1146
+ function retag(source, name) {
1147
+ const doc = source.ownerDocument;
1148
+ const el = source.namespaceURI ? doc.createElementNS(source.namespaceURI, name) : doc.createElement(name);
1149
+ for (const attr of Array.from(source.attributes)) {
1150
+ if (attr.namespaceURI)
1151
+ el.setAttributeNS(attr.namespaceURI, attr.name, attr.value);
1152
+ else
1153
+ el.setAttribute(attr.name, attr.value);
1154
+ }
1155
+ for (const child of Array.from(source.childNodes)) {
1156
+ el.appendChild(child.cloneNode(true));
1157
+ }
1158
+ return el;
1159
+ }
1116
1160
  function toIterable(value) {
1117
1161
  if (value == null)
1118
1162
  return [];
@@ -1154,12 +1198,13 @@ class ForBlock {
1154
1198
  renderer;
1155
1199
  conditional;
1156
1200
  itemsExpr;
1201
+ tagged;
1157
1202
  rows = new Map;
1158
1203
  order = [];
1159
1204
  pendingItems = null;
1160
1205
  running = false;
1161
1206
  warnedDuplicates = false;
1162
- constructor(owner, anchor, templates, alias, params, parentLocals, keyFor, renderer, conditional = false, itemsExpr = "") {
1207
+ constructor(owner, anchor, templates, alias, params, parentLocals, keyFor, renderer, conditional = false, itemsExpr = "", tagged = false) {
1163
1208
  this.owner = owner;
1164
1209
  this.anchor = anchor;
1165
1210
  this.templates = templates;
@@ -1170,22 +1215,55 @@ class ForBlock {
1170
1215
  this.renderer = renderer;
1171
1216
  this.conditional = conditional;
1172
1217
  this.itemsExpr = itemsExpr;
1218
+ this.tagged = tagged;
1173
1219
  }
1220
+ retagged = new Map;
1221
+ warnedTag = false;
1174
1222
  toItems(value) {
1175
1223
  if (this.conditional) {
1176
1224
  return Number.isInteger(value) && value >= 0 ? [value] : [];
1177
1225
  }
1226
+ if (this.tagged)
1227
+ return [this.tagFor(value)];
1178
1228
  return toIterable(value);
1179
1229
  }
1180
1230
  templateFor(item) {
1231
+ if (this.tagged) {
1232
+ const source = this.templates[0];
1233
+ if (item === source.localName)
1234
+ return source;
1235
+ let made = this.retagged.get(item);
1236
+ if (!made) {
1237
+ made = retag(source, item);
1238
+ this.retagged.set(item, made);
1239
+ }
1240
+ return made;
1241
+ }
1181
1242
  return this.conditional ? this.templates[item] : this.templates[0];
1182
1243
  }
1244
+ tagFor(value) {
1245
+ const written = this.templates[0].localName;
1246
+ if (value == null || value === "")
1247
+ return written;
1248
+ const name = typeof value === "string" ? value.trim().toLowerCase() : null;
1249
+ if (name && TAG_NAME.test(name) && !REFUSED_TAGS.has(name))
1250
+ return name;
1251
+ if (!this.warnedTag) {
1252
+ this.warnedTag = true;
1253
+ warn(`$tag="${this.itemsExpr}" gave ${JSON.stringify(value)}, which is not an element it can make - rendering <${written}>.`);
1254
+ }
1255
+ return written;
1256
+ }
1183
1257
  schedule(items) {
1184
1258
  this.pendingItems = items;
1185
1259
  if (this.running)
1186
1260
  return;
1187
1261
  this.running = true;
1188
- this.drain();
1262
+ this.draining = this.drain();
1263
+ }
1264
+ draining = null;
1265
+ settled() {
1266
+ return this.draining ?? Promise.resolve();
1189
1267
  }
1190
1268
  async drain() {
1191
1269
  try {
package/dist/index.js CHANGED
@@ -571,7 +571,7 @@ class BindingEvaluator {
571
571
  }
572
572
  if (binding.kind === "prop") {
573
573
  const target = binding.target;
574
- if (Object.is(target.props[binding.name], value))
574
+ if (sameValue(target.props[binding.name], value))
575
575
  return;
576
576
  target.props[binding.name] = value;
577
577
  this.onPropChanged(target);
@@ -684,6 +684,26 @@ function setHidden(el, hidden) {
684
684
  else
685
685
  el.removeAttribute(HIDDEN_ATTR);
686
686
  }
687
+ function sameValue(a, b, depth = 0) {
688
+ if (Object.is(a, b))
689
+ return true;
690
+ if (depth > 20 || !isPlain(a) || !isPlain(b))
691
+ return false;
692
+ if (Array.isArray(a) !== Array.isArray(b))
693
+ return false;
694
+ const keys = Object.keys(a);
695
+ if (keys.length !== Object.keys(b).length)
696
+ return false;
697
+ return keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && sameValue(a[key], b[key], depth + 1));
698
+ }
699
+ function isPlain(value) {
700
+ if (value === null || typeof value !== "object")
701
+ return false;
702
+ if (Array.isArray(value))
703
+ return true;
704
+ const proto = Object.getPrototypeOf(value);
705
+ return proto === Object.prototype || proto === null;
706
+ }
687
707
  // src/runtime/events/EventBinder.ts
688
708
  class EventBinder {
689
709
  bind(el, eventType, handlerName, owner, cleanupTarget = owner) {
@@ -1020,6 +1040,30 @@ class Row {
1020
1040
  this.locals[this.locals.length - 1] = value;
1021
1041
  }
1022
1042
  }
1043
+ var TAG_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
1044
+ var REFUSED_TAGS = new Set([
1045
+ "script",
1046
+ "style",
1047
+ "template",
1048
+ "slot",
1049
+ "iframe",
1050
+ "object",
1051
+ "embed"
1052
+ ]);
1053
+ function retag(source, name) {
1054
+ const doc = source.ownerDocument;
1055
+ const el = source.namespaceURI ? doc.createElementNS(source.namespaceURI, name) : doc.createElement(name);
1056
+ for (const attr of Array.from(source.attributes)) {
1057
+ if (attr.namespaceURI)
1058
+ el.setAttributeNS(attr.namespaceURI, attr.name, attr.value);
1059
+ else
1060
+ el.setAttribute(attr.name, attr.value);
1061
+ }
1062
+ for (const child of Array.from(source.childNodes)) {
1063
+ el.appendChild(child.cloneNode(true));
1064
+ }
1065
+ return el;
1066
+ }
1023
1067
  function toIterable(value) {
1024
1068
  if (value == null)
1025
1069
  return [];
@@ -1061,12 +1105,13 @@ class ForBlock {
1061
1105
  renderer;
1062
1106
  conditional;
1063
1107
  itemsExpr;
1108
+ tagged;
1064
1109
  rows = new Map;
1065
1110
  order = [];
1066
1111
  pendingItems = null;
1067
1112
  running = false;
1068
1113
  warnedDuplicates = false;
1069
- constructor(owner, anchor, templates, alias, params, parentLocals, keyFor, renderer, conditional = false, itemsExpr = "") {
1114
+ constructor(owner, anchor, templates, alias, params, parentLocals, keyFor, renderer, conditional = false, itemsExpr = "", tagged = false) {
1070
1115
  this.owner = owner;
1071
1116
  this.anchor = anchor;
1072
1117
  this.templates = templates;
@@ -1077,22 +1122,55 @@ class ForBlock {
1077
1122
  this.renderer = renderer;
1078
1123
  this.conditional = conditional;
1079
1124
  this.itemsExpr = itemsExpr;
1125
+ this.tagged = tagged;
1080
1126
  }
1127
+ retagged = new Map;
1128
+ warnedTag = false;
1081
1129
  toItems(value) {
1082
1130
  if (this.conditional) {
1083
1131
  return Number.isInteger(value) && value >= 0 ? [value] : [];
1084
1132
  }
1133
+ if (this.tagged)
1134
+ return [this.tagFor(value)];
1085
1135
  return toIterable(value);
1086
1136
  }
1087
1137
  templateFor(item) {
1138
+ if (this.tagged) {
1139
+ const source = this.templates[0];
1140
+ if (item === source.localName)
1141
+ return source;
1142
+ let made = this.retagged.get(item);
1143
+ if (!made) {
1144
+ made = retag(source, item);
1145
+ this.retagged.set(item, made);
1146
+ }
1147
+ return made;
1148
+ }
1088
1149
  return this.conditional ? this.templates[item] : this.templates[0];
1089
1150
  }
1151
+ tagFor(value) {
1152
+ const written = this.templates[0].localName;
1153
+ if (value == null || value === "")
1154
+ return written;
1155
+ const name = typeof value === "string" ? value.trim().toLowerCase() : null;
1156
+ if (name && TAG_NAME.test(name) && !REFUSED_TAGS.has(name))
1157
+ return name;
1158
+ if (!this.warnedTag) {
1159
+ this.warnedTag = true;
1160
+ warn(`$tag="${this.itemsExpr}" gave ${JSON.stringify(value)}, which is not an element it can make - rendering <${written}>.`);
1161
+ }
1162
+ return written;
1163
+ }
1090
1164
  schedule(items) {
1091
1165
  this.pendingItems = items;
1092
1166
  if (this.running)
1093
1167
  return;
1094
1168
  this.running = true;
1095
- this.drain();
1169
+ this.draining = this.drain();
1170
+ }
1171
+ draining = null;
1172
+ settled() {
1173
+ return this.draining ?? Promise.resolve();
1096
1174
  }
1097
1175
  async drain() {
1098
1176
  try {
@@ -1446,6 +1524,15 @@ class Mounter {
1446
1524
  this.createBlock(el, context, "if");
1447
1525
  return;
1448
1526
  }
1527
+ if (el.hasAttribute("data-bind-tag")) {
1528
+ if (el.tagName === "TEMPLATE") {
1529
+ warn("$tag on a <template> means nothing - it renders its contents, not itself.", el);
1530
+ el.removeAttribute("data-bind-tag");
1531
+ } else {
1532
+ this.createBlock(el, context, "tag");
1533
+ return;
1534
+ }
1535
+ }
1449
1536
  if (el.hasAttribute("data-bind-else") || el.hasAttribute("data-bind-else-if")) {
1450
1537
  warn("$else / $else-if must directly follow a $if or $else-if sibling", el);
1451
1538
  el.remove();
@@ -1591,6 +1678,13 @@ class Mounter {
1591
1678
  i += consumed.templates - 1;
1592
1679
  continue;
1593
1680
  }
1681
+ if (el.hasAttribute("data-bind-tag") && el.tagName !== "TEMPLATE") {
1682
+ const consumed = this.adoptBlock(el, found, d, context, "tag");
1683
+ if (!consumed)
1684
+ return false;
1685
+ d += consumed.dom;
1686
+ continue;
1687
+ }
1594
1688
  if (el.tagName === "SLOT") {
1595
1689
  if (!found[d] || !isSlotStart(found[d])) {
1596
1690
  if (!this.adoptNodes(Array.from(el.childNodes), found.slice(d), context, markers)) {
@@ -1726,18 +1820,19 @@ class Mounter {
1726
1820
  const rawExpression = el.getAttribute(`data-bind-${kind}`) ?? "";
1727
1821
  const branches = conditional ? this.gatherBranches(el) : [];
1728
1822
  const expression = conditional ? composeBranchExpression(branches) : rawExpression;
1729
- const parsed = conditional ? { alias: null, items: expression } : parseForExpression(rawExpression);
1823
+ const parsed = conditional || kind === "tag" ? { alias: null, items: expression } : parseForExpression(rawExpression);
1730
1824
  if (!parsed) {
1731
1825
  warn(`Could not parse $for="${expression}" - expected "item in items"`);
1732
1826
  el.remove();
1733
1827
  return null;
1734
1828
  }
1735
- if (!conditional && el.hasAttribute("data-bind-if")) {
1829
+ if (kind === "for" && el.hasAttribute("data-bind-if")) {
1736
1830
  warn(`$for and $if on the same element are not supported (${rawExpression}). Wrap one in an element.`);
1737
1831
  }
1738
- const keyExpr = conditional ? null : el.getAttribute("data-bind-key");
1832
+ const keyExpr = kind === "for" ? el.getAttribute("data-bind-key") : null;
1739
1833
  el.removeAttribute(`data-bind-${kind}`);
1740
- el.removeAttribute("data-bind-key");
1834
+ if (kind === "for")
1835
+ el.removeAttribute("data-bind-key");
1741
1836
  let anchor;
1742
1837
  if (existingAnchor) {
1743
1838
  anchor = existingAnchor;
@@ -1752,7 +1847,7 @@ class Mounter {
1752
1847
  const compiled = this.bindings.compileExpression(keyExpr, params);
1753
1848
  return compiled(context.owner.state, context.owner.props, this.cellsProxy(), this.bindings.pendingProbe(), ...locals);
1754
1849
  } : null;
1755
- const block = new ForBlock(context.owner, anchor, templates, parsed.alias, context.params, context.locals, keyFor, this, conditional, parsed.items);
1850
+ const block = new ForBlock(context.owner, anchor, templates, parsed.alias, context.params, context.locals, keyFor, this, conditional, parsed.items, kind === "tag");
1756
1851
  context.holder.addBlock(block);
1757
1852
  context.holder.addBinding(this.bindings.createForBinding(block, parsed.items, context.params));
1758
1853
  return block;
@@ -1840,9 +1935,10 @@ class Mounter {
1840
1935
  if (this.ssg) {
1841
1936
  fragment.insertBefore(document.createComment(ROW_MARK), fragment.firstChild);
1842
1937
  }
1843
- row.nodes = Array.from(fragment.childNodes);
1844
1938
  this.bindings.updateRow(block.owner, row);
1845
1939
  row.instances.forEach((child) => this.bindings.updateTree(child));
1940
+ await Promise.all(row.blocks.map((nested) => nested.settled()));
1941
+ row.nodes = Array.from(fragment.childNodes);
1846
1942
  return row;
1847
1943
  }
1848
1944
  connected() {
@@ -2248,7 +2344,7 @@ function isBlockAnchor(node) {
2248
2344
  if (node.nodeType !== 8)
2249
2345
  return false;
2250
2346
  const data = node.data.trim();
2251
- return data.startsWith("for:") || data.startsWith("if:");
2347
+ return data.startsWith("for:") || data.startsWith("if:") || data.startsWith("tag:");
2252
2348
  }
2253
2349
  function isInsignificant(node) {
2254
2350
  return node.nodeType === 3 && !(node.textContent ?? "").trim();
package/dist/ssg.js CHANGED
@@ -590,7 +590,7 @@ class BindingEvaluator {
590
590
  }
591
591
  if (binding.kind === "prop") {
592
592
  const target = binding.target;
593
- if (Object.is(target.props[binding.name], value))
593
+ if (sameValue(target.props[binding.name], value))
594
594
  return;
595
595
  target.props[binding.name] = value;
596
596
  this.onPropChanged(target);
@@ -703,6 +703,26 @@ function setHidden(el, hidden) {
703
703
  else
704
704
  el.removeAttribute(HIDDEN_ATTR);
705
705
  }
706
+ function sameValue(a, b, depth = 0) {
707
+ if (Object.is(a, b))
708
+ return true;
709
+ if (depth > 20 || !isPlain(a) || !isPlain(b))
710
+ return false;
711
+ if (Array.isArray(a) !== Array.isArray(b))
712
+ return false;
713
+ const keys = Object.keys(a);
714
+ if (keys.length !== Object.keys(b).length)
715
+ return false;
716
+ return keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && sameValue(a[key], b[key], depth + 1));
717
+ }
718
+ function isPlain(value) {
719
+ if (value === null || typeof value !== "object")
720
+ return false;
721
+ if (Array.isArray(value))
722
+ return true;
723
+ const proto = Object.getPrototypeOf(value);
724
+ return proto === Object.prototype || proto === null;
725
+ }
706
726
  // src/runtime/events/EventBinder.ts
707
727
  class EventBinder {
708
728
  bind(el, eventType, handlerName, owner, cleanupTarget = owner) {
@@ -1039,6 +1059,30 @@ class Row {
1039
1059
  this.locals[this.locals.length - 1] = value;
1040
1060
  }
1041
1061
  }
1062
+ var TAG_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
1063
+ var REFUSED_TAGS = new Set([
1064
+ "script",
1065
+ "style",
1066
+ "template",
1067
+ "slot",
1068
+ "iframe",
1069
+ "object",
1070
+ "embed"
1071
+ ]);
1072
+ function retag(source, name) {
1073
+ const doc = source.ownerDocument;
1074
+ const el = source.namespaceURI ? doc.createElementNS(source.namespaceURI, name) : doc.createElement(name);
1075
+ for (const attr of Array.from(source.attributes)) {
1076
+ if (attr.namespaceURI)
1077
+ el.setAttributeNS(attr.namespaceURI, attr.name, attr.value);
1078
+ else
1079
+ el.setAttribute(attr.name, attr.value);
1080
+ }
1081
+ for (const child of Array.from(source.childNodes)) {
1082
+ el.appendChild(child.cloneNode(true));
1083
+ }
1084
+ return el;
1085
+ }
1042
1086
  function toIterable(value) {
1043
1087
  if (value == null)
1044
1088
  return [];
@@ -1080,12 +1124,13 @@ class ForBlock {
1080
1124
  renderer;
1081
1125
  conditional;
1082
1126
  itemsExpr;
1127
+ tagged;
1083
1128
  rows = new Map;
1084
1129
  order = [];
1085
1130
  pendingItems = null;
1086
1131
  running = false;
1087
1132
  warnedDuplicates = false;
1088
- constructor(owner, anchor, templates, alias, params, parentLocals, keyFor, renderer, conditional = false, itemsExpr = "") {
1133
+ constructor(owner, anchor, templates, alias, params, parentLocals, keyFor, renderer, conditional = false, itemsExpr = "", tagged = false) {
1089
1134
  this.owner = owner;
1090
1135
  this.anchor = anchor;
1091
1136
  this.templates = templates;
@@ -1096,22 +1141,55 @@ class ForBlock {
1096
1141
  this.renderer = renderer;
1097
1142
  this.conditional = conditional;
1098
1143
  this.itemsExpr = itemsExpr;
1144
+ this.tagged = tagged;
1099
1145
  }
1146
+ retagged = new Map;
1147
+ warnedTag = false;
1100
1148
  toItems(value) {
1101
1149
  if (this.conditional) {
1102
1150
  return Number.isInteger(value) && value >= 0 ? [value] : [];
1103
1151
  }
1152
+ if (this.tagged)
1153
+ return [this.tagFor(value)];
1104
1154
  return toIterable(value);
1105
1155
  }
1106
1156
  templateFor(item) {
1157
+ if (this.tagged) {
1158
+ const source = this.templates[0];
1159
+ if (item === source.localName)
1160
+ return source;
1161
+ let made = this.retagged.get(item);
1162
+ if (!made) {
1163
+ made = retag(source, item);
1164
+ this.retagged.set(item, made);
1165
+ }
1166
+ return made;
1167
+ }
1107
1168
  return this.conditional ? this.templates[item] : this.templates[0];
1108
1169
  }
1170
+ tagFor(value) {
1171
+ const written = this.templates[0].localName;
1172
+ if (value == null || value === "")
1173
+ return written;
1174
+ const name = typeof value === "string" ? value.trim().toLowerCase() : null;
1175
+ if (name && TAG_NAME.test(name) && !REFUSED_TAGS.has(name))
1176
+ return name;
1177
+ if (!this.warnedTag) {
1178
+ this.warnedTag = true;
1179
+ warn(`$tag="${this.itemsExpr}" gave ${JSON.stringify(value)}, which is not an element it can make - rendering <${written}>.`);
1180
+ }
1181
+ return written;
1182
+ }
1109
1183
  schedule(items) {
1110
1184
  this.pendingItems = items;
1111
1185
  if (this.running)
1112
1186
  return;
1113
1187
  this.running = true;
1114
- this.drain();
1188
+ this.draining = this.drain();
1189
+ }
1190
+ draining = null;
1191
+ settled() {
1192
+ return this.draining ?? Promise.resolve();
1115
1193
  }
1116
1194
  async drain() {
1117
1195
  try {
@@ -1469,6 +1547,15 @@ class Mounter {
1469
1547
  this.createBlock(el, context, "if");
1470
1548
  return;
1471
1549
  }
1550
+ if (el.hasAttribute("data-bind-tag")) {
1551
+ if (el.tagName === "TEMPLATE") {
1552
+ warn("$tag on a <template> means nothing - it renders its contents, not itself.", el);
1553
+ el.removeAttribute("data-bind-tag");
1554
+ } else {
1555
+ this.createBlock(el, context, "tag");
1556
+ return;
1557
+ }
1558
+ }
1472
1559
  if (el.hasAttribute("data-bind-else") || el.hasAttribute("data-bind-else-if")) {
1473
1560
  warn("$else / $else-if must directly follow a $if or $else-if sibling", el);
1474
1561
  el.remove();
@@ -1614,6 +1701,13 @@ class Mounter {
1614
1701
  i += consumed.templates - 1;
1615
1702
  continue;
1616
1703
  }
1704
+ if (el.hasAttribute("data-bind-tag") && el.tagName !== "TEMPLATE") {
1705
+ const consumed = this.adoptBlock(el, found, d, context, "tag");
1706
+ if (!consumed)
1707
+ return false;
1708
+ d += consumed.dom;
1709
+ continue;
1710
+ }
1617
1711
  if (el.tagName === "SLOT") {
1618
1712
  if (!found[d] || !isSlotStart(found[d])) {
1619
1713
  if (!this.adoptNodes(Array.from(el.childNodes), found.slice(d), context, markers)) {
@@ -1749,18 +1843,19 @@ class Mounter {
1749
1843
  const rawExpression = el.getAttribute(`data-bind-${kind}`) ?? "";
1750
1844
  const branches = conditional ? this.gatherBranches(el) : [];
1751
1845
  const expression = conditional ? composeBranchExpression(branches) : rawExpression;
1752
- const parsed = conditional ? { alias: null, items: expression } : parseForExpression(rawExpression);
1846
+ const parsed = conditional || kind === "tag" ? { alias: null, items: expression } : parseForExpression(rawExpression);
1753
1847
  if (!parsed) {
1754
1848
  warn(`Could not parse $for="${expression}" - expected "item in items"`);
1755
1849
  el.remove();
1756
1850
  return null;
1757
1851
  }
1758
- if (!conditional && el.hasAttribute("data-bind-if")) {
1852
+ if (kind === "for" && el.hasAttribute("data-bind-if")) {
1759
1853
  warn(`$for and $if on the same element are not supported (${rawExpression}). Wrap one in an element.`);
1760
1854
  }
1761
- const keyExpr = conditional ? null : el.getAttribute("data-bind-key");
1855
+ const keyExpr = kind === "for" ? el.getAttribute("data-bind-key") : null;
1762
1856
  el.removeAttribute(`data-bind-${kind}`);
1763
- el.removeAttribute("data-bind-key");
1857
+ if (kind === "for")
1858
+ el.removeAttribute("data-bind-key");
1764
1859
  let anchor;
1765
1860
  if (existingAnchor) {
1766
1861
  anchor = existingAnchor;
@@ -1775,7 +1870,7 @@ class Mounter {
1775
1870
  const compiled = this.bindings.compileExpression(keyExpr, params);
1776
1871
  return compiled(context.owner.state, context.owner.props, this.cellsProxy(), this.bindings.pendingProbe(), ...locals);
1777
1872
  } : null;
1778
- const block = new ForBlock(context.owner, anchor, templates, parsed.alias, context.params, context.locals, keyFor, this, conditional, parsed.items);
1873
+ const block = new ForBlock(context.owner, anchor, templates, parsed.alias, context.params, context.locals, keyFor, this, conditional, parsed.items, kind === "tag");
1779
1874
  context.holder.addBlock(block);
1780
1875
  context.holder.addBinding(this.bindings.createForBinding(block, parsed.items, context.params));
1781
1876
  return block;
@@ -1863,9 +1958,10 @@ class Mounter {
1863
1958
  if (this.ssg) {
1864
1959
  fragment.insertBefore(document.createComment(ROW_MARK), fragment.firstChild);
1865
1960
  }
1866
- row.nodes = Array.from(fragment.childNodes);
1867
1961
  this.bindings.updateRow(block.owner, row);
1868
1962
  row.instances.forEach((child) => this.bindings.updateTree(child));
1963
+ await Promise.all(row.blocks.map((nested) => nested.settled()));
1964
+ row.nodes = Array.from(fragment.childNodes);
1869
1965
  return row;
1870
1966
  }
1871
1967
  connected() {
@@ -2271,7 +2367,7 @@ function isBlockAnchor(node) {
2271
2367
  if (node.nodeType !== 8)
2272
2368
  return false;
2273
2369
  const data = node.data.trim();
2274
- return data.startsWith("for:") || data.startsWith("if:");
2370
+ return data.startsWith("for:") || data.startsWith("if:") || data.startsWith("tag:");
2275
2371
  }
2276
2372
  function isInsignificant(node) {
2277
2373
  return node.nodeType === 3 && !(node.textContent ?? "").trim();
@@ -3161,7 +3257,14 @@ function serializeNodes(nodes) {
3161
3257
 
3162
3258
  // src/ssg/render.ts
3163
3259
  var MAX_BUILD_ROUNDS = 6;
3164
- async function renderPage(options) {
3260
+ var queue = Promise.resolve();
3261
+ function renderPage(options) {
3262
+ const next = queue.then(() => renderPageNow(options));
3263
+ queue = next.catch(() => {
3264
+ });
3265
+ return next;
3266
+ }
3267
+ async function renderPageNow(options) {
3165
3268
  const jsdom = await loadJsdom();
3166
3269
  if (!jsdom) {
3167
3270
  return {
@@ -73,6 +73,8 @@ export declare class ForBlock {
73
73
  conditional: boolean;
74
74
  /** The collection expression, kept so adoption can re-evaluate it. */
75
75
  itemsExpr: string;
76
+ /** `$tag`: one row, of the element the expression names (ADR-063). */
77
+ tagged: boolean;
76
78
  rows: Map<string, Row>;
77
79
  order: string[];
78
80
  private pendingItems;
@@ -93,7 +95,12 @@ export declare class ForBlock {
93
95
  /** `$if`: zero or one row, driven by a condition rather than a list. */
94
96
  conditional?: boolean,
95
97
  /** The collection expression, kept so adoption can re-evaluate it. */
96
- itemsExpr?: string);
98
+ itemsExpr?: string,
99
+ /** `$tag`: one row, of the element the expression names (ADR-063). */
100
+ tagged?: boolean);
101
+ /** `$tag`'s copies of the template under other names, made once each. */
102
+ private retagged;
103
+ private warnedTag;
97
104
  /**
98
105
  * Turn a bound expression into the rows it implies.
99
106
  *
@@ -103,6 +110,8 @@ export declare class ForBlock {
103
110
  toItems(value: any): any[];
104
111
  /** The markup a row should clone. */
105
112
  templateFor(item: any): Element;
113
+ /** The element `$tag` evaluated to, or the one as written. */
114
+ private tagFor;
106
115
  /**
107
116
  * Queue a reconcile.
108
117
  *
@@ -111,6 +120,14 @@ export declare class ForBlock {
111
120
  * matters, so it replaces any queued one and the loop runs until settled.
112
121
  */
113
122
  schedule(items: any[]): void;
123
+ private draining;
124
+ /**
125
+ * Resolves once every queued reconcile has run.
126
+ *
127
+ * A row built inside another block's new row waits for this before that row
128
+ * counts its nodes, or the rows placed meanwhile are left out of it.
129
+ */
130
+ settled(): Promise<void>;
114
131
  private drain;
115
132
  /** Public so adoption can match rendered rows to the current data. */
116
133
  keysFor(items: any[]): string[];
@@ -0,0 +1 @@
1
+ export {};
package/dist/vite.js CHANGED
@@ -329,7 +329,7 @@ class BindingEvaluator {
329
329
  }
330
330
  if (binding.kind === "prop") {
331
331
  const target = binding.target;
332
- if (Object.is(target.props[binding.name], value))
332
+ if (sameValue(target.props[binding.name], value))
333
333
  return;
334
334
  target.props[binding.name] = value;
335
335
  this.onPropChanged(target);
@@ -442,6 +442,26 @@ function setHidden(el, hidden) {
442
442
  else
443
443
  el.removeAttribute(HIDDEN_ATTR);
444
444
  }
445
+ function sameValue(a, b, depth = 0) {
446
+ if (Object.is(a, b))
447
+ return true;
448
+ if (depth > 20 || !isPlain(a) || !isPlain(b))
449
+ return false;
450
+ if (Array.isArray(a) !== Array.isArray(b))
451
+ return false;
452
+ const keys = Object.keys(a);
453
+ if (keys.length !== Object.keys(b).length)
454
+ return false;
455
+ return keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && sameValue(a[key], b[key], depth + 1));
456
+ }
457
+ function isPlain(value) {
458
+ if (value === null || typeof value !== "object")
459
+ return false;
460
+ if (Array.isArray(value))
461
+ return true;
462
+ const proto = Object.getPrototypeOf(value);
463
+ return proto === Object.prototype || proto === null;
464
+ }
445
465
  // src/runtime/dom/ForBlock.ts
446
466
  class Row {
447
467
  locals;
@@ -470,6 +490,30 @@ class Row {
470
490
  this.locals[this.locals.length - 1] = value;
471
491
  }
472
492
  }
493
+ var TAG_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
494
+ var REFUSED_TAGS = new Set([
495
+ "script",
496
+ "style",
497
+ "template",
498
+ "slot",
499
+ "iframe",
500
+ "object",
501
+ "embed"
502
+ ]);
503
+ function retag(source, name) {
504
+ const doc = source.ownerDocument;
505
+ const el = source.namespaceURI ? doc.createElementNS(source.namespaceURI, name) : doc.createElement(name);
506
+ for (const attr of Array.from(source.attributes)) {
507
+ if (attr.namespaceURI)
508
+ el.setAttributeNS(attr.namespaceURI, attr.name, attr.value);
509
+ else
510
+ el.setAttribute(attr.name, attr.value);
511
+ }
512
+ for (const child of Array.from(source.childNodes)) {
513
+ el.appendChild(child.cloneNode(true));
514
+ }
515
+ return el;
516
+ }
473
517
  function toIterable(value) {
474
518
  if (value == null)
475
519
  return [];
@@ -511,12 +555,13 @@ class ForBlock {
511
555
  renderer;
512
556
  conditional;
513
557
  itemsExpr;
558
+ tagged;
514
559
  rows = new Map;
515
560
  order = [];
516
561
  pendingItems = null;
517
562
  running = false;
518
563
  warnedDuplicates = false;
519
- constructor(owner, anchor, templates, alias, params, parentLocals, keyFor, renderer, conditional = false, itemsExpr = "") {
564
+ constructor(owner, anchor, templates, alias, params, parentLocals, keyFor, renderer, conditional = false, itemsExpr = "", tagged = false) {
520
565
  this.owner = owner;
521
566
  this.anchor = anchor;
522
567
  this.templates = templates;
@@ -527,22 +572,55 @@ class ForBlock {
527
572
  this.renderer = renderer;
528
573
  this.conditional = conditional;
529
574
  this.itemsExpr = itemsExpr;
575
+ this.tagged = tagged;
530
576
  }
577
+ retagged = new Map;
578
+ warnedTag = false;
531
579
  toItems(value) {
532
580
  if (this.conditional) {
533
581
  return Number.isInteger(value) && value >= 0 ? [value] : [];
534
582
  }
583
+ if (this.tagged)
584
+ return [this.tagFor(value)];
535
585
  return toIterable(value);
536
586
  }
537
587
  templateFor(item) {
588
+ if (this.tagged) {
589
+ const source = this.templates[0];
590
+ if (item === source.localName)
591
+ return source;
592
+ let made = this.retagged.get(item);
593
+ if (!made) {
594
+ made = retag(source, item);
595
+ this.retagged.set(item, made);
596
+ }
597
+ return made;
598
+ }
538
599
  return this.conditional ? this.templates[item] : this.templates[0];
539
600
  }
601
+ tagFor(value) {
602
+ const written = this.templates[0].localName;
603
+ if (value == null || value === "")
604
+ return written;
605
+ const name = typeof value === "string" ? value.trim().toLowerCase() : null;
606
+ if (name && TAG_NAME.test(name) && !REFUSED_TAGS.has(name))
607
+ return name;
608
+ if (!this.warnedTag) {
609
+ this.warnedTag = true;
610
+ warn(`$tag="${this.itemsExpr}" gave ${JSON.stringify(value)}, which is not an element it can make - rendering <${written}>.`);
611
+ }
612
+ return written;
613
+ }
540
614
  schedule(items) {
541
615
  this.pendingItems = items;
542
616
  if (this.running)
543
617
  return;
544
618
  this.running = true;
545
- this.drain();
619
+ this.draining = this.drain();
620
+ }
621
+ draining = null;
622
+ settled() {
623
+ return this.draining ?? Promise.resolve();
546
624
  }
547
625
  async drain() {
548
626
  try {
@@ -900,6 +978,15 @@ class Mounter {
900
978
  this.createBlock(el, context, "if");
901
979
  return;
902
980
  }
981
+ if (el.hasAttribute("data-bind-tag")) {
982
+ if (el.tagName === "TEMPLATE") {
983
+ warn("$tag on a <template> means nothing - it renders its contents, not itself.", el);
984
+ el.removeAttribute("data-bind-tag");
985
+ } else {
986
+ this.createBlock(el, context, "tag");
987
+ return;
988
+ }
989
+ }
903
990
  if (el.hasAttribute("data-bind-else") || el.hasAttribute("data-bind-else-if")) {
904
991
  warn("$else / $else-if must directly follow a $if or $else-if sibling", el);
905
992
  el.remove();
@@ -1045,6 +1132,13 @@ class Mounter {
1045
1132
  i += consumed.templates - 1;
1046
1133
  continue;
1047
1134
  }
1135
+ if (el.hasAttribute("data-bind-tag") && el.tagName !== "TEMPLATE") {
1136
+ const consumed = this.adoptBlock(el, found, d, context, "tag");
1137
+ if (!consumed)
1138
+ return false;
1139
+ d += consumed.dom;
1140
+ continue;
1141
+ }
1048
1142
  if (el.tagName === "SLOT") {
1049
1143
  if (!found[d] || !isSlotStart(found[d])) {
1050
1144
  if (!this.adoptNodes(Array.from(el.childNodes), found.slice(d), context, markers)) {
@@ -1180,18 +1274,19 @@ class Mounter {
1180
1274
  const rawExpression = el.getAttribute(`data-bind-${kind}`) ?? "";
1181
1275
  const branches = conditional ? this.gatherBranches(el) : [];
1182
1276
  const expression = conditional ? composeBranchExpression(branches) : rawExpression;
1183
- const parsed = conditional ? { alias: null, items: expression } : parseForExpression(rawExpression);
1277
+ const parsed = conditional || kind === "tag" ? { alias: null, items: expression } : parseForExpression(rawExpression);
1184
1278
  if (!parsed) {
1185
1279
  warn(`Could not parse $for="${expression}" - expected "item in items"`);
1186
1280
  el.remove();
1187
1281
  return null;
1188
1282
  }
1189
- if (!conditional && el.hasAttribute("data-bind-if")) {
1283
+ if (kind === "for" && el.hasAttribute("data-bind-if")) {
1190
1284
  warn(`$for and $if on the same element are not supported (${rawExpression}). Wrap one in an element.`);
1191
1285
  }
1192
- const keyExpr = conditional ? null : el.getAttribute("data-bind-key");
1286
+ const keyExpr = kind === "for" ? el.getAttribute("data-bind-key") : null;
1193
1287
  el.removeAttribute(`data-bind-${kind}`);
1194
- el.removeAttribute("data-bind-key");
1288
+ if (kind === "for")
1289
+ el.removeAttribute("data-bind-key");
1195
1290
  let anchor;
1196
1291
  if (existingAnchor) {
1197
1292
  anchor = existingAnchor;
@@ -1206,7 +1301,7 @@ class Mounter {
1206
1301
  const compiled = this.bindings.compileExpression(keyExpr, params);
1207
1302
  return compiled(context.owner.state, context.owner.props, this.cellsProxy(), this.bindings.pendingProbe(), ...locals);
1208
1303
  } : null;
1209
- const block = new ForBlock(context.owner, anchor, templates, parsed.alias, context.params, context.locals, keyFor, this, conditional, parsed.items);
1304
+ const block = new ForBlock(context.owner, anchor, templates, parsed.alias, context.params, context.locals, keyFor, this, conditional, parsed.items, kind === "tag");
1210
1305
  context.holder.addBlock(block);
1211
1306
  context.holder.addBinding(this.bindings.createForBinding(block, parsed.items, context.params));
1212
1307
  return block;
@@ -1294,9 +1389,10 @@ class Mounter {
1294
1389
  if (this.ssg) {
1295
1390
  fragment.insertBefore(document.createComment(ROW_MARK), fragment.firstChild);
1296
1391
  }
1297
- row.nodes = Array.from(fragment.childNodes);
1298
1392
  this.bindings.updateRow(block.owner, row);
1299
1393
  row.instances.forEach((child) => this.bindings.updateTree(child));
1394
+ await Promise.all(row.blocks.map((nested) => nested.settled()));
1395
+ row.nodes = Array.from(fragment.childNodes);
1300
1396
  return row;
1301
1397
  }
1302
1398
  connected() {
@@ -1702,7 +1798,7 @@ function isBlockAnchor(node) {
1702
1798
  if (node.nodeType !== 8)
1703
1799
  return false;
1704
1800
  const data = node.data.trim();
1705
- return data.startsWith("for:") || data.startsWith("if:");
1801
+ return data.startsWith("for:") || data.startsWith("if:") || data.startsWith("tag:");
1706
1802
  }
1707
1803
  function isInsignificant(node) {
1708
1804
  return node.nodeType === 3 && !(node.textContent ?? "").trim();
@@ -4552,7 +4648,14 @@ function serializeNodes(nodes) {
4552
4648
 
4553
4649
  // src/ssg/render.ts
4554
4650
  var MAX_BUILD_ROUNDS = 6;
4555
- async function renderPage(options) {
4651
+ var queue = Promise.resolve();
4652
+ function renderPage(options) {
4653
+ const next = queue.then(() => renderPageNow(options));
4654
+ queue = next.catch(() => {
4655
+ });
4656
+ return next;
4657
+ }
4658
+ async function renderPageNow(options) {
4556
4659
  const jsdom = await loadJsdom();
4557
4660
  if (!jsdom) {
4558
4661
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightspeed-js/core",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Lightspeed: a compile-first framework for islands of reactivity in static HTML.",
5
5
  "keywords": [
6
6
  "lightspeed",