@ape-egg/vibe 2.1.4 → 2.1.7
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 +25 -0
- package/README.md +48 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +137 -2
- package/compiler/src/compiler/component_tagger.rs +80 -28
- package/compiler/src/compiler/js_analyzer.rs +122 -36
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/reassignment_analyzer.rs +456 -0
- package/compiler/src/compiler/value_stamper.rs +128 -16
- package/compiler/src/parser/html.rs +81 -3
- package/package.json +1 -1
- package/runtime/component.js +30 -3
- package/runtime/conditionals.js +8 -1
|
@@ -190,7 +190,7 @@ impl HtmlParser {
|
|
|
190
190
|
// Find all tags that match loaded elements
|
|
191
191
|
for (tag_name, element) in &self.cache {
|
|
192
192
|
// Match opening and closing tags with any attributes and children
|
|
193
|
-
let tag_pattern = format!(r"<{}(\s
|
|
193
|
+
let tag_pattern = format!(r"<{}(\s{})?>", regex::escape(tag_name), ATTR_RUN);
|
|
194
194
|
let tag_re = regex::Regex::new(&tag_pattern).unwrap();
|
|
195
195
|
let closing_pattern = format!(r"</{}>", regex::escape(tag_name));
|
|
196
196
|
|
|
@@ -336,7 +336,7 @@ impl HtmlParser {
|
|
|
336
336
|
|
|
337
337
|
// Match only the opening tag — slot content is extracted via depth-counting close search
|
|
338
338
|
// to correctly handle slot content that contains </div> or nested <component> elements.
|
|
339
|
-
let pattern = format!(r#"<(component|div)\s+(
|
|
339
|
+
let pattern = format!(r#"<(component|div)\s+({attr})\bsrc="{src}"\s*({attr})>"#, attr = ATTR_RUN, src = regex::escape(&normalized_src));
|
|
340
340
|
let open_re = regex::Regex::new(&pattern).unwrap();
|
|
341
341
|
|
|
342
342
|
let matches: Vec<_> = open_re.captures_iter(&result).filter_map(|cap| {
|
|
@@ -393,7 +393,7 @@ impl HtmlParser {
|
|
|
393
393
|
// Match only the opening component tag — slot content is extracted via depth-counting
|
|
394
394
|
// to correctly handle slot content containing </div> or nested <component> elements.
|
|
395
395
|
let open_re = regex::Regex::new(
|
|
396
|
-
r#"<(component|div)\s+(
|
|
396
|
+
&format!(r#"<(component|div)\s+({attr})\bsrc="([^"]+)"({attr})>"#, attr = ATTR_RUN)
|
|
397
397
|
).unwrap();
|
|
398
398
|
|
|
399
399
|
// Process one match at a time with re-scanning after each replacement.
|
|
@@ -503,6 +503,13 @@ fn neuter_component_scripts(html: &str) -> String {
|
|
|
503
503
|
html.replace("<script type=\"module\"", "<script type=\"vibe-module\"")
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
/// A run of HTML attributes where `>` is permitted only inside a quoted value.
|
|
507
|
+
/// Mirrors a real HTML tokenizer: a tag ends on an *unquoted* `>` only. Without
|
|
508
|
+
/// this, a binding prop like `flipped="@[a >= b]"` truncates the open tag at the
|
|
509
|
+
/// `>` inside its value, so the trailing attributes get mis-parsed (empty
|
|
510
|
+
/// boolean props → literal `true`) and corrupt the inlined template.
|
|
511
|
+
const ATTR_RUN: &str = r#"(?:"[^"]*"|'[^']*'|[^>"'])*"#;
|
|
512
|
+
|
|
506
513
|
fn is_ident_byte(b: u8) -> bool {
|
|
507
514
|
b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
|
|
508
515
|
}
|
|
@@ -775,4 +782,75 @@ mod tests {
|
|
|
775
782
|
assert!(content[close..].starts_with("</component>"), "matched wrong close: {:?}", &content[close..close + 14]);
|
|
776
783
|
assert!(content[close..].contains("<sibling>"), "sibling was swallowed into the component");
|
|
777
784
|
}
|
|
785
|
+
|
|
786
|
+
#[test]
|
|
787
|
+
fn inline_component_tolerates_gt_in_prop_binding() {
|
|
788
|
+
// A component prop whose `@[...]` value contains a comparison operator
|
|
789
|
+
// (`>=`) must not truncate the open tag at the `>` *inside the value*.
|
|
790
|
+
// The flawed `[^>]*` attribute run cut the tag mid-attribute, so the
|
|
791
|
+
// trailing identifiers (`selectedBrawlers`, `length`) were mis-parsed as
|
|
792
|
+
// empty boolean props (→ literal `true`), rewriting the loop expression
|
|
793
|
+
// to `brawlHandCards(characters, true)` — which throws `true.includes is
|
|
794
|
+
// not a function` at runtime, so the card discs never render.
|
|
795
|
+
let parser = HtmlParser::new(std::path::PathBuf::from("."));
|
|
796
|
+
let page = concat!(
|
|
797
|
+
r#"<page><component src="/components/remote/CardHand.html" "#,
|
|
798
|
+
r#"cards="@[brawlHandCards(characters, selectedBrawlers)]" "#,
|
|
799
|
+
r#"flipped="@[selectedBrawlers.length >= maxBrawlers]" "#,
|
|
800
|
+
r#"angle="16"></component></page>"#,
|
|
801
|
+
);
|
|
802
|
+
let template = concat!(
|
|
803
|
+
r#"<card-hand flipped="@[flipped]"><card-fan style="--angle: @[angle]deg">"#,
|
|
804
|
+
r#"<!-- each cards as card (card.id), i --><card-slot data-id="@[card.id]"></card-slot>"#,
|
|
805
|
+
r#"<!-- /each --></card-fan></card-hand>"#,
|
|
806
|
+
);
|
|
807
|
+
let mut cache = HashMap::new();
|
|
808
|
+
cache.insert("/components/remote/CardHand.html".to_string(), template.to_string());
|
|
809
|
+
|
|
810
|
+
let out = parser.inline_component_elements(page, &cache);
|
|
811
|
+
|
|
812
|
+
// The loop expression must carry `selectedBrawlers` through untouched.
|
|
813
|
+
assert!(
|
|
814
|
+
out.contains("brawlHandCards(characters, selectedBrawlers)"),
|
|
815
|
+
"loop expression corrupted: {out}"
|
|
816
|
+
);
|
|
817
|
+
assert!(
|
|
818
|
+
!out.contains("brawlHandCards(characters, true)"),
|
|
819
|
+
"selectedBrawlers wrongly replaced with `true`: {out}"
|
|
820
|
+
);
|
|
821
|
+
// The binding prop keeps its full comparison expression.
|
|
822
|
+
assert!(
|
|
823
|
+
out.contains(r#"flipped="@[selectedBrawlers.length >= maxBrawlers]""#),
|
|
824
|
+
"flipped binding lost: {out}"
|
|
825
|
+
);
|
|
826
|
+
// A literal prop *after* the `>=` prop must still substitute — proof the
|
|
827
|
+
// tag was parsed to the real `>`, not the one inside the value.
|
|
828
|
+
assert!(out.contains("--angle: 16deg"), "angle not substituted: {out}");
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
#[test]
|
|
832
|
+
fn inline_custom_element_tolerates_gt_in_prop_binding() {
|
|
833
|
+
// Same defect in the custom-element inliner's `<tag(\s[^>]*)?>` regex.
|
|
834
|
+
// (Cached element tags are filename-derived single words — see element.rs.)
|
|
835
|
+
let mut parser = HtmlParser::new(std::path::PathBuf::from("."));
|
|
836
|
+
parser.cache.insert(
|
|
837
|
+
"gauge".to_string(),
|
|
838
|
+
Element::new(
|
|
839
|
+
"gauge".to_string(),
|
|
840
|
+
std::path::PathBuf::from("gauge.html"),
|
|
841
|
+
r#"<gauge-inner show="@[show]" n="@[count]"></gauge-inner>"#.to_string(),
|
|
842
|
+
),
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
let page = r#"<page><gauge show="@[items.length >= max]" count="7"></gauge></page>"#;
|
|
846
|
+
let out = parser.inline_custom_elements(page);
|
|
847
|
+
|
|
848
|
+
// The `>=` inside the binding must not have truncated the open tag.
|
|
849
|
+
assert!(
|
|
850
|
+
out.contains(r#"show="@[items.length >= max]""#),
|
|
851
|
+
"show binding lost: {out}"
|
|
852
|
+
);
|
|
853
|
+
// A literal prop after the `>=` prop still substitutes → tag parsed fully.
|
|
854
|
+
assert!(out.contains(r#"n="7""#), "count not substituted: {out}");
|
|
855
|
+
}
|
|
778
856
|
}
|
package/package.json
CHANGED
package/runtime/component.js
CHANGED
|
@@ -158,14 +158,41 @@ const transformScriptContent = (rawContent) => {
|
|
|
158
158
|
export const executeCompiledComponentScripts = () => {
|
|
159
159
|
const scripts = document.querySelectorAll('script[type="vibe-module"]');
|
|
160
160
|
if (!scripts.length) return null;
|
|
161
|
+
advanceComponentCounterPastIds(document);
|
|
162
|
+
return runVibeModuleScripts(scripts);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Same pipeline as the boot-time pass, but scoped to a freshly mounted subtree
|
|
166
|
+
// (a conditional branch or iteration row) rather than the whole document. The
|
|
167
|
+
// boot pass only sees component scripts that are in the page at boot; a branch
|
|
168
|
+
// that mounts later — or RE-mounts after being unmounted — carries its own
|
|
169
|
+
// inlined `vibe-module` scripts that must run each time so component-local state
|
|
170
|
+
// (`component({...})` → `$._cN`) is re-registered. Without this, a re-opened
|
|
171
|
+
// conditional restores its markup but its `<!-- each _cN.x -->` reads state that
|
|
172
|
+
// was released on unmount (the AccountProgression overlay rendering blank on
|
|
173
|
+
// second open).
|
|
174
|
+
export const executeCompiledComponentScriptsIn = (nodes) => {
|
|
175
|
+
const scripts = [];
|
|
176
|
+
for (const node of nodes) {
|
|
177
|
+
if (node.nodeType !== 1) continue;
|
|
178
|
+
if (node.matches?.('script[type="vibe-module"]')) scripts.push(node);
|
|
179
|
+
node.querySelectorAll?.('script[type="vibe-module"]').forEach((s) => scripts.push(s));
|
|
180
|
+
}
|
|
181
|
+
if (!scripts.length) return null;
|
|
182
|
+
advanceComponentCounterPastIds(document);
|
|
183
|
+
return runVibeModuleScripts(scripts);
|
|
184
|
+
};
|
|
161
185
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
186
|
+
// Build-time tagging already assigned _cN ids to wrappers; advance the runtime
|
|
187
|
+
// counter past them so freshly generated ids never collide.
|
|
188
|
+
const advanceComponentCounterPastIds = (root) => {
|
|
189
|
+
root.querySelectorAll('[data-vibe-component-id]').forEach((el) => {
|
|
165
190
|
const m = el.getAttribute('data-vibe-component-id')?.match(/^_c(\d+)$/);
|
|
166
191
|
if (m) componentCounter = Math.max(componentCounter, Number(m[1]) + 1);
|
|
167
192
|
});
|
|
193
|
+
};
|
|
168
194
|
|
|
195
|
+
const runVibeModuleScripts = (scripts) => {
|
|
169
196
|
const asyncTasks = [];
|
|
170
197
|
const claimed = new Set();
|
|
171
198
|
|
package/runtime/conditionals.js
CHANGED
|
@@ -3,7 +3,7 @@ import affected from './affected.js';
|
|
|
3
3
|
import hydrate from './hydrate.js';
|
|
4
4
|
import { createScopedState, renderAllIterations, initializeBlock, resolveIterationComponentProps } from './iterate.js';
|
|
5
5
|
import { evalInScope } from './utils.js';
|
|
6
|
-
import { collectComponentIds, releaseOrphanedComponentState } from './component.js';
|
|
6
|
+
import { collectComponentIds, releaseOrphanedComponentState, executeCompiledComponentScriptsIn } from './component.js';
|
|
7
7
|
|
|
8
8
|
// Registry of DOM nodes owned by conditional branches.
|
|
9
9
|
// Maps a DOM node to { nodes: array_ref, index: number } so that
|
|
@@ -231,6 +231,13 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
// Compiled pages inline each component's setup script as type="vibe-module".
|
|
235
|
+
// The boot-time pass only runs scripts present at boot, so a branch that
|
|
236
|
+
// mounts (or re-mounts after unmount) must run its own scripts here to
|
|
237
|
+
// re-register component-local state. Must happen BEFORE rendering nested
|
|
238
|
+
// iterations/conditionals so `<!-- each _cN.x -->` sees the registered state.
|
|
239
|
+
executeCompiledComponentScriptsIn(clonedNodes);
|
|
240
|
+
|
|
234
241
|
// Recursively render any nested iterations and conditionals
|
|
235
242
|
if (branchTree) {
|
|
236
243
|
renderAllIterations(branchTree, scopedState, manifest, parentScope);
|