@ape-egg/vibe 2.0.0 → 2.0.5

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.
@@ -395,19 +395,15 @@ impl ManifestBuilder {
395
395
  }
396
396
  }
397
397
 
398
- // Extract template HTML between start and end
398
+ // Extract template HTML between start and end — VERBATIM, including
399
+ // whitespace-only text nodes. The manifest's child keys are childNodes
400
+ // indices computed on the pre-stamp DOM; restoration re-inserts this
401
+ // template, and only an exact node-count round-trip keeps those
402
+ // indices valid for every sibling that follows the restored region.
399
403
  if let Some(end) = end_idx {
400
404
  let template_nodes: Vec<_> = siblings.iter()
401
405
  .skip(start_idx + 1)
402
406
  .take(end - start_idx - 1)
403
- .filter(|node| {
404
- // Skip whitespace-only text nodes
405
- if let NodeData::Text { contents } = &node.data {
406
- !contents.borrow().trim().is_empty()
407
- } else {
408
- true // Keep all non-text nodes
409
- }
410
- })
411
407
  .collect();
412
408
 
413
409
  return self.serialize_nodes(&template_nodes);
@@ -195,7 +195,7 @@ impl HtmlParser {
195
195
  let closing_pattern = format!(r"</{}>", regex::escape(tag_name));
196
196
 
197
197
  // Find all occurrences
198
- let mut matches: Vec<(usize, usize, String, HashMap<String, String>)> = Vec::new();
198
+ let mut matches: Vec<(usize, usize, String, Vec<(String, String)>)> = Vec::new();
199
199
 
200
200
  // Find opening tags
201
201
  for cap in tag_re.find_iter(&result) {
@@ -228,14 +228,9 @@ impl HtmlParser {
228
228
  // Replace from end to start to maintain indices
229
229
  matches.reverse();
230
230
  for (start, end, slot_content, props) in matches {
231
- let mut replacement = element.content.clone();
232
-
233
- // Replace props: for each prop like headline="@[pageTitle]",
234
- // replace @[headline] in content with @[pageTitle]
235
- for (prop_name, prop_value) in props {
236
- let prop_binding = format!("@[{}]", prop_name);
237
- replacement = replacement.replace(&prop_binding, &prop_value);
238
- }
231
+ // Substitute props with full runtime parity (bindings,
232
+ // expressions, directive comments, event handlers)
233
+ let mut replacement = substitute_props(&element.content, &props);
239
234
 
240
235
  // Replace <slot> tags — wrap children in <slot> boundary, or remove if empty
241
236
  let slot_wrapped = if slot_content.trim().is_empty() {
@@ -360,7 +355,10 @@ impl HtmlParser {
360
355
  let slot_content = result[open_end..close_start].to_string();
361
356
 
362
357
  let attrs_str = format!("{} {}", attrs_before, attrs_after);
363
- let props = Self::parse_props(&attrs_str);
358
+ let props: Vec<(String, String)> = Self::parse_props(&attrs_str)
359
+ .into_iter()
360
+ .filter(|(n, _)| n != "src" && n != "class")
361
+ .collect();
364
362
 
365
363
  Some((open_tag.start(), close_end, props, slot_content))
366
364
  }).collect();
@@ -370,12 +368,8 @@ impl HtmlParser {
370
368
  sorted.sort_by(|a, b| b.0.cmp(&a.0));
371
369
 
372
370
  for (start, end, props, slot_content) in &sorted {
373
- let mut replacement = component_content.to_string();
374
-
375
- for (prop_name, prop_value) in props {
376
- let prop_binding = format!("@[{}]", prop_name);
377
- replacement = replacement.replace(&prop_binding, prop_value);
378
- }
371
+ let mut replacement = substitute_props(component_content, props);
372
+ replacement = neuter_component_scripts(&replacement);
379
373
 
380
374
  let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
381
375
  replacement = replacement.replace("<slot></slot>", &slot_wrapped);
@@ -425,7 +419,10 @@ impl HtmlParser {
425
419
  let slot_content = result[open_end..close_start].to_string();
426
420
 
427
421
  let attrs_str = format!("{} {}", attrs_before, attrs_after);
428
- let props = Self::parse_props(&attrs_str);
422
+ let props: Vec<(String, String)> = Self::parse_props(&attrs_str)
423
+ .into_iter()
424
+ .filter(|(n, _)| n != "src" && n != "class")
425
+ .collect();
429
426
 
430
427
  Some((open_tag.start(), close_end, src.to_string(), props, slot_content))
431
428
  }).collect();
@@ -447,11 +444,9 @@ impl HtmlParser {
447
444
  }
448
445
  };
449
446
 
450
- if let Some(mut replacement) = external_cache.get(&normalized_src).cloned() {
451
- for (prop_name, prop_value) in &props {
452
- let prop_binding = format!("@[{}]", prop_name);
453
- replacement = replacement.replace(&prop_binding, prop_value);
454
- }
447
+ if let Some(template) = external_cache.get(&normalized_src) {
448
+ let mut replacement = substitute_props(template, &props);
449
+ replacement = neuter_component_scripts(&replacement);
455
450
 
456
451
  let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
457
452
  replacement = replacement.replace("<slot></slot>", &slot_wrapped);
@@ -473,24 +468,192 @@ impl HtmlParser {
473
468
  result
474
469
  }
475
470
 
476
- /// Parse component props from attributes string
477
- /// Example: ` headline="@[pageTitle]" theme="dark"` -> {"headline": "@[pageTitle]", "theme": "dark"}
478
- fn parse_props(attrs_str: &str) -> HashMap<String, String> {
479
- let mut props = HashMap::new();
471
+ /// Parse component props from attributes string, in source order.
472
+ /// Supports valued (`headline="@[pageTitle]"`) and bare boolean
473
+ /// (`dndDisabled`) attributes the runtime receives bare attributes from
474
+ /// the DOM with an empty-string value, so they're captured the same here.
475
+ fn parse_props(attrs_str: &str) -> Vec<(String, String)> {
476
+ let mut props = Vec::new();
480
477
 
481
- // Match attribute="value" pairs
482
- let attr_re = regex::Regex::new(r#"(\w+)="([^"]*)""#).unwrap();
478
+ let attr_re = regex::Regex::new(r#"([\w-]+)(?:="([^"]*)")?"#).unwrap();
483
479
 
484
480
  for cap in attr_re.captures_iter(attrs_str) {
485
- if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) {
486
- props.insert(name.as_str().to_string(), value.as_str().to_string());
487
- }
481
+ let name = cap.get(1).unwrap().as_str().to_string();
482
+ let value = cap.get(2).map(|m| m.as_str().to_string()).unwrap_or_default();
483
+ props.push((name, value));
488
484
  }
489
485
 
490
486
  props
491
487
  }
492
488
  }
493
489
 
490
+ /// Neuter component scripts inlined from `<component src>` templates:
491
+ /// `<script type="module">` becomes `<script type="vibe-module">` so the
492
+ /// browser does NOT execute it as a native page module (wrong timing —
493
+ /// pre-boot, placeholder `$`, no live state merge). The runtime executes
494
+ /// vibe-module scripts through the same injected-component() path it uses
495
+ /// for fetched component scripts, giving compiled and runtime pages one
496
+ /// script-execution pipeline with identical semantics. Runs before slot
497
+ /// inlining so caller-authored slot scripts stay native.
498
+ fn neuter_component_scripts(html: &str) -> String {
499
+ html.replace("<script type=\"module\"", "<script type=\"vibe-module\"")
500
+ }
501
+
502
+ fn is_ident_byte(b: u8) -> bool {
503
+ b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
504
+ }
505
+
506
+ /// Case-insensitive free-identifier substitution, mirroring the runtime's
507
+ /// renderPropsAndSlot idRegex: the name must not be preceded by an identifier
508
+ /// character or `.` (property access) and not followed by an identifier
509
+ /// character. Case-insensitive because HTML lowercases attribute names while
510
+ /// component templates reference the author's camelCase identifiers.
511
+ fn substitute_identifier(expr: &str, name: &str, replacement: &str) -> String {
512
+ let bytes = expr.as_bytes();
513
+ let nb = name.as_bytes();
514
+ let nlen = nb.len();
515
+ if nlen == 0 {
516
+ return expr.to_string();
517
+ }
518
+ let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
519
+ let mut i = 0;
520
+ while i < bytes.len() {
521
+ if i + nlen <= bytes.len()
522
+ && bytes[i..i + nlen].eq_ignore_ascii_case(nb)
523
+ && (i == 0 || (!is_ident_byte(bytes[i - 1]) && bytes[i - 1] != b'.'))
524
+ && (i + nlen == bytes.len() || !is_ident_byte(bytes[i + nlen]))
525
+ {
526
+ out.extend_from_slice(replacement.as_bytes());
527
+ i += nlen;
528
+ } else {
529
+ out.push(bytes[i]);
530
+ i += 1;
531
+ }
532
+ }
533
+ String::from_utf8(out).unwrap()
534
+ }
535
+
536
+ /// `$.propName` rewrites inside event-handler bodies (the runtime's stateRegex
537
+ /// pass): for a binding prop `value="@[email]"`, `$.value = this.value`
538
+ /// becomes `$.email = this.value` — DOM property reads (`this.value`) stay.
539
+ fn substitute_state_ref(body: &str, name: &str, replacement: &str) -> String {
540
+ let bytes = body.as_bytes();
541
+ let nb = name.as_bytes();
542
+ let nlen = nb.len();
543
+ if nlen == 0 {
544
+ return body.to_string();
545
+ }
546
+ let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
547
+ let mut i = 0;
548
+ while i < bytes.len() {
549
+ if bytes[i] == b'$'
550
+ && i + 2 + nlen <= bytes.len()
551
+ && bytes[i + 1] == b'.'
552
+ && bytes[i + 2..i + 2 + nlen].eq_ignore_ascii_case(nb)
553
+ && (i + 2 + nlen == bytes.len() || !is_ident_byte(bytes[i + 2 + nlen]))
554
+ {
555
+ out.extend_from_slice(b"$.");
556
+ out.extend_from_slice(replacement.as_bytes());
557
+ i += 2 + nlen;
558
+ } else {
559
+ out.push(bytes[i]);
560
+ i += 1;
561
+ }
562
+ }
563
+ String::from_utf8(out).unwrap()
564
+ }
565
+
566
+ /// Substitute component props into a template — the compiled twin of the
567
+ /// runtime's renderPropsAndSlot (runtime/component.js). Both sides must
568
+ /// transform identically:
569
+ /// - exact `@[propName]` bindings (case-insensitive)
570
+ /// - prop identifiers inside other `@[expr]` bindings
571
+ /// - prop identifiers inside directive comments (`if` / `else if` / `each`)
572
+ /// - `$.propName` references inside event-handler attribute bodies
573
+ /// Binding props (`prop="@[path]"`) rewrite identifiers to the bound path;
574
+ /// literal props inject the value (bare boolean attrs → true, strings
575
+ /// JSON-quoted, numerics raw).
576
+ fn substitute_props(template: &str, props: &[(String, String)]) -> String {
577
+ let binding_re = regex::Regex::new(r"@\[([^\]]+)\]").unwrap();
578
+ let directive_re = regex::Regex::new(r"(?s)<!--\s*(if|else if|each)\s+(.*?)\s*-->").unwrap();
579
+ let event_re = regex::Regex::new(r#"\bon(\w+)="([^"]*)""#).unwrap();
580
+ let numeric_re = regex::Regex::new(r"(?i)^-?\d+(\.\d+)?(e[+-]?\d+)?$").unwrap();
581
+
582
+ let mut html = template.to_string();
583
+
584
+ for (prop_name, prop_value) in props {
585
+ let exact_re =
586
+ regex::Regex::new(&format!(r"(?i)@\[{}\]", regex::escape(prop_name))).unwrap();
587
+
588
+ let binding_path = prop_value
589
+ .strip_prefix("@[")
590
+ .and_then(|v| v.strip_suffix(']'));
591
+
592
+ let (exact_repl, ident_repl, each_repl, state_repl) = if let Some(path) = binding_path {
593
+ (
594
+ format!("@[{}]", path),
595
+ format!("({})", path),
596
+ path.to_string(),
597
+ path.to_string(),
598
+ )
599
+ } else {
600
+ let literal = if prop_value.is_empty() {
601
+ "true".to_string()
602
+ } else if numeric_re.is_match(prop_value) {
603
+ prop_value.clone()
604
+ } else {
605
+ serde_json::to_string(prop_value).unwrap()
606
+ };
607
+ (prop_value.clone(), literal.clone(), literal.clone(), literal)
608
+ };
609
+
610
+ html = exact_re
611
+ .replace_all(&html, regex::NoExpand(exact_repl.as_str()))
612
+ .to_string();
613
+
614
+ html = binding_re
615
+ .replace_all(&html, |c: &regex::Captures| {
616
+ let expr = c.get(1).unwrap().as_str();
617
+ let rewritten = substitute_identifier(expr, prop_name, &ident_repl);
618
+ if rewritten == expr {
619
+ c.get(0).unwrap().as_str().to_string()
620
+ } else {
621
+ format!("@[{}]", rewritten)
622
+ }
623
+ })
624
+ .to_string();
625
+
626
+ html = directive_re
627
+ .replace_all(&html, |c: &regex::Captures| {
628
+ let kw = c.get(1).unwrap().as_str();
629
+ let expr = c.get(2).unwrap().as_str();
630
+ let repl = if kw == "each" { each_repl.as_str() } else { ident_repl.as_str() };
631
+ let rewritten = substitute_identifier(expr, prop_name, repl);
632
+ if rewritten == expr {
633
+ c.get(0).unwrap().as_str().to_string()
634
+ } else {
635
+ format!("<!-- {} {} -->", kw, rewritten)
636
+ }
637
+ })
638
+ .to_string();
639
+
640
+ html = event_re
641
+ .replace_all(&html, |c: &regex::Captures| {
642
+ let ev = c.get(1).unwrap().as_str();
643
+ let body = c.get(2).unwrap().as_str();
644
+ let rewritten = substitute_state_ref(body, prop_name, &state_repl);
645
+ if rewritten == body {
646
+ c.get(0).unwrap().as_str().to_string()
647
+ } else {
648
+ format!(r#"on{}="{}""#, ev, rewritten)
649
+ }
650
+ })
651
+ .to_string();
652
+ }
653
+
654
+ html
655
+ }
656
+
494
657
  /// Transform custom HTML elements to divs with classes
495
658
  fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) -> String {
496
659
  let mut result = content.to_string();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.0.0",
3
+ "version": "2.0.5",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -1,5 +1,10 @@
1
1
  import { debugLog } from './debug.js';
2
- import { PHASE_READY, FOUC_CLASS_OR_ATTR, DEHYDRATE_CLASS_OR_ATTR } from './constants.js';
2
+ import {
3
+ PHASE_READY,
4
+ FOUC_CLASS_OR_ATTR,
5
+ DEHYDRATE_CLASS_OR_ATTR,
6
+ NON_REACTIVE_ELEMENTS,
7
+ } from './constants.js';
3
8
 
4
9
  /**
5
10
  * Check if all Vibe processing is complete and cleanup can run
@@ -14,12 +19,16 @@ export const shouldCleanup = (rootElement) => {
14
19
  return false;
15
20
  }
16
21
 
17
- // 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated elements)
22
+ // 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated
23
+ // and non-reactive elements — hydrate never touches those, so a literal inside
24
+ // them is final content, not pending work)
18
25
  const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT, {
19
26
  acceptNode(node) {
20
- // Check if this text node is inside a dehydrated element
21
27
  let parent = node.parentElement;
22
28
  while (parent && parent !== rootElement) {
29
+ if (NON_REACTIVE_ELEMENTS.includes(parent.nodeName)) {
30
+ return NodeFilter.FILTER_REJECT;
31
+ }
23
32
  if (parent.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || parent.classList?.contains(DEHYDRATE_CLASS_OR_ATTR)) {
24
33
  return NodeFilter.FILTER_REJECT; // Skip dehydrated content
25
34
  }
@@ -98,9 +98,131 @@ export const collectComponentIds = (node, into = new Set()) => {
98
98
  return into;
99
99
  };
100
100
 
101
+ // Prepare a component <script> body for execution through the injected
102
+ // component() path: strip the `import component from '...'` line (the
103
+ // function is passed in as a parameter) and rewrite any remaining static
104
+ // imports to awaited dynamic imports. Shared by the fetch path
105
+ // (processSingle) and the compiled-page path (executeCompiledComponentScripts).
106
+ const transformScriptContent = (rawContent) => {
107
+ // Strip `import component from '...'` — Vibe injects the contextual
108
+ // component() function as a parameter (it needs access to the temp DOM)
109
+ let content = rawContent.replace(
110
+ /import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
111
+ ''
112
+ );
113
+
114
+ // Check for remaining imports that need rewriting
115
+ const hasImports = /import\s/.test(content);
116
+
117
+ if (hasImports) {
118
+ // Rewrite remaining imports to dynamic await import()
119
+ // Order matters: combined → default → named → namespace → side-effect (most specific first)
120
+ content = content.replace(
121
+ /import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
122
+ 'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
123
+ );
124
+ content = content.replace(
125
+ /import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
126
+ 'const $1 = (await import($2)).default;'
127
+ );
128
+ content = content.replace(
129
+ /import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
130
+ 'const {$1} = await import($2);'
131
+ );
132
+ content = content.replace(
133
+ /import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
134
+ 'const $1 = await import($2);'
135
+ );
136
+ content = content.replace(
137
+ /import\s+(['"][^'"]+['"])\s*;?/g,
138
+ 'await import($1);'
139
+ );
140
+ }
141
+
142
+ return { content, hasImports };
143
+ };
144
+
145
+ // Compiled pages inline `<component src>` content at build time, and the
146
+ // compiler neuters each component script to type="vibe-module" so the browser
147
+ // does NOT execute it as a native page module — native timing is wrong
148
+ // (pre-boot: `$` is the placeholder, state registered after boot never merges
149
+ // into the live proxy). This executes those scripts through the same
150
+ // injected-component() path processSingle uses for fetched scripts: one
151
+ // pipeline, identical semantics in both modes. Scripts stay in the DOM
152
+ // (inert) so the manifest's childNodes indices keep matching the page.
153
+ //
154
+ // Returns a Promise when any script is async (has imports) — the caller
155
+ // gates `ready` on it — or null when everything ran synchronously.
156
+ export const executeCompiledComponentScripts = () => {
157
+ const scripts = document.querySelectorAll('script[type="vibe-module"]');
158
+ if (!scripts.length) return null;
159
+
160
+ // Build-time tagging already assigned _cN ids to wrappers; advance the
161
+ // runtime counter past them so freshly generated ids never collide.
162
+ document.querySelectorAll('[data-vibe-component-id]').forEach((el) => {
163
+ const m = el.getAttribute('data-vibe-component-id')?.match(/^_c(\d+)$/);
164
+ if (m) componentCounter = Math.max(componentCounter, Number(m[1]) + 1);
165
+ });
166
+
167
+ const asyncTasks = [];
168
+ const claimed = new Set();
169
+
170
+ for (const script of scripts) {
171
+ // Parity with the fetch path: dehydrated components never execute
172
+ if (script.closest(`[${DEHYDRATE_CLASS_OR_ATTR}], .${DEHYDRATE_CLASS_OR_ATTR}`)) continue;
173
+
174
+ const rawContent = script.textContent?.trim() || '';
175
+ if (!rawContent) continue;
176
+
177
+ // The build tagged each component wrapper with its deterministic id —
178
+ // the same id the stamped bindings reference. First script in a wrapper
179
+ // claims it; additional scripts get fresh ids (mirrors the per-script
180
+ // ids of the fetch path).
181
+ const wrapper = script.closest('[data-vibe-component-id]');
182
+ let componentId = wrapper?.getAttribute('data-vibe-component-id');
183
+ if (!componentId || claimed.has(componentId)) componentId = generateComponentId();
184
+ claimed.add(componentId);
185
+
186
+ const { content, hasImports } = transformScriptContent(rawContent);
187
+
188
+ const componentFn = (state) => {
189
+ if (!window.__vibeComponents) window.__vibeComponents = {};
190
+ window.__vibeComponents[componentId] = state;
191
+ if (window.$) window.$[componentId] = state;
192
+ return componentId;
193
+ };
194
+
195
+ runComponentCleanups(componentId);
196
+ const scopedDollar = createScopedDollar(componentId);
197
+
198
+ try {
199
+ if (hasImports) {
200
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
201
+ asyncTasks.push(new AsyncFunction('$', 'component', content)(scopedDollar, componentFn));
202
+ } else {
203
+ new Function('$', 'component', content)(scopedDollar, componentFn);
204
+ }
205
+ } catch (e) {
206
+ console.warn('[vibe] Failed to execute component script:', e);
207
+ }
208
+ }
209
+
210
+ return asyncTasks.length ? Promise.all(asyncTasks) : null;
211
+ };
212
+
101
213
  // Track pending fetches to cancel them if element is removed
102
214
  const pendingFetches = new WeakMap(); // element → AbortController
103
215
 
216
+ // Component HTML is parsed (and prop/slot-transformed) while its @[...]
217
+ // bindings are still literal text. Parsing in the live document lets the
218
+ // browser act on those literals mid-parse — Chrome logs "The specified value
219
+ // ... cannot be parsed" for a typed input's value="@[...]". An inert document
220
+ // (no browsing context) parses identical DOM without a console to complain to;
221
+ // nodes are auto-adopted into the live document on insertion.
222
+ let inertDocument;
223
+ const createDetached = (tagName) =>
224
+ (inertDocument ??= document.implementation.createHTMLDocument('')).createElement(tagName);
225
+
104
226
  // Cancel a pending fetch for a component element
105
227
  export const abortComponentFetch = (element) => {
106
228
  const controller = pendingFetches.get(element);
@@ -273,7 +395,7 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
273
395
  // or trigger a full re-mount (new componentIds).
274
396
  export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', options = {}) => {
275
397
  const { componentIds = [] } = options;
276
- const temp = document.createElement('div');
398
+ const temp = createDetached('div');
277
399
  temp.innerHTML = rawHtml;
278
400
 
279
401
  const idsToReuse = [...componentIds];
@@ -318,7 +440,7 @@ const processSingle = (el, debug) => {
318
440
  .then((r) => r.text())
319
441
  .then((html) => {
320
442
  // Parse HTML in temporary container to process component scripts
321
- const temp = document.createElement('div');
443
+ const temp = createDetached('div');
322
444
  temp.innerHTML = html;
323
445
 
324
446
  // Process any <script type="module"> elements
@@ -343,43 +465,10 @@ const processSingle = (el, debug) => {
343
465
  let firstComponentId = null;
344
466
 
345
467
  for (const script of moduleScripts) {
346
- let scriptContent = script.textContent?.trim() || '';
347
- if (!scriptContent) continue;
348
-
349
- // Strip `import component from '...'` Vibe injects the contextual
350
- // component() function as a parameter (it needs access to the temp DOM)
351
- scriptContent = scriptContent.replace(
352
- /import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
353
- ''
354
- );
355
-
356
- // Check for remaining imports that need rewriting
357
- const hasImports = /import\s/.test(scriptContent);
358
-
359
- if (hasImports) {
360
- // Rewrite remaining imports to dynamic await import()
361
- // Order matters: combined → default → named → namespace → side-effect (most specific first)
362
- scriptContent = scriptContent.replace(
363
- /import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
364
- 'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
365
- );
366
- scriptContent = scriptContent.replace(
367
- /import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
368
- 'const $1 = (await import($2)).default;'
369
- );
370
- scriptContent = scriptContent.replace(
371
- /import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
372
- 'const {$1} = await import($2);'
373
- );
374
- scriptContent = scriptContent.replace(
375
- /import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
376
- 'const $1 = await import($2);'
377
- );
378
- scriptContent = scriptContent.replace(
379
- /import\s+(['"][^'"]+['"])\s*;?/g,
380
- 'await import($1);'
381
- );
382
- }
468
+ const rawContent = script.textContent?.trim() || '';
469
+ if (!rawContent) continue;
470
+
471
+ const { content: scriptContent, hasImports } = transformScriptContent(rawContent);
383
472
 
384
473
  // Reuse component ID from HMR if available, otherwise generate new
385
474
  const reuseIds = el._vibeReuseComponentIds;
@@ -454,9 +543,7 @@ const processSingle = (el, debug) => {
454
543
  if (el.parentNode) {
455
544
  // Create clean wrapper element (preserve tag type: component or div.component)
456
545
  const newWrapper =
457
- el.tagName === 'DIV'
458
- ? document.createElement('div')
459
- : document.createElement('component');
546
+ el.tagName === 'DIV' ? createDetached('div') : createDetached('component');
460
547
 
461
548
  if (el.tagName === 'DIV') {
462
549
  newWrapper.className = 'component';
@@ -476,7 +563,7 @@ const processSingle = (el, debug) => {
476
563
  // detached `<component src>` to the new wrapper. The detached element
477
564
  // would otherwise trigger releaseOrphanedIterationProps and free the
478
565
  // registry slots that the inlined template's bindings still reference,
479
- // causing every `@[window.__vibeIterProps._pN]` to resolve to undefined
566
+ // causing every `@[window.__vibeiterprops._pN]` to resolve to undefined
480
567
  // on the next hydrate.
481
568
  if (el._vibeIterPropIds) {
482
569
  newWrapper._vibeIterPropIds = el._vibeIterPropIds;
@@ -263,18 +263,25 @@ const unmountBranch = (node, manifest) => {
263
263
  }
264
264
  }
265
265
 
266
- // Collect componentIds from the subtree BEFORE detaching so we can check
266
+ // The conditional owns everything between its comments. That's more than
267
+ // activeInstance.nodes: nested directives at the branch's top level (each
268
+ // rows, deeper if branches) insert their rendered output between their own
269
+ // comments after the branch mounts, so it never appears in the original
270
+ // clonedNodes list. Sweep the live range (same pattern as iteration
271
+ // teardown), collecting componentIds BEFORE detaching so we can check
267
272
  // after removal whether any live DOM still holds them.
273
+ const { startComment, endComment } = node.meta;
268
274
  const ids = new Set();
269
- activeInstance.nodes.forEach((domNode) => collectComponentIds(domNode, ids));
270
-
271
- // Remove all nodes from DOM and deregister from branch registry
272
- activeInstance.nodes.forEach((domNode) => {
273
- branchNodeRegistry.delete(domNode);
274
- if (domNode.parentNode) {
275
- domNode.parentNode.removeChild(domNode);
275
+ let current = startComment.nextSibling;
276
+ while (current && current !== endComment) {
277
+ const next = current.nextSibling;
278
+ collectComponentIds(current, ids);
279
+ branchNodeRegistry.delete(current);
280
+ if (current.parentNode) {
281
+ current.parentNode.removeChild(current);
276
282
  }
277
- });
283
+ current = next;
284
+ }
278
285
 
279
286
  // CLEANUP OF CURRENT STATE
280
287
  releaseOrphanedComponentState(ids);
@@ -92,9 +92,18 @@ export default (affected, state, manifest = {}, oldState = {}) => {
92
92
  const expr = isPureBinding[1];
93
93
  const value = evalInScope(expr, effectiveState, element);
94
94
  if (element[attrName] !== value) element[attrName] = value;
95
- if (value !== undefined && value !== null) {
96
- const str = String(value);
97
- if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
95
+ if (attrName === 'value') {
96
+ if (value !== undefined && value !== null) {
97
+ const str = String(value);
98
+ if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
99
+ }
100
+ } else if (value) {
101
+ // checked/selected are boolean — the truthful attribute form is
102
+ // presence (empty) when truthy, absence when falsy. Stringifying
103
+ // would leave checked="false", which is "checked" to CSS and HTML.
104
+ if (element.getAttribute(attrName) !== '') element.setAttribute(attrName, '');
105
+ } else if (element.hasAttribute(attrName)) {
106
+ element.removeAttribute(attrName);
98
107
  }
99
108
  } else if (!isValueAttr && isPureBinding) {
100
109
  // Boolean-like attributes: add or remove based on truthiness.