@ape-egg/vibe 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.2] - 2026-02-27
4
+
5
+ ### Fixed
6
+
7
+ - **Component inlining — hyphenated custom elements inside slot content** (`find_matching_close`)
8
+ - `<component-card>`, `<component-grid>`, etc. inside slot content incorrectly matched `<component\b`, incrementing the depth counter without a corresponding close
9
+ - The real `</component>` could never bring the depth back to 0, so `find_matching_close` returned `None` and the outer component was left un-inlined (causing `Cannot GET /components/Layout.html` at runtime)
10
+ - Fixed by replacing `\b` with `(?:\s|>|/>)` — only matches actual `<component` tags followed by whitespace or `>`, not hyphenated variants
11
+
12
+ ---
13
+
14
+ ## [1.7.1] - 2026-02-27
15
+
16
+ ### Added
17
+
18
+ - **Linux x64 binary** (`vibe-compiler-linux-x64`) — cross-compiled from macOS ARM using `musl-cross` and the `x86_64-unknown-linux-musl` Rust target
19
+ - Enables the compiler to run on Linux environments (e.g. Vercel, Docker, CI) without a local Rust toolchain
20
+ - New `compiler:build:linux` npm script builds the musl binary
21
+ - `publish:vibe:prepare` now builds both macOS ARM and Linux x64 binaries before publishing
22
+
23
+ ### Changed
24
+
25
+ - **`reqwest` TLS backend** — switched from `native-tls` to `rustls-tls` with `default-features = false`
26
+ - Eliminates the OpenSSL dependency that prevented cross-compilation to `x86_64-unknown-linux-musl`
27
+ - Pure-Rust TLS implementation; no system libraries required
28
+
29
+ ---
30
+
3
31
  ## [1.7.0] - 2026-02-19
4
32
 
5
33
  ### Fixed
@@ -430,7 +458,7 @@
430
458
  - Helps explain total compilation time vs individual phase times
431
459
  - Displayed as: `* Validated components in XXms`
432
460
  - **Component tree output**: Improved verbose logging for component compilation
433
- - Tree structure with nested dependencies (e.g., nav.html → top-of-nav.html, menu.html)
461
+ - Tree structure with nested dependencies (e.g., nav.html → menu.html)
434
462
  - Usage counts for each unique component
435
463
  - Color-coded: internal (green) vs external (magenta)
436
464
  - Summary format: `Compiled components (X internal, Y external)`
package/README.md CHANGED
@@ -291,7 +291,6 @@ bunx vibe compile --minify --source-maps
291
291
  bunx vibe compile --verbose # Step-by-step logging
292
292
  bunx vibe compile --minify # Minify output
293
293
  bunx vibe compile --elements-as-is # Keep custom elements as-is
294
- bunx vibe compile --validate # Validate HTML syntax
295
294
  bunx vibe compile --source-maps # Generate source maps
296
295
  bunx vibe compile --node-modules-as-is # Copy node_modules as-is
297
296
  bunx vibe compile --components-as-is # Skip component inlining
@@ -329,7 +328,6 @@ Add to your `package.json`:
329
328
  "elementsAsIs": false,
330
329
  "reservedElements": [],
331
330
  "sourceMaps": false,
332
- "validate": false,
333
331
  "nodeModulesAsIs": false,
334
332
  "componentsAsIs": false,
335
333
  "runtimeAsIs": false,
@@ -302,22 +302,6 @@ version = "1.0.2"
302
302
  source = "registry+https://github.com/rust-lang/crates.io-index"
303
303
  checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
304
304
 
305
- [[package]]
306
- name = "errno"
307
- version = "0.3.14"
308
- source = "registry+https://github.com/rust-lang/crates.io-index"
309
- checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
310
- dependencies = [
311
- "libc",
312
- "windows-sys 0.61.2",
313
- ]
314
-
315
- [[package]]
316
- name = "fastrand"
317
- version = "2.3.0"
318
- source = "registry+https://github.com/rust-lang/crates.io-index"
319
- checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
320
-
321
305
  [[package]]
322
306
  name = "file-id"
323
307
  version = "0.2.3"
@@ -350,21 +334,6 @@ version = "1.0.7"
350
334
  source = "registry+https://github.com/rust-lang/crates.io-index"
351
335
  checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
352
336
 
353
- [[package]]
354
- name = "foreign-types"
355
- version = "0.3.2"
356
- source = "registry+https://github.com/rust-lang/crates.io-index"
357
- checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
358
- dependencies = [
359
- "foreign-types-shared",
360
- ]
361
-
362
- [[package]]
363
- name = "foreign-types-shared"
364
- version = "0.1.1"
365
- source = "registry+https://github.com/rust-lang/crates.io-index"
366
- checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
367
-
368
337
  [[package]]
369
338
  name = "form_urlencoded"
370
339
  version = "1.2.2"
@@ -454,14 +423,13 @@ dependencies = [
454
423
 
455
424
  [[package]]
456
425
  name = "getrandom"
457
- version = "0.3.4"
426
+ version = "0.2.17"
458
427
  source = "registry+https://github.com/rust-lang/crates.io-index"
459
- checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
428
+ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
460
429
  dependencies = [
461
430
  "cfg-if",
462
431
  "libc",
463
- "r-efi",
464
- "wasip2",
432
+ "wasi",
465
433
  ]
466
434
 
467
435
  [[package]]
@@ -598,16 +566,17 @@ dependencies = [
598
566
  ]
599
567
 
600
568
  [[package]]
601
- name = "hyper-tls"
602
- version = "0.5.0"
569
+ name = "hyper-rustls"
570
+ version = "0.24.2"
603
571
  source = "registry+https://github.com/rust-lang/crates.io-index"
604
- checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
572
+ checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
605
573
  dependencies = [
606
- "bytes",
574
+ "futures-util",
575
+ "http",
607
576
  "hyper",
608
- "native-tls",
577
+ "rustls",
609
578
  "tokio",
610
- "tokio-native-tls",
579
+ "tokio-rustls",
611
580
  ]
612
581
 
613
582
  [[package]]
@@ -825,12 +794,6 @@ dependencies = [
825
794
  "redox_syscall 0.7.1",
826
795
  ]
827
796
 
828
- [[package]]
829
- name = "linux-raw-sys"
830
- version = "0.11.0"
831
- source = "registry+https://github.com/rust-lang/crates.io-index"
832
- checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
833
-
834
797
  [[package]]
835
798
  name = "litemap"
836
799
  version = "0.8.1"
@@ -919,23 +882,6 @@ dependencies = [
919
882
  "windows-sys 0.61.2",
920
883
  ]
921
884
 
922
- [[package]]
923
- name = "native-tls"
924
- version = "0.2.14"
925
- source = "registry+https://github.com/rust-lang/crates.io-index"
926
- checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
927
- dependencies = [
928
- "libc",
929
- "log",
930
- "openssl",
931
- "openssl-probe",
932
- "openssl-sys",
933
- "schannel",
934
- "security-framework",
935
- "security-framework-sys",
936
- "tempfile",
937
- ]
938
-
939
885
  [[package]]
940
886
  name = "new_debug_unreachable"
941
887
  version = "1.0.6"
@@ -1025,50 +971,6 @@ version = "1.70.2"
1025
971
  source = "registry+https://github.com/rust-lang/crates.io-index"
1026
972
  checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1027
973
 
1028
- [[package]]
1029
- name = "openssl"
1030
- version = "0.10.75"
1031
- source = "registry+https://github.com/rust-lang/crates.io-index"
1032
- checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
1033
- dependencies = [
1034
- "bitflags 2.10.0",
1035
- "cfg-if",
1036
- "foreign-types",
1037
- "libc",
1038
- "once_cell",
1039
- "openssl-macros",
1040
- "openssl-sys",
1041
- ]
1042
-
1043
- [[package]]
1044
- name = "openssl-macros"
1045
- version = "0.1.1"
1046
- source = "registry+https://github.com/rust-lang/crates.io-index"
1047
- checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
1048
- dependencies = [
1049
- "proc-macro2",
1050
- "quote",
1051
- "syn 2.0.114",
1052
- ]
1053
-
1054
- [[package]]
1055
- name = "openssl-probe"
1056
- version = "0.1.6"
1057
- source = "registry+https://github.com/rust-lang/crates.io-index"
1058
- checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
1059
-
1060
- [[package]]
1061
- name = "openssl-sys"
1062
- version = "0.9.111"
1063
- source = "registry+https://github.com/rust-lang/crates.io-index"
1064
- checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
1065
- dependencies = [
1066
- "cc",
1067
- "libc",
1068
- "pkg-config",
1069
- "vcpkg",
1070
- ]
1071
-
1072
974
  [[package]]
1073
975
  name = "parking_lot"
1074
976
  version = "0.12.5"
@@ -1162,12 +1064,6 @@ version = "0.1.0"
1162
1064
  source = "registry+https://github.com/rust-lang/crates.io-index"
1163
1065
  checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
1164
1066
 
1165
- [[package]]
1166
- name = "pkg-config"
1167
- version = "0.3.32"
1168
- source = "registry+https://github.com/rust-lang/crates.io-index"
1169
- checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
1170
-
1171
1067
  [[package]]
1172
1068
  name = "potential_utf"
1173
1069
  version = "0.1.4"
@@ -1231,12 +1127,6 @@ dependencies = [
1231
1127
  "proc-macro2",
1232
1128
  ]
1233
1129
 
1234
- [[package]]
1235
- name = "r-efi"
1236
- version = "5.3.0"
1237
- source = "registry+https://github.com/rust-lang/crates.io-index"
1238
- checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1239
-
1240
1130
  [[package]]
1241
1131
  name = "rand"
1242
1132
  version = "0.8.5"
@@ -1334,15 +1224,15 @@ dependencies = [
1334
1224
  "http",
1335
1225
  "http-body",
1336
1226
  "hyper",
1337
- "hyper-tls",
1227
+ "hyper-rustls",
1338
1228
  "ipnet",
1339
1229
  "js-sys",
1340
1230
  "log",
1341
1231
  "mime",
1342
- "native-tls",
1343
1232
  "once_cell",
1344
1233
  "percent-encoding",
1345
1234
  "pin-project-lite",
1235
+ "rustls",
1346
1236
  "rustls-pemfile",
1347
1237
  "serde",
1348
1238
  "serde_json",
@@ -1350,15 +1240,30 @@ dependencies = [
1350
1240
  "sync_wrapper",
1351
1241
  "system-configuration",
1352
1242
  "tokio",
1353
- "tokio-native-tls",
1243
+ "tokio-rustls",
1354
1244
  "tower-service",
1355
1245
  "url",
1356
1246
  "wasm-bindgen",
1357
1247
  "wasm-bindgen-futures",
1358
1248
  "web-sys",
1249
+ "webpki-roots",
1359
1250
  "winreg",
1360
1251
  ]
1361
1252
 
1253
+ [[package]]
1254
+ name = "ring"
1255
+ version = "0.17.14"
1256
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1257
+ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1258
+ dependencies = [
1259
+ "cc",
1260
+ "cfg-if",
1261
+ "getrandom",
1262
+ "libc",
1263
+ "untrusted",
1264
+ "windows-sys 0.52.0",
1265
+ ]
1266
+
1362
1267
  [[package]]
1363
1268
  name = "rquickjs"
1364
1269
  version = "0.6.2"
@@ -1393,16 +1298,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1393
1298
  checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
1394
1299
 
1395
1300
  [[package]]
1396
- name = "rustix"
1397
- version = "1.1.3"
1301
+ name = "rustls"
1302
+ version = "0.21.12"
1398
1303
  source = "registry+https://github.com/rust-lang/crates.io-index"
1399
- checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
1304
+ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
1400
1305
  dependencies = [
1401
- "bitflags 2.10.0",
1402
- "errno",
1403
- "libc",
1404
- "linux-raw-sys",
1405
- "windows-sys 0.61.2",
1306
+ "log",
1307
+ "ring",
1308
+ "rustls-webpki",
1309
+ "sct",
1406
1310
  ]
1407
1311
 
1408
1312
  [[package]]
@@ -1414,6 +1318,16 @@ dependencies = [
1414
1318
  "base64",
1415
1319
  ]
1416
1320
 
1321
+ [[package]]
1322
+ name = "rustls-webpki"
1323
+ version = "0.101.7"
1324
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1325
+ checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
1326
+ dependencies = [
1327
+ "ring",
1328
+ "untrusted",
1329
+ ]
1330
+
1417
1331
  [[package]]
1418
1332
  name = "rustversion"
1419
1333
  version = "1.0.22"
@@ -1435,15 +1349,6 @@ dependencies = [
1435
1349
  "winapi-util",
1436
1350
  ]
1437
1351
 
1438
- [[package]]
1439
- name = "schannel"
1440
- version = "0.1.28"
1441
- source = "registry+https://github.com/rust-lang/crates.io-index"
1442
- checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
1443
- dependencies = [
1444
- "windows-sys 0.61.2",
1445
- ]
1446
-
1447
1352
  [[package]]
1448
1353
  name = "scoped-tls"
1449
1354
  version = "1.0.1"
@@ -1457,26 +1362,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1457
1362
  checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1458
1363
 
1459
1364
  [[package]]
1460
- name = "security-framework"
1461
- version = "2.11.1"
1462
- source = "registry+https://github.com/rust-lang/crates.io-index"
1463
- checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
1464
- dependencies = [
1465
- "bitflags 2.10.0",
1466
- "core-foundation",
1467
- "core-foundation-sys",
1468
- "libc",
1469
- "security-framework-sys",
1470
- ]
1471
-
1472
- [[package]]
1473
- name = "security-framework-sys"
1474
- version = "2.15.0"
1365
+ name = "sct"
1366
+ version = "0.7.1"
1475
1367
  source = "registry+https://github.com/rust-lang/crates.io-index"
1476
- checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
1368
+ checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
1477
1369
  dependencies = [
1478
- "core-foundation-sys",
1479
- "libc",
1370
+ "ring",
1371
+ "untrusted",
1480
1372
  ]
1481
1373
 
1482
1374
  [[package]]
@@ -1849,19 +1741,6 @@ dependencies = [
1849
1741
  "libc",
1850
1742
  ]
1851
1743
 
1852
- [[package]]
1853
- name = "tempfile"
1854
- version = "3.24.0"
1855
- source = "registry+https://github.com/rust-lang/crates.io-index"
1856
- checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
1857
- dependencies = [
1858
- "fastrand",
1859
- "getrandom",
1860
- "once_cell",
1861
- "rustix",
1862
- "windows-sys 0.61.2",
1863
- ]
1864
-
1865
1744
  [[package]]
1866
1745
  name = "tendril"
1867
1746
  version = "0.4.3"
@@ -1918,12 +1797,12 @@ dependencies = [
1918
1797
  ]
1919
1798
 
1920
1799
  [[package]]
1921
- name = "tokio-native-tls"
1922
- version = "0.3.1"
1800
+ name = "tokio-rustls"
1801
+ version = "0.24.1"
1923
1802
  source = "registry+https://github.com/rust-lang/crates.io-index"
1924
- checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
1803
+ checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
1925
1804
  dependencies = [
1926
- "native-tls",
1805
+ "rustls",
1927
1806
  "tokio",
1928
1807
  ]
1929
1808
 
@@ -2017,6 +1896,12 @@ version = "0.1.14"
2017
1896
  source = "registry+https://github.com/rust-lang/crates.io-index"
2018
1897
  checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
2019
1898
 
1899
+ [[package]]
1900
+ name = "untrusted"
1901
+ version = "0.9.0"
1902
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1903
+ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1904
+
2020
1905
  [[package]]
2021
1906
  name = "url"
2022
1907
  version = "2.5.8"
@@ -2047,12 +1932,6 @@ version = "0.2.2"
2047
1932
  source = "registry+https://github.com/rust-lang/crates.io-index"
2048
1933
  checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
2049
1934
 
2050
- [[package]]
2051
- name = "vcpkg"
2052
- version = "0.2.15"
2053
- source = "registry+https://github.com/rust-lang/crates.io-index"
2054
- checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
2055
-
2056
1935
  [[package]]
2057
1936
  name = "version_check"
2058
1937
  version = "0.9.5"
@@ -2061,7 +1940,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2061
1940
 
2062
1941
  [[package]]
2063
1942
  name = "vibe-compiler"
2064
- version = "1.7.0"
1943
+ version = "1.7.2"
2065
1944
  dependencies = [
2066
1945
  "clap",
2067
1946
  "colored",
@@ -2109,15 +1988,6 @@ version = "0.11.1+wasi-snapshot-preview1"
2109
1988
  source = "registry+https://github.com/rust-lang/crates.io-index"
2110
1989
  checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2111
1990
 
2112
- [[package]]
2113
- name = "wasip2"
2114
- version = "1.0.2+wasi-0.2.9"
2115
- source = "registry+https://github.com/rust-lang/crates.io-index"
2116
- checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
2117
- dependencies = [
2118
- "wit-bindgen",
2119
- ]
2120
-
2121
1991
  [[package]]
2122
1992
  name = "wasm-bindgen"
2123
1993
  version = "0.2.108"
@@ -2187,6 +2057,12 @@ dependencies = [
2187
2057
  "wasm-bindgen",
2188
2058
  ]
2189
2059
 
2060
+ [[package]]
2061
+ name = "webpki-roots"
2062
+ version = "0.25.4"
2063
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2064
+ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
2065
+
2190
2066
  [[package]]
2191
2067
  name = "winapi-util"
2192
2068
  version = "0.1.11"
@@ -2443,12 +2319,6 @@ dependencies = [
2443
2319
  "windows-sys 0.48.0",
2444
2320
  ]
2445
2321
 
2446
- [[package]]
2447
- name = "wit-bindgen"
2448
- version = "0.51.0"
2449
- source = "registry+https://github.com/rust-lang/crates.io-index"
2450
- checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
2451
-
2452
2322
  [[package]]
2453
2323
  name = "writeable"
2454
2324
  version = "0.6.2"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "1.7.0"
3
+ version = "1.7.2"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -20,7 +20,7 @@ markup5ever = "0.12"
20
20
  thiserror = "1.0"
21
21
  regex = "1.10"
22
22
  colored = "2.1"
23
- reqwest = { version = "0.11", features = ["blocking"] }
23
+ reqwest = { version = "0.11", features = ["blocking", "rustls-tls"], default-features = false }
24
24
  glob = "0.3"
25
25
  rquickjs = "0.6"
26
26
  swc_common = "=0.40.1"
@@ -488,17 +488,15 @@ impl Compiler {
488
488
  }
489
489
  }
490
490
 
491
- // Validate HTML syntax if requested (optional, controlled by validate flag)
492
- if self.config.validate {
493
- let validation_start = Instant::now();
494
- if self.verbose {
495
- println!("\nValidating HTML syntax...");
496
- }
497
- self.validate_html_syntax()?;
498
- stats.validation_time_ms = Some(validation_start.elapsed().as_secs_f64() * 1000.0);
499
- if self.verbose {
500
- println!(" HTML validation completed successfully");
501
- }
491
+ // Validate HTML syntax (always runs)
492
+ let validation_start = Instant::now();
493
+ if self.verbose {
494
+ println!("\nValidating HTML syntax...");
495
+ }
496
+ self.validate_html_syntax()?;
497
+ stats.validation_time_ms = Some(validation_start.elapsed().as_secs_f64() * 1000.0);
498
+ if self.verbose {
499
+ println!(" HTML validation completed successfully");
502
500
  }
503
501
 
504
502
  // Clean output directory if not in no-clean mode
@@ -644,6 +642,13 @@ impl Compiler {
644
642
  }
645
643
 
646
644
  for file_path in files {
645
+ // Validate HTML before compiling (same as full compile path)
646
+ let content = fs::read_to_string(file_path).map_err(|e| CompileError::ReadError {
647
+ path: file_path.display().to_string(),
648
+ source: e,
649
+ })?;
650
+ self.validate_html(&content, file_path)?;
651
+
647
652
  // Calculate relative path
648
653
  let relative_path = file_path
649
654
  .strip_prefix(&self.config.source)
@@ -807,29 +812,19 @@ impl Compiler {
807
812
  source_root: &Path,
808
813
  ) -> Result<(), String> {
809
814
  use crate::compiler::manifest_builder::ManifestBuilder;
810
- use crate::compiler::value_stamper::ValueStamper;
811
815
  use crate::compiler::component_tagger::ComponentTagger;
816
+ use crate::compiler::value_stamper::ValueStamper;
812
817
 
813
818
  // Tag components with deterministic IDs and structure state
814
819
  let tagged = ComponentTagger::tag_components(html, &source_root.to_path_buf())?;
815
820
  let html = &tagged.html; // Use modified HTML with data-vibe-component-id attributes
816
821
  let state = tagged.state;
817
822
 
818
- // Build manifest from ORIGINAL HTML (before stamping, so templates have @[...] markers)
823
+ // Build manifest from pre-stamp HTML (both conditional branches present, @[...] markers intact)
824
+ // This ensures restoration.template has both branches so runtime can switch between them
819
825
  let manifest_builder = ManifestBuilder::new();
820
826
  let manifest = manifest_builder.build_from_html(html, &state, iterations_as_is)?;
821
827
 
822
- // Stamp values (pre-render) AFTER building manifest
823
- // When components_as_is is true, skip stamping inside component elements (runtime will handle them)
824
- let stamper = ValueStamper::new(&state, components_as_is)?;
825
- let pre_rendered = stamper.stamp_html(html.to_string())?;
826
-
827
- // Note: FOUC removal happens in compile_html_file(), not here
828
-
829
- // Write pre-rendered HTML
830
- fs::write(html_path, pre_rendered)
831
- .map_err(|e| format!("Failed to write HTML: {}", e))?;
832
-
833
828
  // Write manifest
834
829
  let manifest_path = output_dir
835
830
  .join("vibe-hyperspeed")
@@ -853,6 +848,20 @@ impl Compiler {
853
848
  fs::write(&manifest_path, manifest_js)
854
849
  .map_err(|e| format!("Failed to write manifest: {}", e))?;
855
850
 
851
+ // Stamp the compiled HTML with initial state values for FOUC prevention:
852
+ // - @[...] bindings replaced with their initial values
853
+ // - Only the active conditional branch is kept (inactive branch stripped)
854
+ // - Iterations are pre-rendered with initial array data
855
+ // The manifest (written above) preserves BOTH branches so the runtime can
856
+ // restore them and switch between branches reactively.
857
+ let stamper = ValueStamper::new(&state, components_as_is)
858
+ .map_err(|e| format!("Failed to create value stamper: {}", e))?;
859
+ let stamped = stamper.stamp_html(html.to_string())
860
+ .map_err(|e| format!("Failed to stamp HTML: {}", e))?;
861
+
862
+ fs::write(html_path, stamped)
863
+ .map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
864
+
856
865
  Ok(())
857
866
  }
858
867
 
@@ -1097,11 +1106,6 @@ impl Compiler {
1097
1106
  source: e,
1098
1107
  })?;
1099
1108
 
1100
- // Validate if requested
1101
- if self.config.validate {
1102
- self.validate_html(&content, path)?;
1103
- }
1104
-
1105
1109
  // Count <component src="..."> instances before processing (only if not components_as_is)
1106
1110
  let (internal_count, external_count, component_srcs) = if !self.config.components_as_is {
1107
1111
  self.extract_components(&content)
@@ -1531,8 +1535,8 @@ impl Compiler {
1531
1535
  }
1532
1536
 
1533
1537
  fn validate_html(&self, content: &str, path: &Path) -> Result<(), CompileError> {
1538
+ // Check 1: Quote balance per line
1534
1539
  let mut line_num = 1;
1535
-
1536
1540
  for line in content.lines() {
1537
1541
  let quote_count = line.matches('"').count();
1538
1542
  if quote_count % 2 != 0 {
@@ -1545,9 +1549,77 @@ impl Compiler {
1545
1549
  line_num += 1;
1546
1550
  }
1547
1551
 
1552
+ // Check 2: Tag balance (unclosed elements break slot extraction)
1553
+ self.validate_tag_balance(content, path)
1554
+ }
1555
+
1556
+ fn validate_tag_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
1557
+ const VOID_ELEMENTS: &[&str] = &[
1558
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
1559
+ "link", "meta", "param", "source", "track", "wbr",
1560
+ ];
1561
+ // These are implicitly closed by the parser — don't require explicit close tags
1562
+ const IMPLICIT_CLOSE: &[&str] = &[
1563
+ "html", "head", "body", "p", "li", "dt", "dd", "option",
1564
+ "optgroup", "tr", "td", "th", "thead", "tbody", "tfoot", "colgroup",
1565
+ ];
1566
+
1567
+ // Strip comments, raw content blocks, and @[...] bindings before tag scanning
1568
+ let stripped = Self::strip_for_tag_validation(content);
1569
+
1570
+ // Match any tag: opening, self-closing, or closing
1571
+ let tag_re = Regex::new(r"</?([a-zA-Z][a-zA-Z0-9-]*)(?:\s[^>]*)?>").unwrap();
1572
+
1573
+ // Count opens vs closes per tag name; track line of first open for error reporting
1574
+ let mut counts: HashMap<String, (i32, usize)> = HashMap::new();
1575
+ for cap in tag_re.captures_iter(&stripped) {
1576
+ let full = cap.get(0).unwrap().as_str();
1577
+ let name = cap[1].to_lowercase();
1578
+ if VOID_ELEMENTS.contains(&name.as_str()) || IMPLICIT_CLOSE.contains(&name.as_str()) {
1579
+ continue;
1580
+ }
1581
+ if full.starts_with("</") {
1582
+ counts.entry(name).and_modify(|(c, _)| *c -= 1);
1583
+ } else if full.ends_with("/>") {
1584
+ // self-closing — no balance change
1585
+ } else {
1586
+ let offset = cap.get(0).unwrap().start();
1587
+ let line = stripped[..offset].matches('\n').count() + 1;
1588
+ let entry = counts.entry(name).or_insert((0, line));
1589
+ entry.0 += 1;
1590
+ }
1591
+ }
1592
+
1593
+ // Report the first unclosed tag (sorted by line for deterministic output)
1594
+ let mut unclosed: Vec<(String, usize)> = counts.into_iter()
1595
+ .filter(|(_, (count, _))| *count > 0)
1596
+ .map(|(name, (_, line))| (name, line))
1597
+ .collect();
1598
+ unclosed.sort_by_key(|(_, line)| *line);
1599
+
1600
+ if let Some((name, line)) = unclosed.first() {
1601
+ return Err(CompileError::ValidationError {
1602
+ file: path.display().to_string(),
1603
+ line: *line,
1604
+ message: format!("Unclosed tag <{}>", name),
1605
+ });
1606
+ }
1607
+
1548
1608
  Ok(())
1549
1609
  }
1550
1610
 
1611
+ fn strip_for_tag_validation(content: &str) -> String {
1612
+ // Strip HTML comments <!-- ... --> (includes Vibe syntax: each, if, else, /if, /each)
1613
+ let comment_re = Regex::new(r"(?s)<!--.*?-->").unwrap();
1614
+ let s = comment_re.replace_all(content, "");
1615
+ // Strip @[...] reactive bindings (can contain < and > characters)
1616
+ let binding_re = Regex::new(r"@\[[^\]]*\]").unwrap();
1617
+ let s = binding_re.replace_all(&s, "");
1618
+ // Strip content inside <script>, <style>, <pre> to avoid parsing embedded code as HTML
1619
+ let raw_re = Regex::new(r"(?si)(<(?:script|style|pre)(?:\s[^>]*)?>).*?(</(?:script|style|pre)>)").unwrap();
1620
+ raw_re.replace_all(&s, "$1$2").into_owned()
1621
+ }
1622
+
1551
1623
  /// Copy an entire directory recursively
1552
1624
  fn copy_directory(
1553
1625
  &mut self,
@@ -127,46 +127,55 @@ impl ComponentTagger {
127
127
  if is_component || is_div_component {
128
128
  drop(borrowed_attrs); // Release borrow before checking innerHTML
129
129
 
130
- // First check if this component has state-registering scripts
131
- // Serialize this node's children to get innerHTML
132
- let mut inner_html_bytes = Vec::new();
130
+ // Only extract state from DIRECT <script> children — not the full subtree.
131
+ // Using the full subtree caused the Layout wrapper (a <component> created by
132
+ // inline_component_elements that contains all nested component scripts) to be
133
+ // treated as a stateful component with the merged state of all its descendants.
134
+ // That made rewrite_this_to_component_id rewrite every @[this.xxx] in the page
135
+ // to @[_c0.xxx] before child components could claim their own bindings.
136
+ let mut direct_scripts_html = String::new();
133
137
  for child in node.children.borrow().iter() {
134
- let _ = serialize(
135
- &mut inner_html_bytes,
136
- &SerializableHandle::from(child.clone()),
137
- SerializeOpts::default()
138
- );
138
+ if let NodeData::Element { name: ref child_name, .. } = child.data {
139
+ if child_name.local.as_ref() == "script" {
140
+ let mut script_bytes = Vec::new();
141
+ let _ = serialize(
142
+ &mut script_bytes,
143
+ &SerializableHandle::from(child.clone()),
144
+ SerializeOpts::default()
145
+ );
146
+ if let Ok(script_str) = String::from_utf8(script_bytes) {
147
+ direct_scripts_html.push_str(&script_str);
148
+ }
149
+ }
150
+ }
139
151
  }
140
152
 
141
- if let Ok(inner_html) = String::from_utf8(inner_html_bytes) {
142
- // Extract state from component's innerHTML
143
- if let Ok(Value::Object(comp_state)) = StateExtractor::extract_from_html(
144
- &inner_html,
145
- &PathBuf::from(".")
146
- ) {
147
- // Only tag and register components that have state
148
- if !comp_state.is_empty() {
149
- let component_id = format!("_c{}", *counter.borrow());
150
- *counter.borrow_mut() += 1;
151
-
152
- // Add data-vibe-component-id attribute
153
- let mut attrs_mut = attrs.borrow_mut();
154
- attrs_mut.push(markup5ever::Attribute {
155
- name: QualName::new(
156
- None,
157
- Namespace::from(""),
158
- LocalName::from("data-vibe-component-id"),
159
- ),
160
- value: component_id.clone().into(),
161
- });
162
- drop(attrs_mut);
163
-
164
- // Rewrite this.property to componentId.property in the node's children
165
- // This allows ValueStamper to properly evaluate component-scoped expressions
166
- Self::rewrite_this_to_component_id(node, &component_id);
167
-
168
- component_states.borrow_mut().push((component_id, Value::Object(comp_state)));
169
- }
153
+ if let Ok(Value::Object(comp_state)) = StateExtractor::extract_from_html(
154
+ &direct_scripts_html,
155
+ &PathBuf::from(".")
156
+ ) {
157
+ // Only tag and register components that have state
158
+ if !comp_state.is_empty() {
159
+ let component_id = format!("_c{}", *counter.borrow());
160
+ *counter.borrow_mut() += 1;
161
+
162
+ // Add data-vibe-component-id attribute
163
+ let mut attrs_mut = attrs.borrow_mut();
164
+ attrs_mut.push(markup5ever::Attribute {
165
+ name: QualName::new(
166
+ None,
167
+ Namespace::from(""),
168
+ LocalName::from("data-vibe-component-id"),
169
+ ),
170
+ value: component_id.clone().into(),
171
+ });
172
+ drop(attrs_mut);
173
+
174
+ // Rewrite this.property to componentId.property in the node's children
175
+ // This allows ValueStamper to properly evaluate component-scoped expressions
176
+ Self::rewrite_this_to_component_id(node, &component_id);
177
+
178
+ component_states.borrow_mut().push((component_id, Value::Object(comp_state)));
170
179
  }
171
180
  }
172
181
  }
@@ -31,8 +31,6 @@ pub struct VibeCompilerConfig {
31
31
  #[serde(default)]
32
32
  pub elements_as_is: bool,
33
33
  #[serde(default)]
34
- pub validate: bool,
35
- #[serde(default)]
36
34
  pub source_maps: bool,
37
35
  #[serde(default)]
38
36
  pub reserved_elements: Vec<String>,
@@ -96,7 +94,6 @@ impl Default for VibeCompilerConfig {
96
94
  root: None,
97
95
  minify: false,
98
96
  elements_as_is: false,
99
- validate: false,
100
97
  source_maps: false,
101
98
  reserved_elements: vec![],
102
99
  node_modules_as_is: false,
@@ -121,7 +118,6 @@ pub struct Config {
121
118
  pub _root: Option<String>,
122
119
  pub minify: bool,
123
120
  pub elements_as_is: bool,
124
- pub validate: bool,
125
121
  pub source_maps: bool,
126
122
  pub reserved_elements: Vec<String>,
127
123
  pub node_modules_as_is: bool,
@@ -169,7 +165,6 @@ impl Config {
169
165
  _root: config.root,
170
166
  minify: config.minify,
171
167
  elements_as_is: config.elements_as_is,
172
- validate: config.validate,
173
168
  source_maps: config.source_maps,
174
169
  reserved_elements,
175
170
  node_modules_as_is: config.node_modules_as_is,
@@ -14,7 +14,6 @@ use compiler::Compiler;
14
14
  struct ConfigOverrides {
15
15
  minify: bool,
16
16
  elements_as_is: bool,
17
- validate: bool,
18
17
  source_maps: bool,
19
18
  node_modules_as_is: bool,
20
19
  components_as_is: bool,
@@ -53,10 +52,6 @@ struct Args {
53
52
  #[arg(long, name = "source-maps")]
54
53
  source_maps: bool,
55
54
 
56
- /// Validate HTML syntax
57
- #[arg(long)]
58
- validate: bool,
59
-
60
55
  /// Initialize configuration in package.json
61
56
  #[arg(long)]
62
57
  init: bool,
@@ -127,7 +122,6 @@ fn main() {
127
122
  // Store original config values before applying flag overrides
128
123
  let original_minify = config.minify;
129
124
  let original_elements_as_is = config.elements_as_is;
130
- let original_validate = config.validate;
131
125
  let original_source_maps = config.source_maps;
132
126
  let original_node_modules_as_is = config.node_modules_as_is;
133
127
  let original_components_as_is = config.components_as_is;
@@ -145,10 +139,6 @@ fn main() {
145
139
  overrides.elements_as_is = !original_elements_as_is;
146
140
  config.elements_as_is = true;
147
141
  }
148
- if args.validate {
149
- overrides.validate = !original_validate;
150
- config.validate = true;
151
- }
152
142
  if args.source_maps {
153
143
  overrides.source_maps = !original_source_maps;
154
144
  config.source_maps = true;
@@ -229,7 +219,6 @@ fn main() {
229
219
  println!(" {}: {}", format!("{:<16}", "runtimeAsIs").cyan(), format_bool_with_flag(config.runtime_as_is, overrides.runtime_as_is, original_runtime_as_is));
230
220
  println!(" {}: {}", format!("{:<16}", "source").cyan(), format_value_no_flag(&config._source_str));
231
221
  println!(" {}: {}", format!("{:<16}", "sourceMaps").cyan(), format_bool_with_flag(config.source_maps, overrides.source_maps, original_source_maps));
232
- println!(" {}: {}", format!("{:<16}", "validate").cyan(), format_bool_with_flag(config.validate, overrides.validate, original_validate));
233
222
  println!();
234
223
  } else {
235
224
  // Show version in non-verbose mode
@@ -255,6 +255,65 @@ impl HtmlParser {
255
255
  /// Recursively inline all <component> elements with their HTML content
256
256
  /// Inline a single component (one pass, no looping)
257
257
  /// Used during recursive component fetching to resolve nested components
258
+ /// Find the start position of the matching close tag, handling nesting of the same tag.
259
+ /// `after_open`: byte position immediately after the `>` of the opening tag.
260
+ fn find_matching_close(content: &str, after_open: usize, tag_name: &str) -> Option<usize> {
261
+ // (?:\s|>) ensures <component> matches but not <component-foo> (hyphen is not \s or >)
262
+ let open_re = regex::Regex::new(&format!(r"<{}(?:\s|>|/>)", regex::escape(tag_name))).unwrap();
263
+ let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(tag_name))).unwrap();
264
+
265
+ let mut depth = 1i32;
266
+ let mut cursor = after_open;
267
+
268
+ while cursor < content.len() {
269
+ let slice = &content[cursor..];
270
+ let next_open = open_re.find(slice).map(|m| (m.start(), m.end()));
271
+ let next_close = close_re.find(slice).map(|m| (m.start(), m.end()));
272
+
273
+ match (next_open, next_close) {
274
+ (None, None) => return None,
275
+ (None, Some((cs, ce))) => {
276
+ depth -= 1;
277
+ if depth == 0 { return Some(cursor + cs); }
278
+ cursor += ce;
279
+ }
280
+ (Some((os, oe)), None) => {
281
+ // Check if self-closing by finding the end of this tag
282
+ if let Some(end) = slice[os..].find('>') {
283
+ if slice[os..os + end + 1].ends_with("/>") {
284
+ cursor += os + end + 1;
285
+ } else {
286
+ depth += 1;
287
+ cursor += oe;
288
+ }
289
+ } else {
290
+ return None;
291
+ }
292
+ }
293
+ (Some((os, oe)), Some((cs, ce))) => {
294
+ if os < cs {
295
+ if let Some(end) = slice[os..].find('>') {
296
+ if slice[os..os + end + 1].ends_with("/>") {
297
+ cursor += os + end + 1;
298
+ } else {
299
+ depth += 1;
300
+ cursor += oe;
301
+ }
302
+ } else {
303
+ return None;
304
+ }
305
+ } else {
306
+ depth -= 1;
307
+ if depth == 0 { return Some(cursor + cs); }
308
+ cursor += ce;
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ None
315
+ }
316
+
258
317
  pub fn inline_single_component(&self, content: &str, component_src: &str, component_content: &str) -> String {
259
318
  let mut result = content.to_string();
260
319
 
@@ -270,55 +329,53 @@ impl HtmlParser {
270
329
  }
271
330
  };
272
331
 
273
- // Match <component src="..." attrs...>children</component>
274
- // OR <div class="component" src="..." attrs...>children</div>
275
- let pattern = format!(r#"(?s)<(component|div)\s+([^>]*)\bsrc="{}"\s*([^>]*)>(.*?)</(component|div)>"#, regex::escape(&normalized_src));
276
- let component_re = regex::Regex::new(&pattern).unwrap();
332
+ // Match only the opening tag — slot content is extracted via depth-counting close search
333
+ // to correctly handle slot content that contains </div> or nested <component> elements.
334
+ let pattern = format!(r#"<(component|div)\s+([^>]*)\bsrc="{}"\s*([^>]*)>"#, regex::escape(&normalized_src));
335
+ let open_re = regex::Regex::new(&pattern).unwrap();
277
336
 
278
- let matches: Vec<_> = component_re.captures_iter(&result).map(|cap| {
279
- let full_match = cap.get(0).unwrap();
337
+ let matches: Vec<_> = open_re.captures_iter(&result).filter_map(|cap| {
338
+ let open_tag = cap.get(0).unwrap();
280
339
  let tag_name = cap.get(1).unwrap().as_str();
281
340
  let attrs_before = cap.get(2).map(|m| m.as_str()).unwrap_or("");
282
341
  let attrs_after = cap.get(3).map(|m| m.as_str()).unwrap_or("");
283
- let slot_content = cap.get(4).map(|m| m.as_str().to_string());
284
342
 
285
343
  // For <div>, verify it has class="component"
286
344
  if tag_name == "div" {
287
345
  let combined_attrs = format!("{} {}", attrs_before, attrs_after);
288
346
  if !combined_attrs.contains("class=") || !combined_attrs.contains("component") {
289
- return None; // Not a component div, skip
347
+ return None;
290
348
  }
291
349
  }
292
350
 
293
- // Combine all attributes
351
+ let open_end = open_tag.end();
352
+ let close_start = Self::find_matching_close(&result, open_end, tag_name)?;
353
+ let close_end = close_start + format!("</{}>", tag_name).len();
354
+ let slot_content = result[open_end..close_start].to_string();
355
+
294
356
  let attrs_str = format!("{} {}", attrs_before, attrs_after);
295
357
  let props = Self::parse_props(&attrs_str);
296
358
 
297
- Some((full_match.start(), full_match.end(), props, slot_content))
298
- }).flatten().collect();
359
+ Some((open_tag.start(), close_end, props, slot_content))
360
+ }).collect();
361
+
362
+ // Replace from end to start (sorted descending by start position)
363
+ let mut sorted = matches;
364
+ sorted.sort_by(|a, b| b.0.cmp(&a.0));
299
365
 
300
- // Replace from end to start
301
- for (start, end, props, slot_content) in matches.iter().rev() {
366
+ for (start, end, props, slot_content) in &sorted {
302
367
  let mut replacement = component_content.to_string();
303
368
 
304
- // Replace props
305
369
  for (prop_name, prop_value) in props {
306
370
  let prop_binding = format!("@[{}]", prop_name);
307
371
  replacement = replacement.replace(&prop_binding, prop_value);
308
372
  }
309
373
 
310
- // Replace slots
311
- let slot_replacement = slot_content
312
- .as_ref()
313
- .filter(|s| !s.trim().is_empty())
314
- .map(|s| s.as_str())
315
- .unwrap_or("");
316
-
374
+ let slot_replacement = if slot_content.trim().is_empty() { "" } else { slot_content.as_str() };
317
375
  replacement = replacement.replace("<slot></slot>", slot_replacement);
318
376
  replacement = replacement.replace("<slot/>", slot_replacement);
319
377
  replacement = replacement.replace("<slot />", slot_replacement);
320
378
 
321
- // Keep the <component> wrapper
322
379
  let wrapper = format!("<component>{}</component>", replacement);
323
380
  result.replace_range(*start..*end, &wrapper);
324
381
  }
@@ -329,73 +386,81 @@ impl HtmlParser {
329
386
  fn inline_component_elements(&self, content: &str, external_cache: &HashMap<String, String>) -> String {
330
387
  let mut result = content.to_string();
331
388
 
332
- // Single pass - all components in cache are already fully resolved
333
- // Match <component src="..." attrs...>children</component>
334
- // OR <div class="component" src="..." attrs...>children</div> (after accessibility transformation)
335
- // Both signatures require src attribute (wrappers without src are already processed)
336
- let component_re = regex::Regex::new(
337
- r#"(?s)<(component|div)\s+([^>]*)\bsrc="([^"]+)"([^>]*)>(.*?)</(component|div)>"#
389
+ // Match only the opening component tag slot content is extracted via depth-counting
390
+ // to correctly handle slot content containing </div> or nested <component> elements.
391
+ let open_re = regex::Regex::new(
392
+ r#"<(component|div)\s+([^>]*)\bsrc="([^"]+)"([^>]*)>"#
338
393
  ).unwrap();
339
394
 
340
- let matches: Vec<_> = component_re.captures_iter(&result).map(|cap| {
341
- let full_match = cap.get(0).unwrap();
342
- let tag_name = cap.get(1).unwrap().as_str();
343
- let attrs_before = cap.get(2).map(|m| m.as_str()).unwrap_or("");
344
- let src = cap.get(3).unwrap().as_str();
345
- let attrs_after = cap.get(4).map(|m| m.as_str()).unwrap_or("");
346
- let slot_content = cap.get(5).map(|m| m.as_str().to_string());
347
-
348
- // For <div>, verify it has class="component" (or class="component other-classes")
349
- if tag_name == "div" {
350
- let combined_attrs = format!("{} {}", attrs_before, attrs_after);
351
- if !combined_attrs.contains("class=") || !combined_attrs.contains("component") {
352
- return None; // Not a component div, skip
395
+ // Process one match at a time with re-scanning after each replacement.
396
+ // This is required for nested components (e.g. Layout.html wrapping inner components):
397
+ // collecting all byte positions at once and applying them in reverse fails because
398
+ // replacing an inner component changes the string length, invalidating the outer
399
+ // component's end byte position and causing a char boundary panic.
400
+ loop {
401
+ let matches: Vec<_> = open_re.captures_iter(&result).filter_map(|cap| {
402
+ let open_tag = cap.get(0).unwrap();
403
+ let tag_name = cap.get(1).unwrap().as_str();
404
+ let attrs_before = cap.get(2).map(|m| m.as_str()).unwrap_or("");
405
+ let src = cap.get(3).unwrap().as_str();
406
+ let attrs_after = cap.get(4).map(|m| m.as_str()).unwrap_or("");
407
+
408
+ // For <div>, verify it has class="component"
409
+ if tag_name == "div" {
410
+ let combined_attrs = format!("{} {}", attrs_before, attrs_after);
411
+ if !combined_attrs.contains("class=") || !combined_attrs.contains("component") {
412
+ return None;
413
+ }
353
414
  }
354
- }
355
415
 
356
- // Combine all attributes (excluding src which we already extracted)
357
- let attrs_str = format!("{} {}", attrs_before, attrs_after);
358
- let props = Self::parse_props(&attrs_str);
416
+ let open_end = open_tag.end();
417
+ let close_start = Self::find_matching_close(&result, open_end, tag_name)?;
418
+ let close_end = close_start + format!("</{}>", tag_name).len();
419
+ let slot_content = result[open_end..close_start].to_string();
359
420
 
360
- Some((full_match.start(), full_match.end(), src.to_string(), props, slot_content))
361
- }).flatten().collect();
421
+ let attrs_str = format!("{} {}", attrs_before, attrs_after);
422
+ let props = Self::parse_props(&attrs_str);
362
423
 
363
- // Replace from end to start
364
- for (start, end, src, props, slot_content) in matches.iter().rev() {
365
- // Normalize path
366
- let normalized_src = if src.starts_with("http://") || src.starts_with("https://") {
367
- src.to_string()
368
- } else {
369
- let without_prefix = src.trim_start_matches("./");
370
- if without_prefix.starts_with('/') {
371
- without_prefix.to_string()
424
+ Some((open_tag.start(), close_end, src.to_string(), props, slot_content))
425
+ }).collect();
426
+
427
+ // Pick the rightmost match that has a cached component and apply it, then re-scan.
428
+ let mut sorted = matches;
429
+ sorted.sort_by(|a, b| b.0.cmp(&a.0));
430
+
431
+ let mut applied = false;
432
+ for (start, end, src, props, slot_content) in sorted {
433
+ let normalized_src = if src.starts_with("http://") || src.starts_with("https://") {
434
+ src.to_string()
372
435
  } else {
373
- format!("/{}", without_prefix)
374
- }
375
- };
376
-
377
- // Get fully resolved component from cache
378
- if let Some(mut replacement) = external_cache.get(&normalized_src).cloned() {
379
- // Replace props
380
- for (prop_name, prop_value) in props {
381
- let prop_binding = format!("@[{}]", prop_name);
382
- replacement = replacement.replace(&prop_binding, prop_value);
383
- }
436
+ let without_prefix = src.trim_start_matches("./");
437
+ if without_prefix.starts_with('/') {
438
+ without_prefix.to_string()
439
+ } else {
440
+ format!("/{}", without_prefix)
441
+ }
442
+ };
384
443
 
385
- // Replace slots
386
- let slot_replacement = slot_content
387
- .as_ref()
388
- .filter(|s| !s.trim().is_empty())
389
- .map(|s| s.as_str())
390
- .unwrap_or("");
444
+ if let Some(mut replacement) = external_cache.get(&normalized_src).cloned() {
445
+ for (prop_name, prop_value) in &props {
446
+ let prop_binding = format!("@[{}]", prop_name);
447
+ replacement = replacement.replace(&prop_binding, prop_value);
448
+ }
449
+
450
+ let slot_replacement = if slot_content.trim().is_empty() { "" } else { slot_content.as_str() };
451
+ replacement = replacement.replace("<slot></slot>", slot_replacement);
452
+ replacement = replacement.replace("<slot/>", slot_replacement);
453
+ replacement = replacement.replace("<slot />", slot_replacement);
391
454
 
392
- replacement = replacement.replace("<slot></slot>", slot_replacement);
393
- replacement = replacement.replace("<slot/>", slot_replacement);
394
- replacement = replacement.replace("<slot />", slot_replacement);
455
+ let wrapper = format!("<component>{}</component>", replacement);
456
+ result.replace_range(start..end, &wrapper);
457
+ applied = true;
458
+ break;
459
+ }
460
+ }
395
461
 
396
- // Keep the <component> wrapper (without src attribute)
397
- let wrapper = format!("<component>{}</component>", replacement);
398
- result.replace_range(*start..*end, &wrapper);
462
+ if !applied {
463
+ break;
399
464
  }
400
465
  }
401
466
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -60,8 +60,10 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
60
60
 
61
61
  const src = el.getAttribute('src');
62
62
 
63
- // Capture children and props before fetching
64
- const children = el.innerHTML.trim();
63
+ // Use pre-hydration slot content if available (saved by index.js before hydration ran),
64
+ // otherwise fall back to current innerHTML (e.g. runtime-only usage without boot).
65
+ const children = (el._vibeSlotContent !== undefined ? el._vibeSlotContent : el.innerHTML).trim();
66
+ delete el._vibeSlotContent;
65
67
  const props = {};
66
68
  Array.from(el.attributes).forEach((attr) => {
67
69
  if (attr.name !== 'src') {
package/runtime/index.js CHANGED
@@ -454,6 +454,13 @@ const main = (s, config = {}, stringSelector = '') => {
454
454
  restoreMarkersFromManifest(rootElement, cloneForRestoration, hyperspeedTree);
455
455
  }
456
456
 
457
+ // Save raw slot content of fetched components before hydration replaces @[...] markers.
458
+ // component.js captures el.innerHTML when resolving — if hydration already ran, the
459
+ // binding syntax is gone and the resolved component won't be reactive.
460
+ rootElement.querySelectorAll('component[src], div.component[src]').forEach(el => {
461
+ el._vibeSlotContent = el.innerHTML;
462
+ });
463
+
457
464
  // Runtime parses DOM (which now has restored markers if hyperspeed was used)
458
465
  let parsedTree = parse(rootElement);
459
466
 
@@ -467,8 +467,12 @@ export const restoreMarkersFromManifest = (
467
467
 
468
468
  if (text.startsWith("if")) {
469
469
  if (!startComment) {
470
- startComment = comment;
471
- conditionalDepth = 1;
470
+ const expression = childTree.meta?.expression;
471
+ const expectedText = expression ? 'if ' + expression : null;
472
+ if (expectedText && text === expectedText) {
473
+ startComment = comment;
474
+ conditionalDepth = 1;
475
+ }
472
476
  } else {
473
477
  // Track nested conditionals
474
478
  conditionalDepth++;