All files / src jq79.ts

98.82% Statements 759/768
94.64% Branches 442/467
99.31% Functions 145/146
99.37% Lines 640/644

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568                                                11x 2062x               944x       1409x 963x 963x   446x 420x   26x                                   11x   11x 10011x 10011x 10011x 367x         367x   1x   367x   10011x     11x 9982x 9982x 9981x 9981x   28x           11x 1345x     11x       11x                     11x 509x 509x   509x 125x 124x 124x   124x 124x                         11x 8x 8x   8x 6x 6x 6x                 6x 6x 6x     8x     260x                   11x 1144x             11x 132x 153x 153x 153x         11x 1056x 1056x 1150x 1150x 1150x                 11x 2281x 2281x 4267x 19489x     2112x                               11x         169x 169x 169x 169x   169x 169x 169x 169x     309x 279x 8x 271x         29x 29x 242x 241x 241x   1x                 169x 169x         169x 169x 29x 29x 1x   29x     29x 1x       169x 169x 169x   169x 301x 301x 301x   170x 301x 301x 301x 301x 301x       168x                       168x       28x 28x 4x 2x 2x   28x 29x 2x 2x   27x 29x 29x 2x 2x             25x             168x   168x 269x         168x 168x 301x   301x 301x 422x     301x 301x     169x 109x 109x     169x                       11x 13x 97x 97x   13x   34x 34x     58x 58x 37x     5x 5x 4x 4x   1x                     11x 98x 48x 45x 42x 3x                 11x 11x 1x 2x 2x           10x 2x                 11x       2269x 2269x   2269x 2269x   2106x             2106x 6x 6x 13x 12x 12x 6x 6x     6x 6x 6x       2106x 2930x 2421x         2x 1x   2419x     2106x 2106x 833x   833x 2838x 2838x 2838x 2838x 2845x 2845x                 2106x 2106x 30x 30x   30x 48x 48x 11x   48x 48x                     2106x 2106x 2106x 2106x 1x   2106x 9x 2100x 549x 2186x 2186x     1551x                     2106x 2106x 9x 19x 19x     2106x 4212x 4212x 3x 8x     2106x             11x 87x 87x 87x   87x 87x 87x   87x 214x 186x   108x 186x 108x 108x 108x   92x     92x 92x 92x     87x                     11x 4374x                 11x 21x 10x 10x                       11x 187x 187x   185x 185x 185x 185x   185x 185x 185x       185x 1x     185x 185x   185x 389x         389x 1763x           389x 389x 828x 828x 825x     389x 389x 389x 1790x 1790x 1790x 1790x 1790x 1790x 3x 3x   1790x 1790x   1790x 744x 744x 744x 744x     1046x 67x 67x     1046x     1046x 1046x       825x 17x 17x     389x 389x 1790x 1790x             389x   389x     185x         11x 2059x 2059x   2059x 3480x   3480x 2075x     2075x 2075x 2075x 2075x     1405x 187x 187x 187x     1218x 87x 87x           87x 176x 176x 176x 176x 30x 30x   146x     87x 2x   87x 87x   87x 87x     1131x 1131x     2059x     11x 81x                             11x             11x 11x             11x 447x     1157x     153x                   11x       11x 21x 5248x 21x     11x 55x 71x 34x 34x             11x 33x     34x 34x 34x 34x 34x             11x 22x 35x 2x               11x 21x 1x   21x 21x 21x 34x           11x                                     447x 447x   447x 447x 447x   447x 880x   880x 684x 524x             447x 160x 1x                     447x 447x 21x 21x 21x 21x       447x       11x 5x                                                   11x 251x             168x         11x   11x 91x 91x 77x 77x 77x 77x 77x   91x     11x 77x 77x 73x 73x             11x                         11x     229x 229x   10277x     229x       229x                             11x 251x 260x 14x           11x                 11x 22x 22x 22x         22x 22x 22x 38x 21x 21x 21x 20x 18x       20x       20x 20x 15x   1x       22x 22x                                       11x 11x       11x   11x 611x 14x 14x 14x             11x 56x 56x                         11x 11x   11x     11x   11x 11x 45x 9x 12x 12x       12x   9x   11x           11x 14x 14x                                             611x   611x       611x     611x 611x     611x 611x 611x 611x   611x     611x     611x 611x 611x 611x 611x 611x 611x                                 12x 12x 12x         12x 12x 12x 12x 12x         12x   12x 12x 12x 12x   9x 9x       9x 9x 9x 9x 12x       9x 9x     8x               43x 43x 43x       2x 2x       241x           33x       430x   430x 430x 430x 430x 430x   430x 430x                         430x 430x 66x 66x 64x       66x 66x                   430x 430x 430x             430x 430x 287x 287x 259x 259x 259x     287x   430x         430x 430x 101x             430x   430x 252x 252x 252x 252x 23x 23x 23x 23x   229x 229x     668x 229x 252x     430x             430x 2627x   14827x   430x 430x   430x 122x 119x 119x 119x     305x 305x     427x                   333x 333x 332x 330x           91x 91x 90x 89x       419x   419x     419x 419x 419x 419x 419x           721x       290x 290x 900x 900x 900x 610x     290x 290x       716x 716x 716x     716x 716x 716x 716x 264x 264x   716x 716x 716x 716x 716x 716x       87x             11x    
 
import { $, $$, $create, sanitizeHTML, allowedHosts } from "./dom"
import type { AllowUrl } from "./dom"
import { $reactive, untracked, createEffectScope } from "./reactive"
import type { ReactiveDeepData, EffectScope } from "./reactive"
import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps, type PropDecl } from "./transform"
 
export { $, $$, $create } from "./dom"
export { $reactive } from "./reactive"
type TemplateNode = {
  tag: string
  attrs: Record<string, string>
  children: (TemplateNode | string)[]
}
 
type TagBlock = {
  attrs: Record<string, string>
  content: string
  // <style scoped> only: `content` rewritten to require the component's scope
  // attribute. Kept beside the original rather than replacing it, because a
  // shadow root doesn't want it - see headStyle()
  scoped?: string
}
 
const elementAttrs = (el: Element): Record<string, string> =>
  Object.fromEntries(Array.from(el.attributes).map(attr => [attr.name, attr.value]))
 
// text is kept verbatim - not trimmed, not dropped when it's only whitespace.
// A template is HTML, so the space in `<span>a</span>\n<span>b</span>` is the
// same space the browser would collapse-and-render between them, and CSS gets
// to decide what it's worth (nothing in a block or flex container, one space
// between inline elements). Trimming it here, as this used to, silently glued
// siblings together and ate the spaces in `hola <b>mundo</b> adios`
const elementToAST = (el: Element): TemplateNode => ({
  tag: el.tagName.toLowerCase(),
  attrs: elementAttrs(el),
  children: Array.from(el.childNodes).flatMap((node): (TemplateNode | string)[] => {
    if (node.nodeType === Node.TEXT_NODE) {
      const text = node.textContent ?? ""
      return text ? [text] : []
    }
    if (node.nodeType === Node.ELEMENT_NODE) {
      return [elementToAST(node as Element)]
    }
    return []
  })
})
 
// evaluated with `with` (rather than passing scope keys as positional params)
// so only the identifiers an expression actually references are read from
// `scope` - which is what makes dependency tracking in $reactive
// precise instead of "read everything up front". `extras` are passed as
// function parameters (outside the `with`), so scope keys still win but names
// like $event resolve when the scope doesn't shadow them
//
// Compiled functions are cached: an expression is re-evaluated on every effect
// run - once per interpolation, once per :each item - while the set of distinct
// expressions is fixed by the source. The `extras` names are part of the key,
// not just the expression: they become the function's parameters, so the same
// expression compiled with and without $event is two different functions. A
// syntactically invalid expression caches its failure (null) so it isn't
// recompiled, and rethrown as undefined, exactly as before
const compiled = new Map<string, Function | null>()
 
const compileExpr = (expr: string, params: string[]): Function | null => {
  const key = `${params.join(",")}|${expr}`
  let fn = compiled.get(key)
  if (fn === undefined) {
    try {
      // the newline before `)` ends a trailing line comment in the
      // expression ({{ msg // greeting }}); ASI doesn't apply inside parens,
      // so everything else is untouched. Without it the comment eats the
      // rest of this single-line body and the expression never compiles
      fn = new Function("$scope", ...params, `with ($scope) { return (${expr}\n); }`)
    } catch {
      fn = null // a syntax error: it will never compile, so don't try again
    }
    compiled.set(key, fn)
  }
  return fn
}
 
const evalExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
  const fn = compileExpr(expr, extras ? Object.keys(extras) : [])
  if (!fn) return undefined
  try {
    return fn(scope, ...(extras ? Object.values(extras) : []))
  } catch {
    return undefined
  }
}
 
// [\s\S] rather than `.` so an expression can span lines, like the ones in
// directive attributes (which reach evalExpr wrapped in parens either way)
const interpolate = (template: string, scope: Record<string, any>): string =>
  template.replace(/{{\s*([\s\S]+?)\s*}}/g, (_, expr) => evalExpr(expr, scope) ?? "")
 
 
const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed"])
// `item in items`, `item, i in items`, `(value, key) in props` - the second
// binding is the array index or the object key, parens optional (Vue-style).
// The list expression can span lines, so it matches [\s\S] rather than `.`
const EACH_PATTERN = /^\s*\(?\s*(\w+)\s*(?:,\s*(\w+))?\s*\)?\s+in\s+([\s\S]+)$/
 
type ConditionalBranch = { expr?: string; node: TemplateNode }
 
 
// @event attributes: @click="onClick", @submit.prevent="$event => onSubmit($event)",
// or an inline statement like @click="count = count + 1". The expression is
// evaluated (with `$event` in scope) on every event; if it yields a function,
// that function is then invoked with the event - so both a handler reference
// and an inline arrow/statement work. Modifiers after dots: .prevent .stop
// .self (runtime guards) and .once .capture (addEventListener options)
const bindEvent = (el: Element, attr: string, expr: string, scope: Record<string, any>) => {
  const [name, ...modifiers] = attr.slice(1).split(".")
  const mods = new Set(modifiers)
 
  el.addEventListener(name, event => {
    if (mods.has("self") && event.target !== el) return
    if (mods.has("prevent")) event.preventDefault()
    if (mods.has("stop")) event.stopPropagation()
 
    const handler = evalExpr(expr, scope, { $event: event })
    if (typeof handler === "function") handler.call(el, event)
  }, { once: mods.has("once"), capture: mods.has("capture") })
}
 
// @event on a component tag: the tag renders as comment anchors, so there is
// no element to listen on - the attribute subscribes to the child instance's
// $emit channel (instance.on) instead, which survives the child's re-renders
// and works detached. Native DOM events from the child's inner DOM never
// arrive here: they bubble past the anchors to shared ancestors (a listener
// on a wrapping element hears those); a child that wants its native event
// heard on its tag re-emits it. .prevent flips the child's $emit() return to
// false, .stop keeps the emit off the DOM dispatch, .once unsubscribes after
// one call; .self and .capture have no meaning on this channel and are ignored
const wireTagEvent = (instance: Component79, attr: string, expr: string, scope: Record<string, any>) => {
  const [name, ...modifiers] = attr.slice(1).split(".")
  const mods = new Set(modifiers)
 
  const listener = (event: CustomEvent) => {
    if (mods.has("prevent")) event.preventDefault()
    if (mods.has("stop")) event.stopPropagation()
    if (mods.has("once")) instance.off(name, listener)
 
    // untracked, so a tag handler behaves like an element handler no matter
    // when the emit fires: an element handler never runs inside an effect,
    // but $emit can (a $: that emits, a setup-script emit inside the parent's
    // creation effect), and the handler's reads would land in that effect's
    // deps. Today that is contained - cross-store deps are never notified,
    // and the creation effect's definition guard no-ops a spurious wake - but
    // "what a handler reads is nobody's dependency" shouldn't hinge on either
    untracked(() => {
      const handler = evalExpr(expr, scope, { $event: event })
      Iif (typeof handler === "function") handler(event)
    })
  }
  instance.on(name, listener)
}
 
const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
 
// the stable boundaries of a rendered chunk. An element is its own handle, but
// a fragment (a nested component: two anchors with the instance's DOM between
// them) empties itself into the parent on insertion - after that its identity
// answers nothing, and what stays put are its first and last children. Callers
// that reposition or remove a chunk later (:each entries, :if branches) must
// capture its bounds *before* inserting it and work on the range
type NodeRange = { first: Node; last: Node }
 
const boundsOf = (node: Node): NodeRange =>
  node instanceof DocumentFragment
    ? { first: node.firstChild!, last: node.lastChild! }
    : { first: node, last: node }
 
// removes [first..last] inclusive - the range's content is dynamic (a nested
// component's DOM comes and goes between its anchors), so it walks siblings
// rather than assuming any particular nodes in between
const removeRange = ({ first, last }: NodeRange) => {
  for (let node: Node | null = first; node; ) {
    const next: Node | null = node === last ? null : node.nextSibling
    node.parentNode?.removeChild(node)
    node = next
  }
}
 
// moves [first..last] inclusive so the range starts right after `prev`
const moveRangeAfter = ({ first, last }: NodeRange, prev: Node) => {
  const ref = prev.nextSibling
  for (let node: Node | null = first; node; ) {
    const next: Node | null = node === last ? null : node.nextSibling
    prev.parentNode!.insertBefore(node, ref)
    node = next
  }
}
 
// finds the scope variable a template tag refers to. HTML parsing lowercases
// tag names, so <NestedComponent> arrives as "nestedcomponent" and matching is
// case-insensitive with dashes stripped (<nested-component> works too). Only
// PascalCase scope keys participate, so ordinary variables named like real
// elements (title, code, ...) never hijack them
const findComponentKey = (scope: Record<string, any>, tag: string): string | null => {
  const normalized = tag.replace(/-/g, "").toLowerCase()
  for (let obj: any = scope; obj && obj !== Object.prototype; obj = Object.getPrototypeOf(obj)) {
    for (const key of Object.keys(obj)) {
      if (/^[A-Z]/.test(key) && key.replace(/-/g, "").toLowerCase() === normalized) return key
    }
  }
  return null
}
 
// <MyComponent :user :title="'str'"></MyComponent> - renders a child
// component instance at this position. Props: `:name="expr"` evaluates expr
// in the parent scope (`:name` alone is shorthand for `:name="name"`), plain
// attributes pass through as literal strings, and kebab-case prop names
// become camelCase. Props stay live: a parent effect re-evaluates each
// expression and writes it into the child's store. The component variable is
// reactive too - while it's undefined (e.g. an `await import(...)` still in
// flight) nothing renders, and the child appears when it resolves.
// `shadow` is the parent's style mode, carried down the whole render: a child
// of a shadow-rendered component renders inside that shadow root, so its
// <style> has to go in there with it - document.head can't reach into a shadow
// tree, and a style that never applies to its own component would still be
// restyling the page around it
const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
  // two anchors bracketing everything this usage site ever renders: the
  // instance's DOM is dynamic (the definition can resolve late or be swapped),
  // so a caller that needs to move or remove this chunk later can't hold any
  // of it - it holds the anchors, which never move on their own (see boundsOf)
  const anchor = document.createComment(key)
  const endAnchor = document.createComment(`/${key}`)
  const wrapper = document.createDocumentFragment()
  wrapper.append(anchor, endAnchor)
 
  const props: Record<string, string> = {} // prop name -> expression in parent scope
  const models: Record<string, string> = {} // model name -> assignable expression in parent scope
  const events: Array<[string, string]> = [] // @attr (modifiers included) -> handler expression
  Object.entries(node.attrs).forEach(([attr, value]) => {
    // the parent's scope stamp is stamped on every template element, this tag
    // included - it's not a prop, and the child renders under its own scope
    if (attr === SCOPE_ATTR || CONTROL_ATTRS.has(attr)) return
    if (attr.startsWith("@")) {
      events.push([attr, value])
    } else if (attr === ":model" || attr.startsWith(":model.")) {
      // :model[.name]="expr" - two-way: a prop down plus a writeback listener
      // (wired below, once the instance exists). The modifier arrives
      // lowercased from the HTML parser, so names are declared kebab-case;
      // the bare :model binds the name "default"
      const name = attr === ":model" ? "default" : kebabToCamel(attr.slice(":model.".length))
      models[name] = value || (attr === ":model" ? "model" : name)
    } else if (attr.startsWith(":")) {
      const name = kebabToCamel(attr.slice(1))
      props[name] = value || name
    } else {
      props[kebabToCamel(attr)] = JSON.stringify(value)
    }
  })
 
  // each model is also a prop down: the child reads the value under the
  // model's name - `model` for the default, because a prop named `default`
  // could never be read from a child expression (reserved word). Without the
  // prop this isn't two-way, it's upward collection: a parent reset or an
  // initial value would never reach the child
  const modelAttr = (name: string) => (name === "default" ? ":model" : `:model.${name}`)
  const modelProp = (name: string) => (name === "default" ? "model" : name)
  // the newline keeps `= $value` out of a trailing line comment in the
  // expression (:model="uname // the username") - glued on the same line,
  // the assignment would vanish into the comment and compile as a bare read,
  // dropping every update without a word
  const assignment = (expr: string) => `${expr}\n= $value`
  Object.entries(models).forEach(([name, expr]) => {
    const prop = modelProp(name)
    if (props[prop] !== undefined) {
      console.warn(`jq79: <${node.tag}> binds prop "${prop}" through both :${prop} and ${modelAttr(name)} - ${modelAttr(name)} wins`)
    }
    props[prop] = expr
    // an expression that can't be an assignment target is a wiring mistake -
    // say so now, not on the first update that silently goes nowhere
    if (compileExpr(assignment(expr), ["$value"]) === null) {
      console.warn(`jq79: ${modelAttr(name)}="${expr}" is not assignable - updates from <${node.tag}> will be dropped`)
    }
  })
 
  let current: Component79 | null = null
  let currentDef: Component79 | null = null
  let childFx: EffectScope | null = null
 
  fx.effect(() => {
    const value = evalExpr(key, scope)
    const nextDef = value instanceof Component79 ? value : null
    if (nextDef === currentDef) return
 
    childFx?.dispose()
    childFx = null
    current?.destroy() // detaches its marker range, removing the child's DOM
    current = null
    currentDef = nextDef
    if (!nextDef) return
 
    // a fresh instance per usage site: the definition's parsed parts (and
    // pre-resolved modules) are shared, but store/effects/DOM are per instance
    const instance = new Component79({
      template: nextDef.template,
      scripts: nextDef.scripts,
      styles: nextDef.styles,
      modules: nextDef.modules,
      filename: nextDef.filename,
    })
    // the writeback half of :model - one event, one contract. The name is
    // normalized like the attribute was (kebab->camel; absent means default),
    // and everything off-contract warns and does nothing: an event protocol's
    // failure mode has to be loud, or a typo'd name is an input that types
    // into the void
    if (Object.keys(models).length) {
      // each mistake is warned once per instance, not once per keystroke: an
      // input emitting a typo'd name would otherwise flood the console on
      // every character typed into it
      const warned = new Set<string>()
      const warnOnce = (key: string, message: string) => {
        if (warned.has(key)) return
        warned.add(key)
        console.warn(message)
      }
      instance.on("model:update", (_event, payload) => {
        if (payload === null || typeof payload !== "object") {
          warnOnce("payload", `jq79: model:update expects a { name?, value } payload, got ${payload === null ? "null" : typeof payload}`)
          return
        }
        const name = payload.name == null ? "default" : kebabToCamel(String(payload.name))
        const expr = models[name]
        if (expr === undefined) {
          warnOnce(name, `jq79: <${node.tag}> has no ${modelAttr(name)} - bound: ${Object.keys(models).map(modelAttr).join(", ")}`)
          return
        }
        // untracked, like the tag handlers: a child emitting from its setup
        // script runs inside the parent's *creation* effect, and the reads a
        // path assignment makes (`user` in `user.name = $value`) would land
        // in its deps - donating that effect one wasted (guard-stopped) wake
        // per later write. An imperative writeback is nobody's dependency
        untracked(() => evalExpr(assignment(expr), scope, { $value: payload.value }))
      })
    }
 
    // @event on the tag listens to this instance's $emit channel (and only
    // this instance's - a grandchild's emit arrives here solely as an
    // explicit re-emit)
    events.forEach(([attr, expr]) => wireTagEvent(instance, attr, expr, scope))
 
    const seed = untracked(() =>
      Object.fromEntries(Object.entries(props).map(([name, expr]) => [name, evalExpr(expr, scope)]))
    )
    // mounting into a fragment attaches no shadow root of its own: a
    // shadow-rendered child keeps its <style> elements inline, next to the DOM
    // they style, and the parent's shadow root is what scopes both
    const holder = document.createDocumentFragment()
    ;(shadow ? instance.renderShadow(seed) : instance.render(seed)).mount(holder)
    endAnchor.parentNode!.insertBefore(holder, endAnchor)
 
    const syncFx = createEffectScope(scope)
    Object.entries(props).forEach(([name, expr]) => {
      syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
    })
 
    childFx = syncFx
    current = instance
  })
 
  fx.onDispose(() => {
    childFx?.dispose()
    current?.destroy()
  })
 
  return wrapper
}
 
// :with="expr" narrows the scope for an element and its subtree: names
// resolve against the expression's value first, then fall back to the outer
// scope. The value is re-evaluated lazily on every name lookup (never
// snapshotted), so an effect reading through this proxy tracks both the
// expression's own dependencies and the property it reads - replacing the
// object or mutating one of its properties re-renders exactly the dependents,
// without rebuilding the subtree. Assignments to names the object owns write
// through to it (reactively, if it came from a store); everything else
// behaves as if the :with weren't there
const createWithScope = (expr: string, scope: Record<string, any>): Record<string, any> => {
  const source = (): Record<string, any> | null => {
    const value = evalExpr(expr, scope)
    return value !== null && typeof value === "object" ? value : null
  }
  return new Proxy(scope, {
    has(target, key) {
      const obj = source()
      return (obj !== null && Reflect.has(obj, key)) || Reflect.has(target, key)
    },
    get(target, key) {
      const obj = source()
      if (obj !== null && Reflect.has(obj, key)) return obj[key as string]
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      const obj = source()
      if (obj !== null && Reflect.has(obj, key)) {
        obj[key as string] = value
        return true
      }
      return Reflect.set(target, key, value)
    },
  })
}
 
// what :class accepts, flattened to single class tokens: a string of
// space-separated names, an array (entries normalized recursively), or an
// object whose truthy-valued keys are the names (a key may itself hold
// several). Everything else - null, false, numbers - contributes nothing, so
// `cond && 'active'` reads naturally. The object form reads each value, so a
// store-backed flag is tracked per key
const classNames = (value: any): string[] => {
  if (typeof value === "string") return value.split(/\s+/).filter(Boolean)
  if (Array.isArray(value)) return value.flatMap(classNames)
  if (value !== null && typeof value === "object")
    return Object.entries(value).flatMap(([name, on]) => (on ? classNames(name) : []))
  return []
}
 
// what :html.allowed accepts, normalized to an AllowUrl predicate: host
// patterns (a comma-separated string or an array - see allowedHosts in
// ./dom) or a function (url: URL, tag, attr) => boolean. Anything else -
// including a policy expression that evaluates to undefined - denies every
// destination: the attribute declares the intent to restrict, so a broken
// policy fails closed, and so does a predicate that throws
const normalizeAllowUrl = (policy: any): AllowUrl => {
  if (typeof policy === "function") {
    return (url, tag, attr) => {
      try {
        return !!policy(url, tag, attr)
      } catch {
        return false
      }
    }
  }
  if (typeof policy === "string" || Array.isArray(policy)) return allowedHosts(policy)
  return () => false
}
 
// renders a single element node: static attrs, @event listeners, a reactive
// :attrs object, and its content - :text/:html override the element's own
// children with a reactive textContent/innerHTML, otherwise children render
// normally. :if/:elseif/:else/:each are handled by renderNodes, which decides
// *whether*/*how many times* a node is rendered before calling this. Tags
// matching a PascalCase scope variable render as nested components instead
const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
  // :with applies to the element's own bindings (@events, :attrs) and its
  // whole subtree. On a :each element the item scope is already in place, so
  // :with="item" works
  const withExpr = node.attrs[":with"]
  const scope = withExpr !== undefined ? createWithScope(withExpr, outerScope) : outerScope
 
  const componentKey = findComponentKey(scope, node.tag)
  if (componentKey) return renderNestedComponent(componentKey, node, scope, fx, shadow)
 
  const el = document.createElement(node.tag)
 
  // a tag that isn't standard HTML but has no matching scope key *yet* may be
  // a component that arrives later (e.g. an async factory script exposing an
  // imported component after `await`). Watch for the key: the effect tracks
  // no deps, so it only re-runs on the store's new-key sweep, and swaps the
  // placeholder element for the component exactly once
  if (el instanceof HTMLUnknownElement || node.tag.includes("-")) {
    let upgraded = false
    fx.effect(() => {
      if (upgraded) return
      const key = findComponentKey(scope, node.tag)
      if (!key) return
      upgraded = true
      const replacement = renderNestedComponent(key, node, scope, fx, shadow)
      // whoever tears this subtree down holds `el`, which the swap detaches -
      // so the component's anchors must remove themselves when the scope goes
      const range = boundsOf(replacement)
      fx.onDispose(() => removeRange(range))
      el.replaceWith(replacement)
    })
  }
 
  Object.entries(node.attrs).forEach(([key, value]) => {
    if (key.startsWith("@")) bindEvent(el, key, value, scope)
    else if (key === ":model" || key.startsWith(":model.")) {
      // :model binds component tags only (see TODOS/2026-07-15.model-directive.md;
      // the native-element form is parked there). Warn on a real element, but
      // not on a tag that may still upgrade into a component - the upgrade
      // re-renders through renderNestedComponent, models and all
      if (!(el instanceof HTMLUnknownElement || node.tag.includes("-"))) {
        console.warn(`jq79: ${key} on <${node.tag}> does nothing - :model binds component tags only (for now)`)
      }
    } else if (!CONTROL_ATTRS.has(key)) el.setAttribute(key, value)
  })
 
  const bindExpr = node.attrs[":attrs"]
  if (bindExpr !== undefined) {
    let boundKeys: string[] = []
 
    fx.effect(() => {
      boundKeys.forEach(key => el.removeAttribute(key))
      const bound = evalExpr(bindExpr, scope)
      boundKeys = bound && typeof bound === "object" ? Object.keys(bound) : []
      boundKeys.forEach(key => {
        const value = bound[key]
        if (value != null && value !== false) el.setAttribute(key, String(value))
      })
    })
  }
 
  // :class="expr" adds classes on top of the static `class` attribute. Only
  // classes this binding added are ever removed: the static list survives
  // every re-run, even when the expression names one of its classes and then
  // drops it (class="btn" :class="{ btn: cond }" keeps btn on false)
  const classExpr = node.attrs[":class"]
  if (classExpr !== undefined) {
    const staticClasses = new Set(classNames(node.attrs.class ?? ""))
    let bound: string[] = []
 
    fx.effect(() => {
      const next = classNames(evalExpr(classExpr, scope))
      bound.forEach(name => {
        if (!next.includes(name) && !staticClasses.has(name)) el.classList.remove(name)
      })
      el.classList.add(...next)
      bound = next
    })
  }
 
  // :text="expr" sets textContent reactively, replacing any children.
  // :html="expr" sets innerHTML reactively, sanitizing the value first so
  // untrusted content can't inject scripts/attributes (see sanitizeHTML in
  // ./dom). Both skip rendering the element's own children/interpolation.
  // :html.allowed="expr" adds a destination policy for the content's
  // href/src URLs - evaluated in the same effect, so a policy held in the
  // store is as reactive as the content itself
  const textExpr = node.attrs[":text"]
  const htmlExpr = node.attrs[":html"]
  const allowedExpr = node.attrs[":html.allowed"]
  if (allowedExpr !== undefined && htmlExpr === undefined) {
    console.warn("jq79: :html.allowed without :html on the same element does nothing")
  }
  if (textExpr !== undefined) {
    fx.effect(() => { el.textContent = String(evalExpr(textExpr, scope) ?? "") })
  } else if (htmlExpr !== undefined) {
    fx.effect(() => {
      const options = allowedExpr !== undefined ? { allowUrl: normalizeAllowUrl(evalExpr(allowedExpr, scope)) } : undefined
      el.innerHTML = sanitizeHTML(String(evalExpr(htmlExpr, scope) ?? ""), options)
    })
  } else {
    el.appendChild(renderNodes(node.children, scope, fx, shadow))
  }
 
  // :value / :checked / :selected write the DOM *property*, not the
  // attribute - the attribute is only a form control's default, and detaches
  // the moment the user interacts (which is why :attrs="{ value }" stops
  // driving a typed-in input). One-way, store -> DOM: the way back stays an
  // explicit @input/@change. :value skips the write when the property
  // already holds the string, so an unrelated re-run can't move the caret of
  // the input the user is typing into. Registered after the children render:
  // :value on a <select> can only pick an <option> that already exists
  const valueExpr = node.attrs[":value"]
  if (valueExpr !== undefined) {
    fx.effect(() => {
      const value = String(evalExpr(valueExpr, scope) ?? "")
      if ((el as HTMLInputElement).value !== value) (el as HTMLInputElement).value = value
    })
  }
  ;([":checked", ":selected"] as const).forEach(attr => {
    const expr = node.attrs[attr]
    if (expr === undefined) return
    const prop = attr.slice(1) as "checked" | "selected"
    fx.effect(() => { (el as any)[prop] = !!evalExpr(expr, scope) })
  })
 
  return el
}
 
// a :if/:elseif*/:else? chain sharing one anchor comment so the active branch
// can be swapped in place without disturbing sibling positions. Only depends
// on whatever the branch expressions read (e.g. "score"), and skips
// rebuilding entirely when the active branch hasn't actually changed
const renderConditional = (branches: ConditionalBranch[], scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
  const anchor = document.createComment("if")
  const wrapper = document.createDocumentFragment()
  wrapper.appendChild(anchor)
 
  let current: NodeRange | null = null
  let activeBranch: ConditionalBranch | null = null
  let branchFx: EffectScope | null = null
 
  fx.effect(() => {
    const next = branches.find(branch => branch.expr === undefined || evalExpr(branch.expr, scope)) ?? null
    if (next === activeBranch) return
 
    branchFx?.dispose()
    if (current) removeRange(current)
    current = null
    activeBranch = next
    if (!next) return
 
    branchFx = createEffectScope(scope)
    // bounds captured before inserting: a component branch is a fragment, and
    // inserting it is what empties it (see boundsOf)
    const rendered = renderNode(next.node, scope, branchFx, shadow)
    current = boundsOf(rendered)
    anchor.parentNode!.insertBefore(rendered, anchor.nextSibling)
  })
 
  return wrapper
}
 
// defines a loop-local binding directly as `scope`'s own property. Plain
// assignment (scope[key] = value) would only do this if the key isn't
// already own on `scope` *or anywhere up its prototype chain* - if it isn't,
// JS delegates the [[Set]] to whatever's up there, which for us is another
// reactive proxy's `set` trap: it would wrap `value` as if it were a genuine
// store mutation and fire a bogus notify() under a name (e.g. "item") shared
// by every unrelated item in every :each on the page. defineProperty always
// writes to `scope` itself, never delegating, so this can't happen
const defineScopeVar = (scope: Record<string, any>, key: string, value: any) => {
  Object.defineProperty(scope, key, { value, writable: true, enumerable: true, configurable: true })
}
 
type EachEntry = { key: any; item: any; scope: Record<string, any>; range: NodeRange; fx: EffectScope }
 
// what :each iterates besides arrays: dictionaries, as their entries. Class
// instances, Maps and the rest stay out - the store doesn't wrap them
// (isPlainData), so their contents wouldn't be tracked and the list would go
// silently stale
const isPlainObject = (value: any): value is Record<string, any> => {
  if (value === null || typeof value !== "object" || Array.isArray(value)) return false
  const proto = Object.getPrototypeOf(value)
  return proto === Object.prototype || proto === null
}
 
// :each="item in items" (or "item, i in items" / "(value, key) in props"),
// optionally keyed with :key="expr". Only depends on what the list expression
// reads, and on each run diffs by key: unchanged items (same key, same item
// reference) keep their DOM/effects, changed/added ones are (re)rendered,
// removed ones are disposed. Without :key, an array uses position - fine for
// append-only lists, wasteful for reordering - and an object uses the
// property key, which is already the stable identity. Each item gets its own
// scope via Object.create(scope), so the bindings and `$index` shadow
// same-named outer names without copying the parent scope's keys
const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
  const match = node.attrs[":each"].match(EACH_PATTERN)
  if (!match) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
 
  const [, itemName, atName, listExpr] = match
  const keyExpr = node.attrs[":key"]
  const { [":each"]: _each, [":key"]: _key, ...itemAttrs } = node.attrs
  const itemNode: TemplateNode = { ...node, attrs: itemAttrs }
 
  const anchor = document.createComment("each")
  const wrapper = document.createDocumentFragment()
  wrapper.appendChild(anchor)
 
  // :if on the same element is not per-item filtering, and rendering
  // everything in silence reads like a broken filter - say it out loud
  if (":if" in node.attrs || ":elseif" in node.attrs || ":else" in node.attrs) {
    console.warn("jq79: :if/:elseif/:else on a :each element is ignored; filter the list expression instead")
  }
 
  let entries: EachEntry[] = []
  let warnedDuplicates = false
 
  fx.effect(() => {
    const list = evalExpr(listExpr, scope)
    // both sources normalize to [at, item] pairs: the index for an array, the
    // property key for a plain object (insertion order). Object entries are
    // read off the store proxy, so each value is tracked under its own key -
    // adds, deletes and changes all wake this effect
    const pairs: [any, any][] = Array.isArray(list)
      ? list.map((item, index): [any, any] => [index, item])
      : isPlainObject(list) ? Object.entries(list) : []
    // buckets rather than a key->entry map: duplicate keys (a user error, but
    // one that must degrade instead of corrupt) consume entries in order of
    // appearance, so no entry is ever matched twice - matching one twice is
    // how a reused row got disposed and a removed one resurrected
    const previous = new Map<any, EachEntry[]>()
    entries.forEach(entry => {
      const bucket = previous.get(entry.key)
      if (bucket) bucket.push(entry)
      else previous.set(entry.key, [entry])
    })
 
    const seen = new Set<any>()
    const moved: EachEntry[] = []
    const nextEntries = pairs.map(([at, item], index): EachEntry => {
      const itemScope = Object.create(scope)
      defineScopeVar(itemScope, itemName, item)
      if (atName) defineScopeVar(itemScope, atName, at)
      defineScopeVar(itemScope, "$index", index)
      const key = keyExpr !== undefined ? evalExpr(keyExpr, itemScope) : at
      if (seen.has(key) && !warnedDuplicates) {
        warnedDuplicates = true
        console.warn(`jq79: duplicate :key in :each "${node.attrs[":each"]}"; duplicates pair up by position`)
      }
      seen.add(key)
      const existing = previous.get(key)?.shift()
 
      if (existing && Object.is(existing.item, item)) {
        if (existing.scope.$index !== index) moved.push(existing)
        defineScopeVar(existing.scope, "$index", index)
        if (atName) defineScopeVar(existing.scope, atName, at)
        return existing
      }
 
      if (existing) {
        existing.fx.dispose()
        removeRange(existing.range)
      }
 
      const itemFx = createEffectScope(scope)
      // bounds captured before the positioning pass inserts the entry: a
      // component entry is a fragment, which empties on insertion (see boundsOf)
      const range = boundsOf(renderNode(itemNode, itemScope, itemFx, shadow))
      return { key, item, scope: itemScope, fx: itemFx, range }
    })
 
    // whatever no new item consumed is gone
    previous.forEach(bucket => bucket.forEach(entry => {
      entry.fx.dispose()
      removeRange(entry.range)
    }))
 
    let prevNode: Node = anchor
    nextEntries.forEach(entry => {
      if (prevNode.nextSibling !== entry.range.first) moveRangeAfter(entry.range, prevNode)
      prevNode = entry.range.last
    })
 
    // reused entries that changed position: their tracked bindings re-run off
    // the list notification anyway, but a binding that reads only `$index` or
    // the named key tracked nothing - refresh them so the move reaches those
    // too. Untracked, so these runs don't feed this list effect's own deps
    moved.forEach(entry => untracked(() => entry.fx.refresh()))
 
    entries = nextEntries
  })
 
  return wrapper
}
 
// renders a list of sibling template nodes (text + elements), grouping
// consecutive :if/:elseif/:else nodes into a single conditional block
const renderNodes = (nodes: (TemplateNode | string)[], scope: Record<string, any>, fx: EffectScope, shadow = false): DocumentFragment => {
  const fragment = document.createDocumentFragment()
  let i = 0
 
  while (i < nodes.length) {
    const node = nodes[i]
 
    if (typeof node === "string") {
      const textNode = document.createTextNode(node)
      // static text is most of a template (all of its indentation, for a start):
      // only text with a {{ expression }} in it needs an effect to stay in sync
      if (node.includes("{{")) fx.effect(() => { textNode.textContent = interpolate(node, scope) })
      fragment.appendChild(textNode)
      i++
      continue
    }
 
    if (":each" in node.attrs) {
      fragment.appendChild(renderEach(node, scope, fx, shadow))
      i++
      continue
    }
 
    if (":if" in node.attrs) {
      const branches: ConditionalBranch[] = [{ expr: node.attrs[":if"], node }]
      i++
 
      // the branches of a chain are siblings in the AST, but the template writes
      // them on their own lines - so the whitespace between them is indentation
      // and nothing else, and it's dropped rather than rendered: only one branch
      // is ever in the DOM, so there is nothing for it to be a space *between*
      const nextBranch = (attr: string): TemplateNode | undefined => {
        let next = i
        while (next < nodes.length && typeof nodes[next] === "string" && !(nodes[next] as string).trim()) next++
        const candidate = nodes[next]
        if (typeof candidate === "object" && attr in candidate.attrs) {
          i = next + 1
          return candidate
        }
        return undefined
      }
 
      for (let elseif = nextBranch(":elseif"); elseif; elseif = nextBranch(":elseif")) {
        branches.push({ expr: elseif.attrs[":elseif"], node: elseif })
      }
      const elseNode = nextBranch(":else")
      if (elseNode) branches.push({ node: elseNode })
 
      fragment.appendChild(renderConditional(branches, scope, fx, shadow))
      continue
    }
 
    fragment.appendChild(renderNode(node, scope, fx, shadow))
    i++
  }
 
  return fragment
}
 
export const renderComponent = (component: Component79, data: ReactiveDeepData<Record<string, any>>, shadow = false): Node =>
  renderNodes(component.template, data, createEffectScope(data), shadow)
 
type ComponentParts = {
  template: TemplateNode[]
  scripts: TagBlock[]
  styles: TagBlock[]
  // pre-resolved modules for `import(...)` calls in setup scripts, keyed by
  // the literal specifier. Bundlers (the jq79/vite plugin) fill this so
  // imports resolve from the bundle instead of being fetched at runtime
  modules?: Record<string, any>
  // where this component came from (a URL for fetch(), a path for the vite
  // plugin). Names the setup scripts in devtools - see scriptSourceUrl
  filename?: string
}
 
const VOID_ELEMENTS = new Set([
  "area", "base", "br", "col", "embed", "hr", "img", "input",
  "link", "meta", "param", "source", "track", "wbr",
])
 
// a self-closing tag with its attributes; quoted attribute values are matched
// as whole chunks so a "/>" inside one doesn't end the tag early
const SELF_CLOSING_RE = /<([A-Za-z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)\/>/g
const RAW_BLOCK_RE = /(<script[\s\S]*?<\/script\s*>|<style[\s\S]*?<\/style\s*>)/gi
 
// expands self-closing tags (<MyComponent />, <div />) into explicit
// open+close pairs BEFORE DOM parsing. The HTML parser ignores the slash and
// would treat them as unclosed, swallowing the following siblings. Void
// elements keep their native behavior, and <script>/<style> contents are
// passed through untouched so code inside them is never rewritten
const expandSelfClosingTags = (src: string): string =>
  src
    .split(RAW_BLOCK_RE)
    .map((chunk, i) =>
      i % 2 === 1 // odd chunks are the captured script/style blocks
        ? chunk
        : chunk.replace(SELF_CLOSING_RE, (match, tag: string, attrs: string) =>
            VOID_ELEMENTS.has(tag.toLowerCase()) ? match : `<${tag}${attrs}></${tag}>`
          )
    )
    .join("")
 
// <style scoped> support. Every element of the component's own template is
// stamped with data-jq79="<hash>" and the style's selectors are rewritten to
// require that attribute, so its rules can't reach anything the component
// didn't render. Purely a runtime transform (the browser parses the CSS), so
// it works the same for a bundled component and one loaded with fetch()
const SCOPE_ATTR = "data-jq79"
 
// FNV-1a over the source: stable per definition (not per instance), so N
// instances of the same component share one refcounted <style> in the head
const scopeHash = (src: string): string => {
  let hash = 2166136261
  for (let i = 0; i < src.length; i++) hash = Math.imul(hash ^ src.charCodeAt(i), 16777619)
  return (hash >>> 0).toString(36)
}
 
const stampScope = (nodes: (TemplateNode | string)[], scope: string) => {
  nodes.forEach(node => {
    if (typeof node === "string") return
    node.attrs[SCOPE_ATTR] = scope
    stampScope(node.children, scope)
  })
}
 
// the scope attribute goes on the selector's last compound - the element the
// rule actually targets - but *before* a pseudo-element, which must stay last
// (".a::before" scopes to ".a[data-jq79='x']::before", not "::before[...]")
const scopeSelector = (selectorText: string, scope: string): string =>
  selectorText
    .split(",")
    .map(part => {
      const selector = part.trim()
      const pseudoAt = selector.indexOf("::")
      const target = pseudoAt === -1 ? selector : selector.slice(0, pseudoAt)
      const pseudoElement = pseudoAt === -1 ? "" : selector.slice(pseudoAt)
      return `${target}[${SCOPE_ATTR}="${scope}"]${pseudoElement}`
    })
    .join(", ")
 
// CSSStyleRule is scoped in place; CSSGroupingRule (@media, @supports,
// @container) is recursed into; everything else - notably @keyframes, whose
// "selectors" are percentages - is left alone
const scopeRules = (rules: CSSRuleList, scope: string) => {
  Array.from(rules).forEach(rule => {
    if (rule instanceof CSSStyleRule) rule.selectorText = scopeSelector(rule.selectorText, scope)
    else if (rule instanceof CSSGroupingRule) scopeRules(rule.cssRules, scope)
  })
}
 
// the CSS parser is the browser's own (no dependency, no hand-rolled parser).
// Note browsers *silently drop* rules whose selector they can't parse, which
// is what Vue's :deep()/::v-deep/>>> escape hatches are - unsupported here,
// and warned about rather than left to vanish
const scopeCss = (css: string, scope: string): string => {
  if (/:deep\(|::v-deep|>>>/.test(css)) {
    console.warn("jq79: :deep()/::v-deep/>>> are not supported in <style scoped>; the rule will be dropped by the browser")
  }
  const sheet = new CSSStyleSheet()
  sheet.replaceSync(css)
  scopeRules(sheet.cssRules, scope)
  return Array.from(sheet.cssRules).map(rule => rule.cssText).join("\n")
}
 
// converts a string of HTML into an AST representation of the component:
// - template: the non-script/style top-level elements, as TemplateNodes
// - scripts/styles: { attrs, content } blocks in source order
const parseComponentString = (component: string): ComponentParts => {
  // example
  // <script :setup="{ fname, lname }">
  //   const fullName = `${fname} ${lname}`
  // </script>
  //
  // <div :attrs="{ fullName }"></div>
  // <div class="full-name">
  //  {{ fullName }}
  // </div>
  //
  // <style>
  // .full-name {
  //  color: red;
  // }
  // </style>
 
  // parsed as the content of a <template> so leading <script>/<style> tags
  // aren't reparented into <head> by the HTML parser
  const parsedDOM = new DOMParser().parseFromString(`<template>${expandSelfClosingTags(component)}</template>`, "text/html")
  const root = parsedDOM.querySelector("template") as HTMLTemplateElement
 
  const scripts: TagBlock[] = []
  const styles: TagBlock[] = []
  const template: TemplateNode[] = []
 
  Array.from(root.content.children).forEach(el => {
    const block: TagBlock = { attrs: elementAttrs(el), content: el.textContent ?? "" }
 
    if (el.tagName === "SCRIPT") scripts.push(block)
    else if (el.tagName === "STYLE") styles.push(block)
    else template.push(elementToAST(el))
  })
 
  // <style lang="scss"> is compiled by the jq79/vite plugin, so a `lang` still
  // here means this component never went through it - it was fetched, loaded
  // from a URL, or built from an inline string. The browser would drop the
  // uncompiled source without a word, so say it out loud instead
  styles.forEach(style => {
    if ("lang" in style.attrs) {
      console.warn(
        `jq79: <style lang="${style.attrs.lang}"> needs the jq79/vite plugin to compile it. ` +
        "This component didn't go through the bundler, so its styles were left uncompiled and the browser will ignore them."
      )
    }
  })
 
  // scoping is resolved once, here: the stamped template and the scoped CSS
  // are what every instance of this definition renders and injects. An
  // uncompiled `lang` block is left as it was written - rewriting selectors
  // in something that isn't CSS yet would only garble what devtools shows
  const isScoped = (style: TagBlock) => "scoped" in style.attrs && !("lang" in style.attrs)
  if (styles.some(isScoped)) {
    const scope = scopeHash(component)
    stampScope(template, scope)
    styles.forEach(style => {
      Eif (isScoped(style)) style.scoped = scopeCss(style.content, scope)
    })
  }
 
  return { template, scripts, styles }
}
 
// loads .html URLs as components, delegating anything else to native import()
const importResource = (url: string): Promise<any> =>
  /\.html?([?#]|$)/.test(url) ? Component79.fetch(url) : import(url)
 
// ---------------------------------------------------------------------------
// naming scripts for devtools
//
// setup scripts are compiled with new Function (they need `with`, which is a
// SyntaxError in a module), so no bundler source map can reach them: they show
// up as an anonymous "VM1234" script, breakpoints don't survive a reload, and
// stack traces name nothing. A //# sourceURL comment fixes all three - the
// compiled script takes the component's name, so it is findable in the sources
// tree, keeps its breakpoints, and appears by name in stack traces.
//
// The line numbers it reports are the compiled script's own, not the .html
// file's: the engine wraps a Function body in a header ("function anonymous(
// args\n) {\n") that shifts everything down, and no amount of padding can
// shift code *up* to match a <script> sitting on line 1. Reporting the
// component's real lines would need a source map, which the runtime doesn't
// emit today
// ---------------------------------------------------------------------------
 
// where a script block came from: the component's filename, and its index
// among the component's scripts (two scripts in one file need distinct names,
// or devtools shows only one of them)
type ScriptLocation = { filename?: string; index?: number }
 
// nothing to name an inline component's scripts after, so they stay anonymous
const sourceUrlComment = (filename: string | undefined, index: number): string =>
  filename ? `\n//# sourceURL=${filename}?jq79-script=${index}` : ""
 
// what a <style> block injects into document.head: the scoped rewrite when it
// has one, the source otherwise. A shadow root uses `content` directly instead
// - scoping is what a shadow root already does, and doing both would break the
// `:host` rules only shadow rendering can have (`:host[data-jq79=...]` matches
// nothing: the host element is outside the template, so it carries no stamp)
const headStyle = (style: TagBlock): string => style.scoped ?? style.content
 
// document.head styles are shared by content and refcounted, so N instances
// of the same component (e.g. one per :each item) inject a single <style> tag
// that goes away when the last instance is destroyed
const styleRegistry = new Map<string, { el: HTMLStyleElement; count: number }>()
 
const acquireStyle = (content: string) => {
  let entry = styleRegistry.get(content)
  if (!entry) {
    const el = document.createElement("style")
    el.textContent = content
    document.head.appendChild(el)
    entry = { el, count: 0 }
    styleRegistry.set(content, entry)
  }
  entry.count++
}
 
const releaseStyle = (content: string) => {
  const entry = styleRegistry.get(content)
  if (entry && --entry.count <= 0) {
    entry.el.remove()
    styleRegistry.delete(content)
  }
}
 
// library helpers injected into setup scripts. They behave like extra
// globals: a same-named scope property (render data or a top-level
// declaration) shadows them
const SETUP_HELPERS: Record<string, any> = { $, $$, $create, $reactive }
 
// scripts run inside `with (scriptScope)`, where scriptScope's `has` trap
// claims ownership of every name that is neither a real global, an injected
// library helper, nor one of the internal helpers. This makes `with` route ALL
// other reads/writes through the reactive store - even bare assignments to
// names never declared with let/const, which would otherwise leak onto
// globalThis - while `console`, `Promise`, `fetch`, etc. still resolve
// normally. get/set are deliberately not trapped: they default-forward to
// `scope` (the reactive proxy), preserving tracking and notify.
// The body is wrapped in an async IIFE so top-level `await` works: everything
// up to the first await runs synchronously (before the template renders), and
// later assignments update the DOM reactively when they happen
const runSetupScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource, at: ScriptLocation = {}) => {
  // instanceHelpers are per-component-instance additions (e.g. $emit, which
  // is bound to this instance's DOM position)
  const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
  const scriptScope = new Proxy(scope, {
    has: (target, key) =>
      key !== "$__effect" && key !== "$__import" &&
      (Reflect.has(target, key) || !(key in globalThis) && !(key in helpers)),
  })
  const result: Promise<void> = new Function(
    "$scope", "$__effect", "$__import", ...Object.keys(helpers),
    `return (async () => { with ($scope) { ${code} } })()${sourceUrlComment(at.filename, at.index ?? 0)}`
  )(scriptScope, effect, importer, ...Object.values(helpers))
  result.catch(error => console.error("jq79: error in :setup script", error))
}
 
// puts a component's declared props on the store, before any script runs and
// before the first render: the names, so the template can bind to them even
// when the parent passes nothing, and the defaults, so it binds to something.
//
// A prop the parent *did* pass is already on the store (render() seeds it), so
// a default only fills an `undefined` - which is also what JS destructuring
// does with the same pattern, so both modes agree. It happens once, at setup:
// re-applying a default later would need an effect that reads and writes the
// same key, and that effect would wake itself forever.
//
// `null` props means the component declared no signature at all, which is not
// the same as declaring an empty one: it keeps today's permissive behavior
const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
  props?.forEach(({ name, default: expr }) => {
    if (store[name] !== undefined) return
    store[name] = expr === undefined ? undefined : evalExpr(expr, store)
  })
}
 
// default-import interop for factory scripts: real modules expose .default,
// while importing an .html component resolves to the Component79 itself
const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.default : mod)
 
// runs a factory script: the (rewritten) module body executes in plain
// lexical strict-mode scope - no `with`, no implicit reactivity - with the
// library helpers as parameters, then the default export is called with the
// instance context and a returned object is merged into the store. A fully
// synchronous body invokes the factory before the first render, matching
// setup-script timing; bodies with top-level await (static imports included)
// resolve later and the template updates reactively
const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource, at: ScriptLocation = {}) => {
  const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
  const $__exports: { default?: (props: Record<string, any>, ctx: Record<string, any>) => any; done?: boolean } = {}
  const result: Promise<void> = new Function(
    "$__exports", "$__default", "$__import", ...Object.keys(helpers),
    `return (async () => { "use strict";\n${code}\n;$__exports.done = true })()${sourceUrlComment(at.filename, at.index ?? 0)}`
  )($__exports, interopDefault, importer, ...Object.values(helpers))
 
  const logError = (error: any) => console.error("jq79: error in factory script", error)
  let invoked = false
  const invoke = () => {
    if (invoked) return
    invoked = true
    const factory = $__exports.default
    if (typeof factory !== "function") return
    const merge = (bindings: any) => {
      if (bindings && typeof bindings === "object") Object.assign(scope, bindings)
    }
    // the sync path is invoked straight from render(), so a throwing factory
    // must be caught here too - not just by the `result` rejection handler
    try {
      // props first, ctx second. Both are the store: the pattern destructures
      // the props it declared (copying, as destructuring does - $props is the
      // live view for a primitive the parent reassigns later)
      const returned = factory(scope, { $data: scope, $props: scope, $effect: effect, ...instanceHelpers })
      if (returned instanceof Promise) returned.then(merge).catch(logError)
      else merge(returned)
    } catch (error) {
      logError(error)
    }
  }
 
  result.then(invoke, logError)
  if ($__exports.done) invoke() // fully-sync body: factory runs before first render
}
 
// ---------------------------------------------------------------------------
// hot reload
//
// Both delivery paths want the same thing when a .html file changes: reparse
// it, and re-render every live instance of it in place, keeping its data. The
// swap lives in the runtime (hotReplace, below) so jq79/dev and the Vite
// plugin share one implementation instead of two - and so it can reach the
// private fields it needs (the markers, the holding fragment) rather than
// poking at them from outside, which is what the plugin used to do.
//
// Finding the instances is the part only the runtime can do: a component
// fetched at runtime is reachable from nothing but the DOM it rendered. So
// instances register themselves - but only once a page opts in, before the
// runtime loads. Nothing here costs a bundled app anything: with the registry
// off, an instance is not tracked at all.
// ---------------------------------------------------------------------------
 
const HOT_FLAG = "__JQ79_HMR_ENABLED__"
const HOT_RUNTIME = "__JQ79_HMR__"
 
// live instances by filename. WeakRef because a destroyed component that the
// page has dropped must stay collectable: `:each` churns through clones
let hotRegistry: Map<string, Set<WeakRef<Component79>>> | null = null
 
const hotRegister = (instance: Component79) => {
  if (!hotRegistry || !instance.filename) return
  let refs = hotRegistry.get(instance.filename)
  if (!refs) hotRegistry.set(instance.filename, (refs = new Set()))
  refs.add(new WeakRef(instance))
}
 
// the same file reaches the runtime under different names - "./card.html" from
// an import() in a setup script, "/cards/card.html" from a fetch, "cards/card.
// html" from the dev server that watched it - and they all have to land on one
// key. Resolving against the page is what settles them
const hotKey = (filename: string): string => {
  try {
    return new URL(filename, document.baseURI).pathname
  } catch {
    return filename
  }
}
 
// swaps the file's new source into every instance that came from `filename`,
// and returns how many of them were *on the page* and so re-rendered. Zero
// means the change is not visible anywhere - the file is a page rather than a
// component, or nothing has mounted it yet - and the caller (a dev server)
// should fall back to reloading. Definitions and instances that have been
// destroyed but not yet collected are patched all the same; they just don't
// count, because nothing on screen changed for them
export const hotUpdate = (filename: string, src: string): number => {
  Iif (!hotRegistry) return 0
 
  const key = hotKey(filename)
  // parsed once and shared by every instance - which is already what a
  // definition and the clones :component makes from it do
  const parts = parseComponentString(src)
 
  let rerendered = 0
  for (const [name, refs] of hotRegistry) {
    if (hotKey(name) !== key) continue
    for (const ref of refs) {
      const instance = ref.deref()
      Iif (!instance) {
        refs.delete(ref) // collected since the last update
        continue
      }
      if (instance.hotReplace(parts)) rerendered++
    }
    Iif (!refs.size) hotRegistry.delete(name)
  }
  return rerendered
}
 
// starts tracking instances, so hotUpdate can find them. jq79/dev's client
// calls this through the global handshake at the foot of this file; it is
// exported so a bundled app - or a test - can opt in directly
export const enableHotReload = (): void => {
  hotRegistry ??= new Map()
  ;(globalThis as any)[HOT_RUNTIME] = { update: hotUpdate }
}
 
type EmitListener = (event: CustomEvent, payload: any) => void
 
// a parsed single-file component. Typical lifecycle:
//
//   const jq79 = new Component79(src)   // or await Component79.fetch(url)
//   jq79.on("submit", (e, payload) => {}) // hear this instance's $emit events
//   jq79.mount("#app", { user })        // render (reactive DOM, scripts, styles) + attach
//   ...                                 // (mountShadow mounts into a shadow root)
//   jq79.detach()                       // detach, keeping state - mount() re-attaches
//      .destroy()                      // dispose effects and remove styles
export class Component79 {
  template: TemplateNode[]
  scripts: TagBlock[]
  styles: TagBlock[]
  // pre-resolved modules for setup-script `import(...)` calls (see
  // ComponentParts.modules); checked before falling back to fetch/import
  modules?: Record<string, any>
  // the component's origin, used to name its scripts in devtools
  filename?: string
 
  data: ReactiveDeepData<Record<string, any>> | null = null
 
  private fx: EffectScope | null = null
  // holds the rendered nodes while detached; anchors keep this fragment as
  // their parentNode, so effects keep the (detached) DOM up to date and a
  // later mount() shows current state
  private content: DocumentFragment | null = null
  // markers bracketing the component's output so detach() can collect nodes
  // that :if/:each inserted next to the anchors after mounting
  private startMarker: Comment | null = null
  private endMarker: Comment | null = null
  // shadow rendering keeps per-instance <style> elements; head rendering goes
  // through the shared refcounted styleRegistry instead
  private styleEls: HTMLStyleElement[] = []
  private ownsSharedStyles = false
  private useShadow = false
  private mountRoot: Element | ShadowRoot | DocumentFragment | null = null
  // settles the $mounted() promise handed to this render generation's scripts
  private resolveMounted: (() => void) | null = null
  // instance-level listeners for $emit events, registered with on(). Kept
  // outside the render generation so they survive re-render and destroy()
  private emitListeners = new Map<string, Set<EmitListener>>()
 
  constructor(src: string | ComponentParts, options: { modules?: Record<string, any>; filename?: string } = {}) {
    const parts = typeof src === "string" ? parseComponentString(src) : src
    this.template = parts.template
    this.scripts = parts.scripts
    this.styles = parts.styles
    this.modules = options.modules ?? (typeof src === "string" ? undefined : src.modules)
    this.filename = options.filename ?? (typeof src === "string" ? undefined : src.filename)
    hotRegister(this) // a no-op unless the page enabled hot reload
  }
 
  // swaps this component's parsed parts for `src`'s and, if it is on the page,
  // re-renders it where it stands - seeded with a snapshot of its data, so
  // props and store values survive (the setup script runs again, so whatever it
  // initializes is reset). Returns whether it re-rendered: an instance that was
  // never rendered is a *definition*, and patching its parts is all there is to
  // do - the clones :component made from it are instances in their own right,
  // registered under the same filename, and re-render themselves.
  //
  // Dev-only, and not part of the public API: jq79/dev and the Vite plugin call
  // it when a file changes. It re-attaches against the markers rather than
  // mountRoot on purpose - a nested clone is mounted into a fragment that is
  // then emptied into the page, so its mountRoot is a stale, detached fragment
  // while its markers sit where its DOM actually is
  hotReplace(src: string | ComponentParts): boolean {
    const parts = typeof src === "string" ? parseComponentString(src) : src
    const marker = this.startMarker
    const rendered = !!(marker && this.content)
 
    // where its output sits now, if it is on the page. A rendered-but-detached
    // instance (markers in the holding fragment) re-renders detached, and a
    // later mount() attaches the new output - like any update it missed away
    const live = rendered && marker!.isConnected
    const parent = live ? (marker!.parentNode as Element | ShadowRoot | DocumentFragment) : null
    const before = live ? this.endMarker!.nextSibling : null
    const data = { ...this.data }
    const shadow = this.useShadow
 
    // destroy() releases the styles it acquired, so it has to run while
    // this.styles is still the *old* set - swapping the parts first would leak
    // the old stylesheet into the head and release a new one nobody holds
    if (rendered) this.destroy()
 
    this.template = parts.template
    this.scripts = parts.scripts
    this.styles = parts.styles
    if (!rendered) return false // a definition: its clones re-render themselves
 
    this.renderWith(data, shadow)
    Iif (!parent) return false
 
    // shadow styles live inline, right before the DOM they style (attach()
    // appends them ahead of the content), so they go back the same way
    Iif (shadow) this.styleEls.forEach(el => parent.insertBefore(el, before))
    parent.insertBefore(this.content!, before)
    this.mountRoot = parent
    this.resolveMounted?.()
    return true
  }
 
  static async fetch(url: string): Promise<Component79> {
    const response = await fetch(url)
    if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
    // the URL names the component's scripts in devtools, and is where the
    // browser will look for the source when a breakpoint lands in one
    return new Component79(await response.text(), { filename: url })
  }
 
  // subscribes to this instance's $emit events, on top of the DOM CustomEvent
  // dispatch - so it hears emits even while the component is detached (where
  // the event has no ancestors to bubble to). Chainable; can be called before
  // render()
  on(eventName: string, listener: EmitListener): this {
    Eif (!this.emitListeners.has(eventName)) this.emitListeners.set(eventName, new Set())
    this.emitListeners.get(eventName)!.add(listener)
    return this
  }
 
  off(eventName: string, listener: EmitListener): this {
    this.emitListeners.get(eventName)?.delete(listener)
    return this
  }
 
  render(data: Record<string, any> = {}): this {
    return this.renderWith(data, false)
  }
 
  // like render(), but styles are injected into a shadow root attached to the
  // mount target instead of document.head, so they don't leak globally
  renderShadow(data: Record<string, any> = {}): this {
    return this.renderWith(data, true)
  }
 
  private renderWith(data: Record<string, any>, shadow: boolean): this {
    this.destroy()
 
    const store = $reactive({ ...data })
    const fx = createEffectScope(store)
    this.data = store
    this.fx = fx
    this.useShadow = shadow
 
    this.startMarker = document.createComment("jq79")
    this.endMarker = document.createComment("/jq79")
 
    // $emit dispatches a bubbling CustomEvent from this instance's start
    // marker, so once mounted it travels up the real DOM and parents can
    // listen on any ancestor (or with @event-name on a wrapping element).
    // Captures the marker rather than `this` so a later re-render's scripts
    // can't dispatch from the wrong generation - the same guard keeps stale
    // generations from reaching the instance's on() listeners.
    // The on() channel runs *first* (it's where @event on a component tag is
    // wired - see wireTagEvent) so its listeners can shape the DOM dispatch:
    // stopPropagation() there keeps the event off the DOM entirely, and the
    // event is cancelable so preventDefault() - from either channel - flips
    // the return to false, telling the emitting child "the parent vetoed"
    const marker = this.startMarker
    const $emit = (eventName: string, payload?: any): boolean => {
      const event = new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true, cancelable: true })
      if (marker === this.startMarker) {
        this.emitListeners.get(eventName)?.forEach(listener => listener(event, payload))
      }
      // cancelBubble is the spec's legacy name, but it's the only *readable*
      // accessor for the stop-propagation flag - hence the deprecation hint
      if (!event.cancelBubble) marker.dispatchEvent(event)
      return !event.defaultPrevented
    }
 
    // `await $mounted()` suspends a setup script until mount() attaches the
    // component, so code below it can querySelector its own DOM. Resumption
    // is a microtask, so in the usual synchronous render().mount() flow the
    // whole tree (nested components included) is in the document before the
    // script continues. If this instance is never mounted, the promise stays
    // pending and the script's tail never runs
    let resolveMounted!: () => void
    const mounted = new Promise<void>(resolve => { resolveMounted = resolve })
    this.resolveMounted = resolveMounted
    const $mounted = () => mounted
 
    // $self / $$self mirror $ / $$ but only search this instance's own
    // output: the sibling nodes between its markers. They work detached too
    // (the holding fragment keeps markers and rendered nodes as siblings),
    // though the template renders after the scripts run, so they only find
    // something from post-await code or callbacks
    const endMarker = this.endMarker
    const $$self = (selector: string): Element[] => {
      const found: Element[] = []
      for (let node: Node | null = marker.nextSibling; node && node !== endMarker; node = node.nextSibling) {
        Eif (node instanceof Element) {
          if (node.matches(selector)) found.push(node)
          found.push(...Array.from(node.querySelectorAll(selector)))
        }
      }
      return found
    }
    const $self = (selector: string): Element | null => $$self(selector)[0] ?? null
 
    // import() calls whose specifier was pre-resolved by a bundler (the
    // modules map) get the bundled module; everything else falls back to the
    // runtime importResource (fetch for .html, native import otherwise)
    const modules = this.modules
    const $import = (url: string): Promise<any> =>
      modules && url in modules ? Promise.resolve(modules[url]) : importResource(url)
 
    // scripts run before the template renders so `$:` values are initialized;
    // a `:mounted` script defers entirely until mount() instead. A top-level
    // `export default` switches the script to factory mode (plain lexical JS)
    // a `:mounted` script is deferred by prepending the await on the code's own
    // first line, so deferring doesn't shift the lines devtools reports for it
    const defer = (code: string) => `await $mounted();${code}`
 
    this.scripts.forEach((script, index) => {
      const instanceHelpers = { $emit, $mounted, $self, $$self }
      const at: ScriptLocation = { filename: this.filename, index }
      const factoryCode = transformFactoryScript(script.content)
      if (factoryCode !== null) {
        declareProps(store, parseFactoryProps(script.content))
        const body = ":mounted" in script.attrs ? defer(factoryCode) : factoryCode
        runFactoryScript(body, store, fx.effect, instanceHelpers, $import, at)
        return
      }
      const { vars, code } = transformSetupScript(script.content)
      declareProps(store, parsePropsPattern(script.attrs[":setup"]))
      // pre-declare script vars on the store so `with` resolves assignments
      // to them (and reads of them) through the reactive proxy
      vars.forEach(name => { Eif (!(name in store)) (store as any)[name] = undefined })
      const body = ":mounted" in script.attrs ? defer(code) : code
      runSetupScript(body, store, fx.effect, instanceHelpers, $import, at)
    })
 
    const content = document.createDocumentFragment()
    // the template renders under a scope that also answers $emit (unless the
    // store shadows the name), so an inline handler can emit without routing
    // through a setup function: @input="$emit('update', $event.target.value)".
    // has/get only - never an own key - so Object.keys, snapshot spreads and
    // the component-key scan don't see it, and every read still forwards
    // through the reactive store, keeping dependency tracking intact
    const templateScope = new Proxy(store as Record<string, any>, {
      has: (target, key) => key === "$emit" || Reflect.has(target, key),
      get: (target, key, receiver) =>
        key === "$emit" && !Reflect.has(target, key) ? $emit : Reflect.get(target, key, receiver),
    })
    content.append(this.startMarker, renderNodes(this.template, templateScope, fx, shadow), this.endMarker)
    this.content = content
 
    if (shadow) {
      this.styleEls = this.styles.map(style => {
        const el = document.createElement("style")
        el.textContent = style.content // the source: a shadow root scopes it already
        return el
      })
    } else {
      this.styles.forEach(style => acquireStyle(headStyle(style)))
      this.ownsSharedStyles = true
    }
 
    return this
  }
 
  // renders (when needed) and attaches in one call: the component is rendered
  // on the first mount, and re-rendered fresh whenever `data` is passed.
  // mount(el) on an already-rendered component just re-attaches, keeping its
  // state - the detach()/mount() round trip. Rendering here keeps whichever
  // style mode was last used (document.head unless renderShadow/mountShadow
  // chose a shadow root)
  mount(parent: Element | ShadowRoot | DocumentFragment | string, data?: Record<string, any>): this {
    const target = typeof parent === "string" ? $(parent) : parent
    if (!target) throw new Error(`mount target not found: ${parent}`)
    if (!this.content || data !== undefined) this.renderWith(data ?? {}, this.useShadow)
    return this.attach(target)
  }
 
  // like mount(), but renders with styles scoped to a shadow root on the
  // target instead of document.head
  mountShadow(parent: Element | ShadowRoot | DocumentFragment | string, data?: Record<string, any>): this {
    const target = typeof parent === "string" ? $(parent) : parent
    if (!target) throw new Error(`mount target not found: ${parent}`)
    Eif (!this.content || data !== undefined || !this.useShadow) this.renderWith(data ?? {}, true)
    return this.attach(target)
  }
 
  private attach(target: Element | ShadowRoot | DocumentFragment): this {
    if (this.mountRoot) this.detach()
 
    const root = this.useShadow && target instanceof Element
      ? target.shadowRoot ?? target.attachShadow({ mode: "open" })
      : target
    if (this.useShadow) this.styleEls.forEach(el => root.appendChild(el))
    root.appendChild(this.content!)
    this.mountRoot = root
    this.resolveMounted?.()
    return this
  }
 
  // detaches from the DOM while keeping all state; a later mount() re-attaches
  // with any updates that happened while detached already applied
  detach(): this {
    if (!this.mountRoot || !this.content || !this.startMarker || !this.endMarker) return this
 
    // move everything between the markers (inclusive) back into the holding
    // fragment - including nodes :if/:each inserted after mounting
    let node: Node | null = this.startMarker
    while (node) {
      const nextNode: Node | null = node.nextSibling
      this.content.appendChild(node)
      if (node === this.endMarker) break
      node = nextNode
    }
 
    this.mountRoot = null
    return this
  }
 
  destroy(): this {
    this.detach()
    this.fx?.dispose()
    this.fx = null
    // a store this component was handed (a shared `$reactive`) outlives it, and
    // holds a listener per store that nested it - drop this instance's
    this.data?.$dispose()
    this.styleEls.forEach(el => el.parentNode?.removeChild(el))
    this.styleEls = []
    if (this.ownsSharedStyles) {
      this.styles.forEach(style => releaseStyle(headStyle(style)))
      this.ownsSharedStyles = false
    }
    this.content = null
    this.startMarker = null
    this.endMarker = null
    this.data = null
    this.resolveMounted = null
    return this
  }
}
 
export const parseComponent = (component: string): Component79 => new Component79(component)
 
// the hot-reload handshake. jq79/dev serves a classic script that sets the flag
// below; classic scripts run before deferred module ones, so the flag is always
// set before this module evaluates. The page's copy of the runtime can come from
// anywhere - a CDN, an import map, dist/ - and the dev client has no way to
// import *that* copy, so the runtime hands itself to the client instead
if (typeof globalThis !== "undefined" && (globalThis as any)[HOT_FLAG]) enableHotReload()