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 1569 1570 1571 | 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 223x 223x 22x 22x 201x 201x 201x 223x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 31447x 1x 1x 31447x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 223x 277x 277x 277x 277x 277x 277x 239x 109x 109x 109x 109x 239x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 119x 119x 119x 119x 1x 1x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 8x 8x 93x 93x 93x 85x 85x 85x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 8x 8x 91x 91x 91x 91x 91x 91x 91x 91x 91x 98x 8x 8x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 450x 11x 11x 11x 11x 11x 11x 11x 11x 11x 11x 11x 11x 11x 11x 91x 91x 91x 91x 90x 90x 88x 88x 88x 88x 88x 88x 88x 88x 88x 119x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 91x 91x 91x 91x 14x 14x 14x 14x 14x 14x 14x 14x 91x 91x 91x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 189x 189x 189x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 104x 104x 104x 104x 104x 104x 104x 104x 104x 104x 104x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 104x 104x 104x 104x 104x 104x 104x 104x 15x 15x 15x 15x 104x 104x 104x 104x 104x 104x 104x 1x 1x 1x 1x 104x 104x 104x 104x 104x 104x 104x 104x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 103x 103x 103x 103x 103x 103x 103x 103x 103x 103x 103x 103x 103x 103x 103x 10x 10x 103x 277x 277x 277x 277x 277x 277x 277x 277x 10x 10x 10x 7x 7x 7x 7x 7x 7x 7x 10x 1x 1x 1x 1x 1x 1x 1x 3x 1x 1x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 10x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 93x 93x 6335x 6335x 145x 145x 93x 93x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 90x 90x 90x 88x 88x 90x 3x 3x 90x 90x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 93x 93x 78x 78x 15x 15x 15x 15x 25x 17x 17x 17x 17x 17x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 93x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 93x 93x 91x 91x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 4x 4x 4x 4x 2x 2x 2x 2x 2x 2x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 93x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 104x 104x 104x 104x 104x 1x 1x 104x 1x 1x 104x 104x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 196x 196x 196x 196x 196x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 170x 170x 322x 322x 170x 170x 322x 322x 322x 322x 322x 322x 322x 322x 322x 170x 170x 170x 170x 170x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 78x 78x 78x 78x 78x 78x 78x 78x 78x 78x 78x 78x 78x 78x 78x 312x 312x 312x 7x 7x 7x 7x 7x 7x 7x 7x 78x 78x 78x 78x 78x 78x 78x 78x 78x 78x 78x 78x 78x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 91x 91x 91x 91x 91x 91x 91x 458x 458x 91x 1x 1x 1x 90x 90x 91x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 88x 448x 448x 448x 448x 448x 448x 88x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 196x 196x 392x 358x 83x 83x 83x 83x 83x 83x 196x 196x 196x 196x 196x 277x 277x 277x 277x 277x 277x 277x 277x 213x 26x 26x 187x 187x 957x 957x 760x 760x 209x 684x 383x 383x 760x 187x 187x 187x 187x 187x 187x 187x 187x 187x 187x 187x 187x 187x 151x 151x 187x 187x 213x 277x 277x 277x 277x 277x 277x 277x 277x 277x 624x 181x 181x 624x 624x 531x 531x 531x 624x 624x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 196x 196x 196x 196x 83x 83x 8x 8x 8x 8x 8x 75x 75x 75x 188x 188x 188x 188x 188x 188x 73x 73x 73x 73x 73x 73x 188x 188x 188x 188x 189x 73x 72x 72x 73x 73x 188x 190x 1x 1x 1x 1x 1x 187x 187x 187x 187x 187x 134x 134x 61x 134x 61x 134x 3x 3x 187x 187x 190x 531x 531x 133x 2x 2x 2x 2x 2x 2x 2x 2x 131x 131x 529x 185x 185x 185x 185x 185x 185x 189x 61x 61x 61x 61x 61x 30x 30x 30x 30x 30x 30x 61x 30x 30x 61x 185x 185x 188x 10x 10x 185x 185x 196x 277x 277x 277x 277x 277x 277x 277x 277x 277x 113x 26x 37x 93x 93x 93x 33x 33x 33x 33x 33x 33x 90x 38x 38x 38x 38x 38x 33x 43x 38x 38x 38x 38x 38x 80x 80x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 38x 33x 84x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 2x 33x 113x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 114x 228x 143x 23x 20x 20x 20x 20x 20x 20x 20x 20x 20x 114x 114x 114x 114x 114x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 191x 191x 191x 352x 352x 352x 352x 191x 191x 191x 598x 595x 728x 728x 216x 650x 352x 352x 728x 191x 191x 191x 191x 191x 191x 191x 191x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 196x 196x 196x 196x 196x 245x 245x 245x 245x 196x 196x 196x 414x 404x 404x 585x 585x 585x 218x 500x 245x 245x 245x 238x 238x 245x 245x 245x 245x 404x 196x 196x 196x 196x 196x 196x 196x 7x 7x 7x 7x 7x 7x 189x 189x 189x 189x 231x 231x 231x 231x 231x 231x 231x 231x 231x 121x 121x 116x 116x 1x 1x 1x 116x 116x 11x 11x 116x 121x 231x 231x 231x 231x 54x 54x 53x 53x 54x 231x 231x 189x 189x 189x 189x 189x 189x 189x 189x 189x 260x 260x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 231x 231x 231x 231x 260x 260x 53x 53x 231x 231x 231x 231x 231x 189x 189x 189x 231x 207x 207x 189x 189x 189x 231x 231x 231x 231x 231x 231x 115x 115x 189x 196x 277x 277x 277x 277x 277x 277x 277x 277x 277x 179x 179x 179x 179x 179x 179x 179x 179x 179x 179x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 183x 183x 183x 183x 129x 129x 158x 158x 158x 158x 118x 118x 118x 118x 118x 118x 118x 118x 118x 118x 141x 64x 64x 141x 64x 64x 129x 183x 183x 183x 183x 277x 277x 277x 277x 277x 277x 277x 18x 18x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x 277x | import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import { fileURLToPath } from 'url';
import loadConfig from './config.js';
const findProjectRoot = loadConfig.findProjectRoot;
import StyleProcessor from './compiler/StyleProcessor.js';
import ComponentParser from './compiler/ComponentParser.js';
import {
analyzeBridgeFile,
findBridgeImports,
declaredMembers,
extractSubscriptions,
suggestName,
} from './compiler/BridgeParser.js';
import { logger } from './core/runtime/AvenxLogger.js';
import { performance } from 'perf_hooks';
import { AvenxErrorCodes } from './core/runtime/AvenxError.js';
import { BuildError } from './compiler/errors/index.js';
import { reportWarning } from './compiler/utils/warningReporter.js';
import { loadEnv, replaceEnvVariables } from './env.js';
import { assertValidOutputs, assertRuntimeCapabilities } from './compiler/bundle/validate.js';
import { NAMESPACE_GLOBAL, PUBLIC_GLOBALS } from './core/globals.js';
import { buildSidecar, sidecarFileName } from './compiler/sourceMapTrace.js';
import {
bridgeModule,
componentModule,
devtoolsModule,
interpreterModule,
stringRendererModule,
builtinComponentModule,
entryModule,
globalsModule,
rewindConfigModule,
} from './compiler/modules.js';
import { bundle, ResolveError, BindingError, DynamicImportError, EmitError, ModuleParseError } from './bundler/index.js';
import { AppModel } from './compiler/atlas/AppModel.js';
import { addBridgeUnit, addRenderEdges } from './compiler/atlas/build.js';
import { addRoutesAndGuards } from './compiler/atlas/routes.js';
import { atlasFileName, serializeAtlas } from './compiler/atlas/emit.js';
import { reportAtlasDiagnostics } from './compiler/atlas/diagnostics.js';
import { reportRewindDiagnostics } from './compiler/rewind/diagnostics.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Default size above which a build warns about the JavaScript it produced.
*
* Raised from 50 KB when the bundler replaced the concatenator, because 50 KB
* was never satisfiable: the framework runtime alone is several times that, so
* AVX_W01 fired on `avenx init` output and on every build after it. A warning
* that always fires is a warning nobody reads, and it made the repository's own
* bundle-size CI gate permanently red.
*
* The number is a ceiling for the whole bundle -- runtime included -- chosen to
* sit above what an application of a few dozen components costs and to fire
* when something large joins the graph. `bundleSizeWarningKb` in
* avenx.config.json overrides it, because the honest number depends on what the
* application is.
* @type {number}
*/
const BUNDLE_SIZE_WARNING_THRESHOLD_KB = 600;
/**
* Built-in components that are only linked when a template references them.
*
* `AvenxApp` used to import every built-in directly, which put each of them in
* every bundle regardless of use. The registry in
* `lib/core/runtime/builtins.js` is filled by these modules instead, and this
* map is what decides which of them the entry graph reaches.
* @type {Array<string[]>}
*/
const BUILTIN_COMPONENT_MODULES = [['VirtualList', 'avenx-core/runtime/virtual-list']];
/**
* Resolves the build mode from configuration and environment.
*
* Production is the default so that a plain `avenx build` — what a deploy
* script runs — produces optimised output. Development has to be asked for,
* by `avenx build --dev`, by `mode`/`dev` in avenx.config.json, or by
* NODE_ENV.
* @param {object} config - The resolved compiler configuration.
* @returns {'production'|'development'} The active mode.
*/
function resolveMode(config) {
if (config.mode === 'development' || config.mode === 'production') {
return config.mode;
}
if (config.dev === true) {
return 'development';
}
if (process.env.NODE_ENV === 'development') {
return 'development';
}
return 'production';
}
/**
* AvenxCompiler is the main orchestrator for the Avenx-JS build process.
* It coordinates the parsing of components, processing of styles, and the
* final bundling of the application.
*/
class AvenxCompiler {
/**
* Creates an instance of AvenxCompiler and initializes its sub-processors.
* @param {object} [options] - Optional custom settings to override config defaults.
*/
constructor(options = {}) {
/**
* The root directory of the project.
* @type {string}
*/
this.rootDir = options.rootDir || findProjectRoot(process.cwd());
loadEnv(this.rootDir);
// Expose properties prefixed with AVX_PUBLIC_ to the compiler
this.publicEnv = {};
for (const key of Object.keys(process.env)) {
if (key.startsWith('AVX_PUBLIC_')) {
this.publicEnv[key] = process.env[key];
}
}
const config = { ...loadConfig(this.rootDir), ...options };
/**
* The output bundle name without file extension.
* Defaults to "bundle" when outputName is not configured.
* @type {string}
*/
this.outputName = config.outputName || 'bundle';
// Configure logger for build-time compiler
logger.configure({
level: (config.logging && config.logging.level) || 'info',
silent:
(config.logging &&
(config.logging.silent || config.logging.level === 'silent' || config.logging.level === 'off')) ||
false,
// CLI output doesn't need prefixes for generic info logs. The CLI injects its
// own formatter (see bin/colors.js) to tint warnings and errors; other
// consumers such as the Vite plugin keep the plain pass-through default.
formatter: (config.logging && config.logging.formatter) || ((level, args) => args),
});
/**
* The source directory (usually 'src').
* @type {string}
*/
this.srcDir = path.join(this.rootDir, config.srcDir);
/**
* The distribution directory (usually 'dist').
* @type {string}
*/
this.distDir = path.join(this.rootDir, config.distDir);
/**
* The directory containing core runtime files.
* @type {string}
*/
this.coreDir = path.join(__dirname, 'core');
/**
* @type {object}
*/
this.config = config;
/**
* @type {StyleProcessor}
*/
this.styleProcessor = new StyleProcessor(config.style || {}, config);
/**
* @type {ComponentParser}
*/
this.componentParser = new ComponentParser(this.styleProcessor, config.voidTags, config);
/**
* The build mode. Production is the default: `avenx build` is what runs in
* CI and in a deploy step, so the safe default is the optimised output.
* `avenx serve` and `avenx watch` opt into development explicitly.
* @type {'production'|'development'}
*/
this.mode = resolveMode(config);
/**
* True when building optimised output.
* @type {boolean}
*/
this.production = this.mode === 'production';
// The parser decides whether to emit an action's body text alongside its
// compiled form, so it has to know which kind of build this is. A parser
// used on its own -- the Vite plugin, `loadComponent`, a unit test -- leaves
// this undefined and keeps the bodies, which is the safe direction: the
// text is only ever redundant, never required.
this.componentParser.production = this.production;
this.init();
}
/**
* Initializes the compiler environment, ensuring required directories exist.
* @private
*/
init() {
if (!fs.existsSync(this.distDir)) {
try {
fs.mkdirSync(this.distDir, { recursive: true });
} catch {
logger.error(`❌ ${new BuildError(AvenxErrorCodes.COMPILER_DIST_CREATION_FAILED, this.distDir).message}`);
}
}
}
/**
* Executes the full build process.
*
* Every fatal condition throws. Nothing here logs an error and returns as if
* the build had finished: a caller that cannot tell success from failure
* cannot set an exit code, and a pipeline that cannot see the failure
* deploys whatever is already in the output directory.
* @returns {{mode: string, distDir: string, files: string[], durationMs: number}}
* A description of what was written.
* @throws {BuildError} When the application cannot be compiled.
*/
build() {
logger.info(`--- Avenx-JS Compiler (${this.mode}) ---`);
const startTime = performance.now();
if (!fs.existsSync(this.srcDir)) {
throw new BuildError(AvenxErrorCodes.COMPILER_SRC_DIR_MISSING, this.srcDir);
}
this.styleProcessor.reset();
this.componentParser.renderFallbacks = [];
this.componentParser.expressionGaps = [];
this.__bridgeConsumerFiles = null;
this.__bridgeConsumerSources = null;
this.beginModel();
/**
* The modules the compiler generates, keyed by the path they stand for.
*
* A component's module is keyed by the component file's own path, so an
* `import './counter.component.js'` written by a developer resolves to the
* compiled class rather than to the template source, which is not
* JavaScript and would not parse.
* @type {Map<string, string>}
*/
const virtualModules = new Map();
/** @type {Array<{name: string, file: string, kind: string}>} */
const registrations = [];
const bridgeData = this.processBridges(virtualModules, registrations);
this.componentParser.setBridges(bridgeData.bridges);
// Every component and page name, discovered up front so a template tag can
// be validated against the whole project (AVX_W46) as each file is parsed.
this.collectComponentNames();
this.validateBridgeUsage(bridgeData.bridges);
// Guards resolve their bridge imports the way everything else does now:
// through the module graph, from the import the developer wrote.
this.__guardBridges = bridgeData.bridges;
this.processGuards(virtualModules);
this.processComponents(virtualModules);
this.processPages(virtualModules, registrations);
const jsFileName = `${this.outputName}.js`;
const jsMapFileName = `${this.outputName}.js.map`;
const cssFileName = `${this.outputName}.css`;
const traceMapFileName = sidecarFileName(this.outputName);
const atlasMapFileName = atlasFileName(this.outputName);
const shouldEmitJsMap =
this.config.sourceMap === true ||
this.config.sourcemap === true ||
(!this.production && this.config.sourceMap !== false);
const entryId = this.buildEntryModule(virtualModules, registrations);
const result = this.runBundler({
entryId,
virtualModules,
sourceMap: shouldEmitJsMap,
file: jsFileName,
});
let bundleJs = result.code;
if (shouldEmitJsMap) {
bundleJs += `\n//# sourceMappingURL=${jsMapFileName}\n`;
}
const isDevMode =
!this.production ||
(this.config.style &&
(this.config.style.dev === true ||
this.config.style.inlineSourceMap === true ||
this.config.style.sourceMap === 'inline'));
const baseCssContent = this.styleProcessor.getGlobalStyles({
dev: isDevMode,
distDir: this.distDir,
cssFileName,
includeSources: this.includedStylesheets(result.included),
});
const sourceMap = this.styleProcessor.getSourceMap(this.distDir, cssFileName);
const cssWithMapComment = isDevMode ? baseCssContent : baseCssContent + `\n/*# sourceMappingURL=${cssFileName}.map */\n`;
// The trace sidecar maps recorded action and computed names back to a file
// and a line. It sits beside the bundle and is never referenced by it, so
// an application that records no traces downloads nothing extra and a
// deployment that does not want the file simply does not upload it.
const traceSidecar = buildSidecar(this.componentParser.locations, bridgeData.bridges, this.rootDir);
// The Atlas is finished here, after every unit has been parsed, and sits
// beside the bundle on the same terms as the trace sidecar: never
// referenced by it, so it costs a browser nothing.
this.reportRenderFallbacks();
this.reportExpressionGaps();
this.finishModel();
reportAtlasDiagnostics(this.model, this.config);
reportRewindDiagnostics(this.model, this.config);
const atlasJson = serializeAtlas(this.model, {
srcDir: path.relative(this.rootDir, this.srcDir).split(path.sep).join('/') || '.',
});
const files = [jsFileName, cssFileName, `${cssFileName}.map`, traceMapFileName, atlasMapFileName];
if (shouldEmitJsMap) {
files.push(jsMapFileName);
}
const outputs = new Map([
[jsFileName, bundleJs],
[cssFileName, cssWithMapComment],
[`${cssFileName}.map`, JSON.stringify(sourceMap, null, 2)],
[traceMapFileName, JSON.stringify(traceSidecar, null, 2)],
[atlasMapFileName, atlasJson],
]);
if (shouldEmitJsMap) {
outputs.set(jsMapFileName, JSON.stringify(result.map, null, 2));
}
// A build may not report success unless what it produced parses. The check
// runs before anything is written, so a bundle that does not parse never
// reaches distDir and the previous build is left intact.
assertValidOutputs(outputs);
// Nor unless it contains the runtime capabilities it decided to link. The
// build tells the developer their component renders through the string
// renderer; if that renderer is not in the artifact, the honest outcome is
// a failed build rather than an application that mounts and shows nothing.
assertRuntimeCapabilities(outputs, this.linkedRuntimeCapabilities());
// Every artifact is produced before any of them is written, and written to
// a staging directory before any of them is promoted. Writing bundle.js
// first and then failing while producing the CSS used to leave a new script
// beside a stale stylesheet — output that never existed as a whole build.
const staging = this.writeStaging(outputs);
try {
this.reportBundleStats(result.stats);
logger.info('\nAsset sizes:');
files.forEach((file) => {
const filePath = path.join(staging, file);
const bytes = fs.statSync(filePath).size;
const sizeKb = bytes / 1024;
// The transferred size is what a browser actually pays, and it is the
// number that moved least when the bundler replaced esbuild's mangling
// minifier with Avenx's conservative one. Reporting only the raw figure
// would tell a developer the wrong story about their own bundle.
const transferred =
file.endsWith('.js') || file.endsWith('.css')
? ` (${(zlib.gzipSync(fs.readFileSync(filePath)).length / 1024).toFixed(2)} KB gzipped)`
: '';
logger.info(`${file}: ${sizeKb.toFixed(2)} KB${transferred}`);
// The trace sidecar, the Atlas and the source maps are build artifacts,
// not something a browser downloads on a page load, so none of them is
// weighed against the bundle budget.
//
// The maps were weighed, which had two costs. A development build warns
// by default -- bundle.js.map is larger than the bundle and always over
// the threshold -- so AVX_W01 became noise on every `avenx serve`, and a
// warning developers learn to scroll past is no longer a warning about
// the bundle actually growing. And a project that escalates AVX_W01 to
// "error", which is documented and supported, could not run a
// development build at all: it failed on the size of a file it does not
// ship.
const isBuildArtifact =
file === traceMapFileName || file === atlasMapFileName || file.endsWith('.map');
if (!isBuildArtifact && sizeKb > this.bundleSizeWarningKb()) {
// Escalating AVX_W01 to an error throws from here. That happens
// before promotion, so the size limit is enforced on output that
// never reaches distDir.
reportWarning(
AvenxErrorCodes.COMPILER_BUNDLE_SIZE_EXCEEDED,
new BuildError(
AvenxErrorCodes.COMPILER_BUNDLE_SIZE_EXCEEDED,
file,
this.bundleSizeWarningKb(),
sizeKb.toFixed(2),
),
this.config,
);
}
});
this.promoteStaging(staging, files);
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
logger.info('-----------------------');
logger.info(`\nBuild successful: ${this.distDir}/${jsFileName} & ${this.distDir}/${cssFileName}`);
const endTime = performance.now();
const durationMs = Math.round(endTime - startTime);
logger.info(`Build completed in ${durationMs} ms`);
return { mode: this.mode, distDir: this.distDir, files, durationMs, bundle: result.stats };
}
/**
* The runtime capabilities this build linked, and how to see them in output.
*
* Each entry pairs a capability the compiler put into the entry graph with
* evidence that can only appear if the module providing it was linked *and*
* ran. Evidence of the call, never of the declaration: `AvenxComponent`
* imports the registry in order to read from it, so the registry module --
* and the text of `installStringRenderer(classes)` inside it -- is in every
* bundle whether or not anything ever fills it.
* @returns {Array<{capability: string, reason: string, evidence: string}>}
* What the emitted bundle has to contain.
* @private
*/
linkedRuntimeCapabilities() {
const required = [];
const fallbacks = this.componentParser.renderFallbacks;
if (fallbacks.length > 0) {
required.push({
capability: 'the string renderer',
reason: `${fallbacks.length} template(s) could not be compiled to a render program (AVX_W47)`,
// The installer passes an object literal; the declaration takes a
// named parameter. Only the former can match.
evidence: 'installStringRenderer\\(\\s*\\{',
});
}
return required;
}
/**
* The size above which the build warns about the JavaScript it produced.
*
* Configurable because the honest number depends on what an application is.
* The default is a ceiling for the whole bundle including the runtime, not a
* budget for application code, and a threshold nothing can satisfy is a
* threshold everyone learns to ignore.
* @returns {number} The threshold in KB.
* @private
*/
bundleSizeWarningKb() {
const configured = this.config && this.config.bundleSizeWarningKb;
return typeof configured === 'number' && configured > 0 ? configured : BUNDLE_SIZE_WARNING_THRESHOLD_KB;
}
/**
* Assembles the module the bundle is rooted at.
*
* `main.app.js` stays the developer's file. What the compiler discovered for
* them -- the pages under `src/pages/`, the bridges something imports -- is
* added as ordinary imports and registration calls, in the same place the old
* pipeline injected them.
* @param {Map<string, string>} virtualModules - The generated module table.
* @param {Array<object>} registrations - Units to import and register.
* @returns {string} The entry module's id.
* @private
*/
buildEntryModule(virtualModules, registrations) {
const prelude = [];
const globalsId = path.join(this.srcDir, '__avenx_globals__.js');
virtualModules.set(globalsId, globalsModule(PUBLIC_GLOBALS, NAMESPACE_GLOBAL));
prelude.push(globalsId);
// Development builds carry the trace recorder, because `avenx serve
// --trace` installs it through the namespace. Production builds do not
// reference it, so it is shaken out -- which is the difference between the
// two modes, and the whole of it.
if (!this.production) {
const devtoolsId = path.join(this.srcDir, '__avenx_devtools__.js');
virtualModules.set(devtoolsId, devtoolsModule(NAMESPACE_GLOBAL));
prelude.push(devtoolsId);
// The expression interpreter, for the same reason and on the same terms.
// A development build keeps rendering an expression the generator could
// not compile -- the build already said which, as AVX_W48. A production
// build references nothing here, so the parser, the tree-walking
// evaluator and the old source-text sandbox leave the graph, and with
// them the last `new Function` in the framework.
const interpreterId = path.join(this.srcDir, '__avenx_interpreter__.js');
virtualModules.set(interpreterId, interpreterModule());
prelude.push(interpreterId);
}
// The string renderer, only when something in this build still needs it.
// `renderFallbacks` is populated by the component parser as it compiles, so
// by the time the entry module is built the answer is known exactly: a
// build where every template lowered does not reference the renderer, and
// the bundler shakes out the patcher, the list manager, the defer manager
// and the template renderer with it.
if (this.componentParser.renderFallbacks.length > 0) {
const stringRendererId = path.join(this.srcDir, '__avenx_string_renderer__.js');
virtualModules.set(stringRendererId, stringRendererModule());
prelude.push(stringRendererId);
}
// Built-in components, only the ones this build's templates reference.
for (const [tag, specifier] of BUILTIN_COMPONENT_MODULES) {
if (!this.componentParser.referencedComponents.has(tag)) continue;
const builtinId = path.join(this.srcDir, `__avenx_builtin_${tag}__.js`);
virtualModules.set(builtinId, builtinComponentModule(specifier, tag));
prelude.push(builtinId);
}
const rewindSettings = this.rewindSettings();
if (rewindSettings) {
const rewindId = path.join(this.srcDir, '__avenx_rewind__.js');
virtualModules.set(rewindId, rewindConfigModule(rewindSettings));
prelude.push(rewindId);
}
const mainFile = path.join(this.srcDir, 'main.app.js');
const source = fs.existsSync(mainFile) ? replaceEnvVariables(fs.readFileSync(mainFile, 'utf-8')) : '';
const entryId = fs.existsSync(mainFile) ? mainFile : path.join(this.srcDir, '__avenx_entry__.js');
virtualModules.set(entryId, entryModule({ source, registrations, prelude }));
return entryId;
}
/**
* Runs the bundler and translates its failures into build diagnostics.
*
* Each of these used to be silence. An unresolvable import was deleted, a
* mistyped named import became `undefined`, and the build said it had
* succeeded. Turning them into coded, located errors is the point of the
* migration, so the translation is explicit rather than a generic wrapper.
* @param {object} options - Bundling options.
* @param {string} options.entryId - The entry module id.
* @param {Map<string, string>} options.virtualModules - Generated modules.
* @param {boolean} options.sourceMap - Whether to emit a source map.
* @param {string} options.file - The output file name.
* @returns {{code: string, map: object|null, stats: object}} The bundle.
* @throws {BuildError} When the application does not link.
* @private
*/
runBundler({ entryId, virtualModules, sourceMap, file }) {
const treeShake = !(this.config && (this.config.treeShake === false || this.config.treeShakeComponents === false));
const shouldMinify =
this.config && typeof this.config.minify === 'boolean' ? this.config.minify : this.production;
try {
return bundle({
entries: [entryId],
virtualModules,
rootDir: this.rootDir,
treeShake,
minify: shouldMinify,
sourceMap,
file,
});
} catch (error) {
throw this.describeBundleFailure(error);
}
}
/**
* Turns a bundler error into a located BuildError.
* @param {Error} error - What the bundler threw.
* @returns {Error} A BuildError, or the original when it is not ours.
* @private
*/
describeBundleFailure(error) {
const relative = (file) => (file && path.isAbsolute(file) ? path.relative(this.rootDir, file) : file || 'the bundle');
if (error instanceof ResolveError) {
return new BuildError(
AvenxErrorCodes.COMPILER_UNRESOLVED_IMPORT,
error.specifier,
relative(error.importer),
error.reason,
);
}
if (error instanceof BindingError) {
return new BuildError(
AvenxErrorCodes.COMPILER_MISSING_EXPORT,
relative(error.importer),
error.message,
relative(error.importer),
);
}
if (error instanceof DynamicImportError) {
return new BuildError(AvenxErrorCodes.COMPILER_UNRESOLVED_IMPORT, 'a computed dynamic import', relative(error.importer), error.message);
}
if (error instanceof ModuleParseError) {
return new BuildError(AvenxErrorCodes.COMPILER_MODULE_UNREADABLE, relative(error.file), error.message);
}
if (error instanceof EmitError) {
// A live-binding collision is two modules publishing one bundle-scope
// name, which is what AVX_C16 has always described.
if (error.kind === 'live-binding-collision') {
return new BuildError(AvenxErrorCodes.COMPILER_DUPLICATE_BUNDLE_BINDING, error.message, relative(error.file), '');
}
// A cycle between bridges is a bridge problem, and saying so is more use
// than the general module-cycle message. Bridge cycles were always fatal
// and stay fatal: a bridge's default export is a value, and a value
// cannot cross a cycle in a browser either.
const bridgeCycle = (error.cycles || []).find(
(cycle) => cycle.length > 1 && cycle.every((id) => id.endsWith('.bridge.js')),
);
if (bridgeCycle) {
const names = bridgeCycle.map((id) => path.basename(id, '.bridge.js'));
return new BuildError(AvenxErrorCodes.COMPILER_BRIDGE_CIRCULAR_IMPORT, names.join(' -> '));
}
return new BuildError(AvenxErrorCodes.COMPILER_BUNDLE_CYCLE, error.message, relative(error.file));
}
return error;
}
/**
* The stylesheets belonging to units that reached the bundle.
*
* Every component is compiled so Atlas can describe the project as written,
* which means the style processor has seen more stylesheets than the
* application uses. A component the bundler shook out must not leave its CSS
* behind: the old pipeline only ever parsed the components it kept, so
* omitting them here preserves what a developer actually observed.
* @param {Set<string>} included - Module ids that reached the bundle.
* @returns {Set<string>} Absolute paths of the stylesheets to emit.
* @private
*/
includedStylesheets(included) {
const sheets = new Set();
for (const id of included) {
const match = /\.(component|page)\.js$/.exec(id);
if (!match) continue;
sheets.add(id.replace(/\.(component|page)\.js$/, `.${match[1]}.css`));
}
return sheets;
}
/**
* Reports what the bundle contains, and what was left out.
*
* The old build could not have printed this: the runtime arrived as one
* prebuilt file, so there was no count of modules and no notion of a module
* being dropped. Printing it now is the same house rule the render-program
* fallback follows -- say what the build did, including what it removed.
* @param {object} stats - The bundler's report.
* @private
*/
reportBundleStats(stats) {
if (!stats) return;
const parts = [`${stats.modulesEmitted} modules`];
if (stats.modulesShaken > 0) {
parts.push(`${stats.modulesShaken} shaken out`);
}
if (stats.externals > 0) {
parts.push(`${stats.externals} from node_modules`);
}
logger.info(`\nBundled ${parts.join(' · ')}`);
}
/**
* Reports templates that could not be compiled to a render program.
*
* A component without a program still renders correctly -- it takes the
* string renderer, which re-renders and re-diffs the whole template on every
* update. That is a real and invisible cost, so it is named. The same house
* rule Atlas follows: an analysis that stopped short says where.
* @private
*/
reportRenderFallbacks() {
const fallbacks = this.componentParser.renderFallbacks;
if (!fallbacks || fallbacks.length === 0) {
return;
}
// Grouped by reason rather than listed per component: "eleven templates
// contain a list" is one thing to act on, eleven lines are eleven.
const byReason = new Map();
for (const entry of fallbacks) {
if (!byReason.has(entry.reason)) {
byReason.set(entry.reason, []);
}
byReason.get(entry.reason).push(entry.name);
}
const detail = [...byReason.entries()]
.sort((a, b) => b[1].length - a[1].length)
.map(([reason, names]) => ` ${reason}: ${names.sort().join(', ')}`)
.join('\n');
reportWarning(
AvenxErrorCodes.COMPILER_RENDER_NOT_COMPILED,
new BuildError(AvenxErrorCodes.COMPILER_RENDER_NOT_COMPILED, fallbacks.length, detail),
this.config,
);
}
/**
* Reports what the expression generator could not compile.
*
* A security refusal fails the build: an expression naming `window` or
* writing `__proto__` is a mistake in the application, and the developer
* should learn about it here rather than from a sandbox violation the first
* time that branch executes.
*
* A language gap is a warning. The expression still works -- it is
* interpreted at runtime as it always was -- but it is the reason the
* interpreter is still in the bundle, so the cost is named rather than
* absorbed silently. Same house rule as AVX_W47 above.
* @throws {BuildError} When an expression was refused for a security reason.
* @private
*/
reportExpressionGaps() {
const units = this.componentParser.expressionGaps;
if (!units || units.length === 0) {
return;
}
for (const unit of units) {
for (const refusal of unit.refusals) {
throw new BuildError(
AvenxErrorCodes.COMPILER_EXPRESSION_REFUSED,
refusal.source,
refusal.reason,
unit.name,
);
}
}
const gaps = units.flatMap((unit) =>
unit.gaps.map((gap) => ({ ...gap, name: unit.name, filePath: unit.filePath })),
);
if (gaps.length === 0) {
return;
}
const detail = gaps
.map((gap) => {
const where = gap.filePath
? ` ${path.relative(this.rootDir, gap.filePath).split(path.sep).join('/')}${gap.line ? `:${gap.line}` : ''}`
: '';
return ` <${gap.name}>${where} ${JSON.stringify(gap.source)} - ${gap.reason}`;
})
.sort()
.join('\n');
// A production bundle has no interpreter. Every one of these would throw
// AVX_R32 when evaluated, so the build fails instead of shipping them, and
// the warning configuration cannot silence it. A development build keeps
// the warning so a template being edited still renders what does compile.
if (this.production) {
throw new BuildError(AvenxErrorCodes.COMPILER_EXPRESSION_NOT_EXECUTABLE, gaps.length, detail);
}
reportWarning(
AvenxErrorCodes.COMPILER_EXPRESSION_NOT_COMPILED,
new BuildError(AvenxErrorCodes.COMPILER_EXPRESSION_NOT_COMPILED, gaps.length, detail),
this.config,
);
}
/**
* The Rewind journal settings this project overrides, if any.
*
* A project that leaves `rewind` alone produces no configuration module at
* all, so the defaults baked into the journal are the only thing shipped.
* @returns {object|null} The settings, or null when there are none.
* @private
*/
rewindSettings() {
const rewind = this.config && this.config.rewind;
if (!rewind) return null;
const settings = {};
if (rewind.onConflict && rewind.onConflict !== 'safe') {
settings.onConflict = rewind.onConflict;
}
if (typeof rewind.maxSnapshotItems === 'number' && rewind.maxSnapshotItems !== 10000) {
settings.maxSnapshotItems = rewind.maxSnapshotItems;
}
return Object.keys(settings).length > 0 ? settings : null;
}
/**
* Starts a fresh Atlas model for this run and attaches it to the parser.
*
* Called at the top of both `build` and `analyze` so a compiler instance can
* be reused — `avenx watch` does exactly that — without the second run
* inheriting the first run's nodes.
* @returns {AppModel} The model being populated.
*/
beginModel() {
this.model = new AppModel();
this.componentParser.setRootDir(this.rootDir);
this.componentParser.setModel(this.model);
return this.model;
}
/**
* Completes the Atlas model once every unit has been parsed.
*
* Render edges are resolved here rather than during parsing because a
* component's template can name a child that has not been compiled yet, and
* an edge to a node that does not exist yet would be dropped.
* @returns {AppModel} The finished model.
* @private
*/
finishModel() {
const known = new Map();
for (const unit of this.componentParser.__atlasUnits) {
known.set(unit.name, `${unit.kind}:${unit.name}`);
}
for (const unit of this.componentParser.__atlasUnits) {
addRenderEdges(this.model, {
ownerId: `${unit.kind}:${unit.name}`,
content: unit.content,
masked: unit.masked,
starts: unit.starts,
file: path.relative(this.rootDir, unit.filePath).split(path.sep).join('/'),
known,
});
}
addRoutesAndGuards(this.model, { srcDir: this.srcDir, rootDir: this.rootDir });
return this.model;
}
/**
* Builds the Atlas model without emitting a bundle.
*
* `avenx atlas`, `avenx impact` and `avenx why` need the model and nothing
* else, and writing a bundle to answer a query would be both slow and rude
* — it would overwrite whatever is in `dist/`. The work here is the same
* parse the build performs, so the two can never disagree.
* @param {object} [options] - Analysis options.
* @param {boolean} [options.tolerant] - Record a failing phase on the model
* and carry on, rather than throwing. Defaults to true, because a query is
* often asked precisely because the project is broken.
* @returns {AppModel} The finished model.
*/
analyze(options = {}) {
if (!fs.existsSync(this.srcDir)) {
throw new BuildError(AvenxErrorCodes.COMPILER_SRC_DIR_MISSING, this.srcDir);
}
const tolerant = options.tolerant !== false;
this.__analyzing = true;
this.styleProcessor.reset();
this.__bridgeConsumerFiles = null;
this.__bridgeConsumerSources = null;
this.beginModel();
// A query is asked *about* code, often precisely because something is
// wrong with it. A malformed bridge should cost the model that bridge, not
// the whole answer, so each phase is allowed to fail on its own and the
// failure is recorded where the caller can report it.
const phase = (name, run) => {
try {
return run();
} catch (err) {
if (!tolerant) throw err;
this.model.errors.push({
phase: name,
code: err.code || 'AVX_UNK',
message: String(err.message || err),
});
return null;
}
};
try {
const bridgeData = phase('bridges', () => this.processBridges());
this.componentParser.setBridges((bridgeData && bridgeData.bridges) || new Map());
phase('componentNames', () => this.collectComponentNames());
phase('components', () => this.processComponents());
phase('pages', () => this.processPages());
return this.finishModel();
} finally {
this.__analyzing = false;
}
}
/**
* Writes the finished artifacts to a staging directory.
*
* Staging lives inside distDir so that promotion is a rename on the same
* filesystem — a rename across devices fails with EXDEV, which would put the
* promote step back in the business of copying half a build.
* @param {Map<string, string>} outputs - File name to contents.
* @returns {string} The staging directory path.
* @throws {BuildError} When the output directory cannot be written to.
* @private
*/
writeStaging(outputs) {
const staging = path.join(this.distDir, `.avenx-staging-${process.pid}`);
try {
fs.rmSync(staging, { recursive: true, force: true });
fs.mkdirSync(staging, { recursive: true });
for (const [fileName, contents] of outputs) {
fs.writeFileSync(path.join(staging, fileName), contents);
}
} catch (err) {
fs.rmSync(staging, { recursive: true, force: true });
throw new BuildError(AvenxErrorCodes.COMPILER_DIST_CREATION_FAILED, `${this.distDir} (${err.message})`);
}
return staging;
}
/**
* Moves staged artifacts into the output directory.
*
* Each file is renamed into place, which is atomic per file. A build that
* fails before this point leaves distDir exactly as it was, so the previous
* artifacts stay whole rather than being partly overwritten by a build that
* never finished.
*
* The previous artifacts are deliberately not deleted on failure. The exit
* code is what stops a deployment; removing a good bundle would break
* anything still serving it — a dev server, a local preview, a rollback —
* and would turn a build error into a second, unrelated outage.
* @param {string} staging - The staging directory.
* @param {string[]} files - File names to promote.
* @throws {BuildError} When a staged file cannot be moved into place.
* @private
*/
promoteStaging(staging, files) {
for (const fileName of files) {
const from = path.join(staging, fileName);
const to = path.join(this.distDir, fileName);
try {
fs.renameSync(from, to);
} catch (err) {
throw new BuildError(AvenxErrorCodes.COMPILER_DIST_CREATION_FAILED, `${to} (${err.message})`);
}
}
}
/**
* Processes bridge registrations from the global directory.
* @returns {{registrations: string}} The registration code for bridges.
* @private
*/
/**
* Collects every `.bridge.js` module in the project.
* Bridges live in `src/bridges/` (preferred) or alongside guards in
* `src/global/`; both are scanned recursively.
* @returns {string[]} Absolute paths to bridge modules.
* @private
*/
findBridgeFiles() {
const files = [];
const scan = (dir) => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir)) {
const fullPath = path.join(dir, entry);
if (fs.statSync(fullPath).isDirectory()) {
scan(fullPath);
} else if (entry.endsWith('.bridge.js')) {
files.push(fullPath);
}
}
};
scan(path.join(this.srcDir, 'bridges'));
scan(path.join(this.srcDir, 'global'));
return files;
}
/**
* Collects the files that may import a bridge: components, pages, the app
* entry point and other bridges.
* @returns {string[]} Absolute paths to candidate consumer files.
* @private
*/
findBridgeConsumerFiles() {
if (this.__bridgeConsumerFiles) {
return this.__bridgeConsumerFiles;
}
const files = [];
const scan = (dir, ext) => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir)) {
const fullPath = path.join(dir, entry);
if (fs.statSync(fullPath).isDirectory()) {
scan(fullPath, ext);
} else if (entry.endsWith(ext)) {
files.push(fullPath);
}
}
};
scan(path.join(this.srcDir, 'components'), '.component.js');
scan(path.join(this.srcDir, 'pages'), '.page.js');
// Guards import bridges too. Leaving them out of consumer discovery made a
// bridge whose only importer was a guard look unreachable, so it was
// tree-shaken out of the bundle and the guard's alias resolved to an
// undefined identifier -- a ReferenceError that stopped the application
// booting at all.
scan(path.join(this.srcDir, 'guards'), '.guard.js');
scan(path.join(this.srcDir, 'global'), '.guard.js');
const mainFile = path.join(this.srcDir, 'main.app.js');
if (fs.existsSync(mainFile)) {
files.push(mainFile);
}
this.__bridgeConsumerFiles = files;
return files;
}
/**
* Reads a bridge consumer's source, caching it for the duration of a build.
* Reachability and usage validation both need every consumer's text.
* @param {string} filePath - The consumer file.
* @returns {string} Its source.
* @private
*/
readBridgeConsumer(filePath) {
if (!this.__bridgeConsumerSources) {
this.__bridgeConsumerSources = new Map();
}
let source = this.__bridgeConsumerSources.get(filePath);
if (source === undefined) {
source = fs.readFileSync(filePath, 'utf-8');
this.__bridgeConsumerSources.set(filePath, source);
}
return source;
}
/**
* Processes bridge modules.
*
* A bridge file is already valid JavaScript, so this generates a module that
* is almost the file itself: its default export is named so the runtime can
* be told what to call it, and that is all. Its own imports -- the runtime,
* other bridges -- stay as written and become real edges in the graph, which
* is what replaced the old alias-and-concatenate scheme along with its
* hand-rolled cycle detection and emission ordering.
* @param {Map<string, string>} [virtualModules] - Generated module table to fill.
* @param {Array<object>} [registrations] - Units to import and register.
* @returns {{bridges: Map<string, object>}} The descriptor of every bridge,
* keyed by absolute path.
* @private
*/
processBridges(virtualModules = null, registrations = null) {
/** @type {Map<string, object>} */
const bridges = new Map();
for (const filePath of this.findBridgeFiles()) {
const descriptor = analyzeBridgeFile(filePath, replaceEnvVariables);
if (!descriptor) {
throw new BuildError(
AvenxErrorCodes.COMPILER_BRIDGE_INVALID_MODULE,
path.relative(this.rootDir, filePath),
);
}
bridges.set(path.resolve(filePath), descriptor);
}
// Every bridge enters the model, reachable or not: `avenx atlas` should
// describe the project as written, and a bridge omitted from the bundle
// for having no importer is exactly what `inspect` needs to report.
if (this.model) {
for (const descriptor of bridges.values()) {
addBridgeUnit(this.model, descriptor, {
rootDir: this.rootDir,
source: replaceEnvVariables(fs.readFileSync(descriptor.filePath, 'utf-8')),
bridges,
});
}
}
// Bridge names become identifiers in the bundle, so they have to be unique.
const byName = new Map();
for (const descriptor of bridges.values()) {
if (!byName.has(descriptor.name)) {
byName.set(descriptor.name, []);
}
byName.get(descriptor.name).push(descriptor.filePath);
}
const duplicates = [...byName.entries()].filter(([, paths]) => paths.length > 1);
if (duplicates.length > 0) {
const details = duplicates
.map(([name, paths]) => ` "${name}":\n${paths.map((item) => ` - ${item}`).join('\n')}`)
.join('\n');
throw new BuildError(AvenxErrorCodes.COMPILER_BRIDGE_DUPLICATE_NAME, details);
}
// Reachability: a modern bridge ships only when something imports it,
// directly or through another bridge.
const reachable = new Set();
const visit = (resolvedPath) => {
const key = path.resolve(resolvedPath);
if (reachable.has(key)) return;
const descriptor = bridges.get(key);
if (!descriptor) return;
reachable.add(key);
for (const entry of descriptor.bridgeImports) {
visit(entry.resolved);
}
};
for (const consumer of this.findBridgeConsumerFiles()) {
const source = this.readBridgeConsumer(consumer);
for (const entry of findBridgeImports(consumer, source)) {
if (!bridges.has(path.resolve(entry.resolved))) {
throw new BuildError(
AvenxErrorCodes.COMPILER_BRIDGE_NOT_FOUND,
entry.specifier,
path.relative(this.rootDir, consumer),
entry.resolved,
[...bridges.values()].map((item) => item.name).join(', ') || 'none',
);
}
visit(entry.resolved);
}
}
// Emission order and cycle detection are the bundler's job now: it orders
// the whole graph topologically and reports a cycle that carries a binding
// which cannot cross it, rather than every cycle regardless of what it
// carries. A bridge module is generated for each reachable bridge and the
// graph decides the rest.
for (const key of reachable) {
const descriptor = bridges.get(key);
if (!descriptor) continue;
logger.info(`[Bridge] ${descriptor.name}`);
if (virtualModules) {
const source = replaceEnvVariables(fs.readFileSync(descriptor.filePath, 'utf-8'));
virtualModules.set(
path.resolve(descriptor.filePath),
bridgeModule({ name: descriptor.name, binding: descriptor.binding, source }),
);
}
if (registrations) {
registrations.push({ name: descriptor.name, file: path.resolve(descriptor.filePath), kind: 'bridge' });
}
}
const unused = [...bridges.values()].filter((item) => !reachable.has(path.resolve(item.filePath)));
for (const descriptor of unused) {
logger.info(`[Bridge] ${descriptor.name} — not imported anywhere, omitted from the bundle`);
}
return { bridges };
}
/**
* Reports template members and event subscriptions that a bridge does not
* declare. These are silent `undefined` reads at runtime, so they are worth
* surfacing at build time with a suggestion.
* @param {Map<string, object>} bridges - Discovered bridges by absolute path.
* @private
*/
validateBridgeUsage(bridges) {
if (bridges.size === 0) return;
for (const consumer of this.findBridgeConsumerFiles()) {
const source = this.readBridgeConsumer(consumer);
const imports = findBridgeImports(consumer, source);
if (imports.length === 0) continue;
const relative = path.relative(this.rootDir, consumer);
// Scan the body only: an import specifier such as './auth.bridge.js'
// would otherwise look like a member access on `auth`.
const body = source.replace(/^[ \t]*import\s+(?:[\s\w$,{}*]*?\s+from\s+)?['"][^'"]*['"];?[ \t]*\r?\n?/gm, '');
const byLocal = new Map();
for (const entry of imports) {
const descriptor = bridges.get(path.resolve(entry.resolved));
if (descriptor) {
byLocal.set(entry.local, descriptor);
}
}
for (const [local, descriptor] of byLocal) {
const members = declaredMembers(descriptor);
const accessRegex = new RegExp(`\\b${local}\\s*(?:\\?\\.|\\.)\\s*([A-Za-z_$][\\w$]*)`, 'g');
const reported = new Set();
let match;
while ((match = accessRegex.exec(body)) !== null) {
const member = match[1];
if (members.includes(member) || reported.has(member)) continue;
reported.add(member);
reportWarning(
AvenxErrorCodes.COMPILER_BRIDGE_UNKNOWN_MEMBER,
new BuildError(
AvenxErrorCodes.COMPILER_BRIDGE_UNKNOWN_MEMBER,
descriptor.name,
member,
relative,
suggestName(member, members),
members.join(', '),
),
this.config,
);
}
}
for (const { target, event } of extractSubscriptions(body)) {
const descriptor = byLocal.get(target);
if (!descriptor || descriptor.events.includes(event)) continue;
reportWarning(
AvenxErrorCodes.COMPILER_BRIDGE_UNKNOWN_EVENT,
new BuildError(
AvenxErrorCodes.COMPILER_BRIDGE_UNKNOWN_EVENT,
descriptor.name,
event,
relative,
suggestName(event, descriptor.events),
descriptor.events.join(', ') || 'none',
),
this.config,
);
}
}
}
/**
* Registers guard modules with environment variables substituted.
*
* A guard file is already valid JavaScript -- `export default class AuthGuard
* extends AvenxGuard` with ordinary imports -- so nothing is rewritten. The
* bridge alias machinery this replaced existed only because
* `rewriteRuntimeImports` deleted a guard's bridge import and left the local
* name undefined, which reported AVX_R07 on every navigation through it.
* With imports resolved rather than deleted, the import means what it says.
*
* A guard nothing imports is no longer emitted at all, where the old pipeline
* concatenated every `.guard.js` in the project whether or not a route used
* it.
* @param {Map<string, string>} virtualModules - Generated module table to fill.
* @returns {void}
* @private
*/
processGuards(virtualModules) {
const scan = (dir) => {
if (!fs.existsSync(dir)) return;
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.guard.js')) continue;
const filePath = path.join(dir, file);
const name = path
.basename(file, '.guard.js')
.split(/[-_]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('') + 'Guard';
logger.info(`[Guard] ${name}`);
virtualModules.set(path.resolve(filePath), replaceEnvVariables(fs.readFileSync(filePath, 'utf-8')));
}
};
scan(path.join(this.srcDir, 'global'));
scan(path.join(this.srcDir, 'guards'));
}
/**
* Collects every registered component and page name by filename and hands
* the set to the parser before any file is compiled.
*
* The unresolved-component check (AVX_W46) runs per file, while a file is
* being parsed, so it cannot rely on the running scan having reached every
* sibling yet. Discovering all names up front from filenames — the same
* PascalCase derivation `processComponents`/`processPages` use — means the
* check sees the whole project regardless of the order files are visited or
* whether tree shaking drops a component from the output.
* @returns {Set<string>} The registered component and page names.
* @private
*/
collectComponentNames() {
const names = new Set();
const toClassName = (fileName, suffix) =>
path
.basename(fileName, suffix)
.split(/[-_]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
const scan = (dir, suffix) => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir)) {
const fullPath = path.join(dir, entry);
if (fs.statSync(fullPath).isDirectory()) {
scan(fullPath, suffix);
} else if (entry.endsWith(suffix)) {
names.add(toClassName(entry, suffix));
}
}
};
scan(path.join(this.srcDir, 'components'), '.component.js');
scan(path.join(this.srcDir, 'pages'), '.page.js');
this.componentParser.setComponentNames(names);
return names;
}
/**
* Compiles every component in `src/components` into an ES module.
*
* Every component is compiled, and the bundler decides which ones ship: a
* component is in the bundle when something imports it, transitively from the
* entry. That replaces `findUsedComponents`, which scanned template tags and
* import statements with regular expressions to guess the same answer, and
* could only ever approximate it -- a component it wrongly kept was dead
* weight, and one it wrongly dropped was a runtime failure.
* @param {Map<string, string>} [virtualModules] - Generated module table to fill.
* @returns {void}
* @private
*/
processComponents(virtualModules = null) {
const compDir = path.join(this.srcDir, 'components');
const classNameMap = new Map();
const pathToClassName = new Map();
const toClassName = (fileName) =>
path
.basename(fileName, '.component.js')
.split(/[-_]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
const scan = (dir) => {
if (!fs.existsSync(dir)) return;
fs.readdirSync(dir).forEach((file) => {
const fullPath = path.join(dir, file);
if (fs.statSync(fullPath).isDirectory()) {
scan(fullPath);
} else if (file.endsWith('.component.js')) {
const className = toClassName(file);
if (!classNameMap.has(className)) {
classNameMap.set(className, []);
}
classNameMap.get(className).push(fullPath);
pathToClassName.set(path.resolve(fullPath), className);
}
});
};
scan(compDir);
const duplicates = [...classNameMap.entries()].filter(([, paths]) => paths.length > 1);
if (duplicates.length > 0) {
const details = duplicates
.map(([className, paths]) => ` "${className}":\n${paths.map((p) => ` - ${p}`).join('\n')}`)
.join('\n');
throw new BuildError(AvenxErrorCodes.COMPILER_DUPLICATE_COMPONENT_NAME, details);
}
// Build dependency graph for components
const graph = new Map();
classNameMap.forEach((paths, className) => {
const fullPath = paths[0];
const deps = new Set();
if (fs.existsSync(fullPath)) {
const content = fs.readFileSync(fullPath, 'utf-8');
// Extract dependencies from JS import statements
const importRegex = /import\s+(?:[\s\w$,{}*]*?\s+from\s+['"](.*?)['"]|['"](.*?)['"]);?/g;
let match;
while ((match = importRegex.exec(content)) !== null) {
const importSpecifier = match[1] || match[2];
if (importSpecifier && importSpecifier.startsWith('.')) {
let resolved = path.resolve(path.dirname(fullPath), importSpecifier);
if (!resolved.endsWith('.js')) {
if (fs.existsSync(`${resolved}.component.js`)) {
resolved = `${resolved}.component.js`;
} else if (fs.existsSync(`${resolved}.js`)) {
resolved = `${resolved}.js`;
}
}
const targetClassName = pathToClassName.get(resolved);
if (targetClassName && targetClassName !== className) {
deps.add(targetClassName);
}
}
}
// Extract dependencies from HTML template tags (e.g. <ChildComp />)
const tagRegex = /<([A-Z][a-zA-Z0-9]*)\b/g;
while ((match = tagRegex.exec(content)) !== null) {
const tagName = match[1];
if (classNameMap.has(tagName) && tagName !== className) {
deps.add(tagName);
}
}
}
graph.set(className, Array.from(deps));
});
// Cycle detection & topological sort using DFS
const visited = new Set();
const visiting = new Map();
const reportedCycles = new Set();
const orderedClasses = [];
const dfs = (className, stack = []) => {
if (visited.has(className)) return;
if (visiting.has(className)) {
const startIndex = stack.indexOf(className);
const cyclePath = stack.slice(startIndex).concat(className);
const cycleStr = cyclePath.join(' -> ');
if (!reportedCycles.has(cycleStr)) {
reportedCycles.add(cycleStr);
reportWarning(
AvenxErrorCodes.COMPILER_CIRCULAR_DEPENDENCY,
new BuildError(AvenxErrorCodes.COMPILER_CIRCULAR_DEPENDENCY, cycleStr),
this.config,
);
}
return;
}
visiting.set(className, stack.length);
stack.push(className);
const deps = graph.get(className) || [];
for (const dep of deps) {
dfs(dep, stack);
}
stack.pop();
visiting.delete(className);
visited.add(className);
orderedClasses.push(className);
};
classNameMap.forEach((_, className) => {
if (!visited.has(className)) {
dfs(className);
}
});
orderedClasses.forEach((className) => {
const paths = classNameMap.get(className);
if (!paths || paths.length === 0) return;
const fullPath = paths[0];
logger.info(`[Compiling] ${path.basename(fullPath)}`);
const compiled = this.componentParser.parse(fullPath);
if (virtualModules) {
virtualModules.set(path.resolve(fullPath), this.wrapUnit(fullPath, compiled));
}
});
}
/**
* Frames a compiled class as an ES module.
* @param {string} filePath - The unit's source path.
* @param {string} compiled - The class declaration the parser produced.
* @returns {string} The module source.
* @private
*/
wrapUnit(filePath, compiled) {
const meta = this.componentParser.moduleMeta.get(path.resolve(filePath));
if (!meta) {
throw new BuildError(
AvenxErrorCodes.COMPILER_MODULE_UNREADABLE,
path.relative(this.rootDir, filePath),
'the compiler produced no module information for this unit',
);
}
return componentModule({
className: meta.className,
body: compiled,
isPage: meta.isPage,
imports: meta.imports,
bridgeBindings: meta.bridgeBindings,
});
}
/**
* Compiles every page in `src/pages` into an ES module.
*
* Pages are the one unit a developer never imports: they are discovered by
* directory and routed by name, so the entry module imports and registers
* them on their behalf. That is the same contract as before -- what changed
* is that the registration now names an imported binding rather than a
* class that happened to be in scope because it had been concatenated above.
* @param {Map<string, string>} [virtualModules] - Generated module table to fill.
* @param {Array<object>} [registrations] - Units to import and register.
* @returns {void}
* @private
*/
processPages(virtualModules = null, registrations = null) {
const pageDir = path.join(this.srcDir, 'pages');
const scan = (dir) => {
if (!fs.existsSync(dir)) return;
fs.readdirSync(dir).forEach((file) => {
const fullPath = path.join(dir, file);
if (fs.statSync(fullPath).isDirectory()) {
scan(fullPath);
return;
}
if (!file.endsWith('.page.js')) return;
logger.info(`[Compiling Page] ${file}`);
const name = path
.basename(file, '.page.js')
.split(/[-_]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
const compiled = this.componentParser.parse(fullPath, 'page');
if (virtualModules) {
virtualModules.set(path.resolve(fullPath), this.wrapUnit(fullPath, compiled));
}
if (registrations) {
registrations.push({ name, file: path.resolve(fullPath), kind: 'page' });
}
});
};
scan(pageDir);
}
/**
* Compiles a single component file.
* @param {string} filePath
* @returns {string}
*/
compileComponent(filePath) {
return this.componentParser.parse(filePath);
}
/**
* Compiles a single page file.
* @param {string} filePath
* @returns {string}
*/
compilePage(filePath) {
return this.componentParser.parse(filePath, 'page');
}
}
export default AvenxCompiler;
|