@ape-egg/vibe-compiler 4.1.3 → 4.2.0
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/native/vibe-compiler-darwin-arm64 +0 -0
- package/native/vibe-compiler-linux-x64 +0 -0
- package/package.json +4 -2
- package/src/Cargo.lock +1 -1
- package/src/Cargo.toml +1 -1
- package/src/compiler/compile.rs +13 -14
- package/src/compiler/component_tagger.rs +13 -12
- package/src/compiler/iteration_optimizer.rs +4 -3
- package/src/compiler/manifest_builder.rs +1 -1
- package/src/compiler/name_binding_protect.rs +11 -40
- package/src/compiler/spa.rs +3 -2
- package/src/compiler/value_stamper.rs +76 -100
- package/src/compiler/watcher.rs +2 -1
- package/src/constants.rs +46 -0
- package/src/inert.rs +171 -0
- package/src/main.rs +2 -0
- package/src/parser/html.rs +132 -13
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ape-egg/vibe-compiler",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Optional Rust compiler for Vibe
|
|
5
|
+
"description": "Optional Rust compiler for Vibe \u2014 pre-renders, inlines components, and optimizes iterations. The runtime never needs it.",
|
|
6
6
|
"homepage": "https://vibe.korte.kim",
|
|
7
7
|
"bin": {
|
|
8
8
|
"vibe": "./bin/vibe-compile.js"
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"src/Cargo.lock",
|
|
15
15
|
"src/main.rs",
|
|
16
16
|
"src/config.rs",
|
|
17
|
+
"src/constants.rs",
|
|
18
|
+
"src/inert.rs",
|
|
17
19
|
"src/compiler",
|
|
18
20
|
"src/parser"
|
|
19
21
|
],
|
package/src/Cargo.lock
CHANGED
package/src/Cargo.toml
CHANGED
package/src/compiler/compile.rs
CHANGED
|
@@ -59,8 +59,7 @@ pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
|
|
59
59
|
// or a more sophisticated asset pipeline.
|
|
60
60
|
// =============================================================================
|
|
61
61
|
// FOUC prevention class/attribute name (matches runtime/constants.js)
|
|
62
|
-
|
|
63
|
-
const FOUC_CLASS_OR_ATTR: &str = "vibe-fouc";
|
|
62
|
+
use crate::constants::{FOUC_ATTR as FOUC_CLASS_OR_ATTR, CSS_COVER_ATTR, CSS_LAYOUT_ATTR, RECURSIVE_SRC_ATTR, HYPERSPEED_DIR, MANIFEST_SUFFIX, SPA_FRAGMENTS_DIR};
|
|
64
63
|
|
|
65
64
|
const VIBE_CSS_FILE: &str = include_str!("../../../vibe/vibe.css");
|
|
66
65
|
const VIBE_CSS_OPTIONAL_MARKER: &str =
|
|
@@ -234,7 +233,7 @@ fn extract_event_handlers(html: &str) -> Vec<String> {
|
|
|
234
233
|
.collect()
|
|
235
234
|
}
|
|
236
235
|
|
|
237
|
-
/// Rename `src="{component_src}"` to `data-vibe-recursive-src="{component_src}"`
|
|
236
|
+
/// Rename `src="{component_src}"` to `data-vibe-internal-recursive-src="{component_src}"`
|
|
238
237
|
/// on `<component>` / `<div class="component">` tags. Used during cache build
|
|
239
238
|
/// to neutralize cyclic refs before they reach `inline_component_elements` —
|
|
240
239
|
/// the inliner's `src=` regex won't match the renamed attribute, so it stops
|
|
@@ -246,7 +245,7 @@ fn escape_recursive_src(content: &str, component_src: &str) -> String {
|
|
|
246
245
|
regex::escape(component_src),
|
|
247
246
|
);
|
|
248
247
|
let re = Regex::new(&pattern).unwrap();
|
|
249
|
-
re.replace_all(content, format!(r#"$
|
|
248
|
+
re.replace_all(content, format!(r#"$1{}="{}""#, RECURSIVE_SRC_ATTR, component_src))
|
|
250
249
|
.into_owned()
|
|
251
250
|
}
|
|
252
251
|
|
|
@@ -279,7 +278,7 @@ fn remove_fouc_prevention(html: String) -> String {
|
|
|
279
278
|
}
|
|
280
279
|
|
|
281
280
|
pub fn inject_vibe_css(html: String, disable_vibe_css: bool) -> String {
|
|
282
|
-
if html.contains(
|
|
281
|
+
if html.contains(CSS_COVER_ATTR) {
|
|
283
282
|
return html;
|
|
284
283
|
}
|
|
285
284
|
let open_end = match html.find("<head") {
|
|
@@ -290,9 +289,9 @@ pub fn inject_vibe_css(html: String, disable_vibe_css: bool) -> String {
|
|
|
290
289
|
None => return html,
|
|
291
290
|
};
|
|
292
291
|
let (cover_css, layout_css) = vibe_css_sheets();
|
|
293
|
-
let mut style = format!("<style
|
|
292
|
+
let mut style = format!("<style {}>{}</style>", CSS_COVER_ATTR, cover_css);
|
|
294
293
|
if !disable_vibe_css {
|
|
295
|
-
style.push_str(&format!("<style
|
|
294
|
+
style.push_str(&format!("<style {}>{}</style>", CSS_LAYOUT_ATTR, layout_css));
|
|
296
295
|
}
|
|
297
296
|
let mut result = String::with_capacity(html.len() + style.len());
|
|
298
297
|
result.push_str(&html[..open_end]);
|
|
@@ -1084,7 +1083,7 @@ impl Compiler {
|
|
|
1084
1083
|
|
|
1085
1084
|
// Tag components with deterministic IDs and structure state
|
|
1086
1085
|
let tagged = ComponentTagger::tag_components(&protected, &source_root.to_path_buf())?;
|
|
1087
|
-
let html = &tagged.html; // Use modified HTML with data-vibe-component-id attributes
|
|
1086
|
+
let html = &tagged.html; // Use modified HTML with data-vibe-internal-component-id attributes
|
|
1088
1087
|
let state = tagged.state;
|
|
1089
1088
|
|
|
1090
1089
|
// Build manifest from pre-stamp HTML (both conditional branches present, @[...] markers intact)
|
|
@@ -1101,8 +1100,8 @@ impl Compiler {
|
|
|
1101
1100
|
|
|
1102
1101
|
// Write manifest
|
|
1103
1102
|
let manifest_path = output_dir
|
|
1104
|
-
.join(
|
|
1105
|
-
.join(format!("{}
|
|
1103
|
+
.join(HYPERSPEED_DIR)
|
|
1104
|
+
.join(format!("{}{}", manifest_rel, MANIFEST_SUFFIX));
|
|
1106
1105
|
|
|
1107
1106
|
// Ensure directory exists
|
|
1108
1107
|
if let Some(parent) = manifest_path.parent() {
|
|
@@ -1572,7 +1571,7 @@ impl Compiler {
|
|
|
1572
1571
|
pages_dir.display()
|
|
1573
1572
|
)));
|
|
1574
1573
|
}
|
|
1575
|
-
let reserved = self.config.components_path().join(
|
|
1574
|
+
let reserved = self.config.components_path().join(SPA_FRAGMENTS_DIR);
|
|
1576
1575
|
if reserved.exists() {
|
|
1577
1576
|
return Err(CompileError::SpaError(format!(
|
|
1578
1577
|
"{} is reserved for compiled page fragments — move or rename it",
|
|
@@ -1686,7 +1685,7 @@ impl Compiler {
|
|
|
1686
1685
|
.config
|
|
1687
1686
|
.output
|
|
1688
1687
|
.join("components")
|
|
1689
|
-
.join(
|
|
1688
|
+
.join(SPA_FRAGMENTS_DIR)
|
|
1690
1689
|
.join(&rel);
|
|
1691
1690
|
if let Some(parent) = fragment_path.parent() {
|
|
1692
1691
|
fs::create_dir_all(parent).map_err(|_| {
|
|
@@ -1733,7 +1732,7 @@ impl Compiler {
|
|
|
1733
1732
|
|
|
1734
1733
|
// The fragments dir mirrors the pages tree: prune fragments whose
|
|
1735
1734
|
// page no longer exists (watch-mode page removals, no-clean runs).
|
|
1736
|
-
let fragments_root = self.config.output.join("components").join(
|
|
1735
|
+
let fragments_root = self.config.output.join("components").join(SPA_FRAGMENTS_DIR);
|
|
1737
1736
|
let live: HashSet<String> = pages.iter().map(|p| p.rel_path.clone()).collect();
|
|
1738
1737
|
let mut existing = Vec::new();
|
|
1739
1738
|
Self::collect_page_files(&fragments_root, &self.config.skip_files, &mut existing);
|
|
@@ -2040,7 +2039,7 @@ impl Compiler {
|
|
|
2040
2039
|
/// `<component src>` that points back into the chain is a cycle (direct
|
|
2041
2040
|
/// self-reference, or A→B→A) — those are valid runtime patterns (recursive
|
|
2042
2041
|
/// tree components bounded by data depth) but would expand infinitely at
|
|
2043
|
-
/// compile time. Cycle references are escaped to `data-vibe-recursive-src`
|
|
2042
|
+
/// compile time. Cycle references are escaped to `data-vibe-internal-recursive-src`
|
|
2044
2043
|
/// in the cached content so the inliner's `src=` regex won't keep matching
|
|
2045
2044
|
/// them; `process_html_with_cache` restores the attribute name at the end
|
|
2046
2045
|
/// of compilation so the runtime sees a normal `<component src>` tag.
|
|
@@ -10,6 +10,7 @@ use std::path::PathBuf;
|
|
|
10
10
|
use std::cell::RefCell;
|
|
11
11
|
use std::sync::OnceLock;
|
|
12
12
|
use crate::compiler::state_extractor::StateExtractor;
|
|
13
|
+
use crate::constants::{COMPONENT_ID_ATTR, COMPONENT_ID_PREFIX};
|
|
13
14
|
|
|
14
15
|
/// Matches a `component(...)` state-registration call in a component's inline
|
|
15
16
|
/// script. The `\b` keeps it from firing on `subcomponent(`; resolvable or not,
|
|
@@ -136,7 +137,7 @@ impl ComponentTagger {
|
|
|
136
137
|
})
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
/// Recursively walk DOM, add data-vibe-component-id, and extract component state
|
|
140
|
+
/// Recursively walk DOM, add data-vibe-internal-component-id, and extract component state
|
|
140
141
|
fn walk_tag_and_extract(
|
|
141
142
|
node: &Handle,
|
|
142
143
|
counter: &RefCell<usize>,
|
|
@@ -186,16 +187,16 @@ impl ComponentTagger {
|
|
|
186
187
|
_ => serde_json::Map::new(),
|
|
187
188
|
};
|
|
188
189
|
|
|
189
|
-
let component_id = format!("
|
|
190
|
+
let component_id = format!("{}{}", COMPONENT_ID_PREFIX, *counter.borrow());
|
|
190
191
|
*counter.borrow_mut() += 1;
|
|
191
192
|
|
|
192
|
-
// Add data-vibe-component-id attribute
|
|
193
|
+
// Add data-vibe-internal-component-id attribute
|
|
193
194
|
let mut attrs_mut = attrs.borrow_mut();
|
|
194
195
|
attrs_mut.push(markup5ever::Attribute {
|
|
195
196
|
name: QualName::new(
|
|
196
197
|
None,
|
|
197
198
|
Namespace::from(""),
|
|
198
|
-
LocalName::from(
|
|
199
|
+
LocalName::from(COMPONENT_ID_ATTR),
|
|
199
200
|
),
|
|
200
201
|
value: component_id.clone().into(),
|
|
201
202
|
});
|
|
@@ -284,7 +285,7 @@ impl ComponentTagger {
|
|
|
284
285
|
/// expressions) and inside if/each/else-if directive comments. Resolving the
|
|
285
286
|
/// directives at build time is what lets a component-local `<!-- if this.x -->`
|
|
286
287
|
/// work on compiled (manifest-restored) pages, where the runtime can't fall
|
|
287
|
-
/// back to a `data-vibe-component-id` ancestor lookup.
|
|
288
|
+
/// back to a `data-vibe-internal-component-id` ancestor lookup.
|
|
288
289
|
fn rewrite_this_to_component_id(node: &Handle, component_id: &str) {
|
|
289
290
|
use regex::Regex;
|
|
290
291
|
// The whole `@[...]` binding; `this.` is resolved within it so nested
|
|
@@ -381,8 +382,8 @@ mod tests {
|
|
|
381
382
|
|
|
382
383
|
let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
|
|
383
384
|
|
|
384
|
-
// Should have data-vibe-component-id in output
|
|
385
|
-
assert!(result.html.contains("data-vibe-component-id=\"_c0\""));
|
|
385
|
+
// Should have data-vibe-internal-component-id in output
|
|
386
|
+
assert!(result.html.contains("data-vibe-internal-component-id=\"_c0\""));
|
|
386
387
|
}
|
|
387
388
|
|
|
388
389
|
#[test]
|
|
@@ -413,7 +414,7 @@ mod tests {
|
|
|
413
414
|
let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
|
|
414
415
|
|
|
415
416
|
assert!(
|
|
416
|
-
result.html.contains("data-vibe-component-id=\"_c0\""),
|
|
417
|
+
result.html.contains("data-vibe-internal-component-id=\"_c0\""),
|
|
417
418
|
"wrapper not tagged; html: {}",
|
|
418
419
|
result.html
|
|
419
420
|
);
|
|
@@ -431,7 +432,7 @@ mod tests {
|
|
|
431
432
|
let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
|
|
432
433
|
|
|
433
434
|
assert!(
|
|
434
|
-
result.html.contains("data-vibe-component-id=\"_c0\""),
|
|
435
|
+
result.html.contains("data-vibe-internal-component-id=\"_c0\""),
|
|
435
436
|
"runtime-built-state component not tagged: {}",
|
|
436
437
|
result.html
|
|
437
438
|
);
|
|
@@ -475,8 +476,8 @@ mod tests {
|
|
|
475
476
|
let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
|
|
476
477
|
|
|
477
478
|
// Should have _c0, _c1, _c2
|
|
478
|
-
assert!(result.html.contains("data-vibe-component-id=\"_c0\""));
|
|
479
|
-
assert!(result.html.contains("data-vibe-component-id=\"_c1\""));
|
|
480
|
-
assert!(result.html.contains("data-vibe-component-id=\"_c2\""));
|
|
479
|
+
assert!(result.html.contains("data-vibe-internal-component-id=\"_c0\""));
|
|
480
|
+
assert!(result.html.contains("data-vibe-internal-component-id=\"_c1\""));
|
|
481
|
+
assert!(result.html.contains("data-vibe-internal-component-id=\"_c2\""));
|
|
481
482
|
}
|
|
482
483
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
use regex::Regex;
|
|
2
2
|
use serde::{Serialize, Deserialize};
|
|
3
3
|
use std::collections::HashMap;
|
|
4
|
+
use crate::constants::NAMEBIND_ATTR;
|
|
4
5
|
|
|
5
6
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
6
7
|
#[serde(rename_all = "camelCase")]
|
|
@@ -157,12 +158,12 @@ const VALUE_ATTRS: &[&str] = &[
|
|
|
157
158
|
|
|
158
159
|
/// Whether an attribute keeps its string value (vs boolean coercion).
|
|
159
160
|
/// Mirrors isValueStyleAttr in runtime/iterate.js.
|
|
160
|
-
/// Turn ` data-vibe-namebind="@[A]@[B]"` back into ` @[A]="" @[B]=""` so each relocated
|
|
161
|
+
/// Turn ` data-vibe-internal-namebind="@[A]@[B]"` back into ` @[A]="" @[B]=""` so each relocated
|
|
161
162
|
/// binding is rendered as a name-binding (` resolvedName=""`) rather than having its
|
|
162
163
|
/// resolved value written into the transport attribute. See name_binding_protect.
|
|
163
164
|
fn restore_relocated_name_bindings(template: &str) -> String {
|
|
164
165
|
let wrapper = Regex::new(
|
|
165
|
-
r#"\s+
|
|
166
|
+
&format!(r#"\s+{}="((?:@\[(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)""#, NAMEBIND_ATTR),
|
|
166
167
|
)
|
|
167
168
|
.unwrap();
|
|
168
169
|
let one = Regex::new(r#"@\[(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\]"#).unwrap();
|
|
@@ -230,7 +231,7 @@ fn compile_template_to_batch_fn(
|
|
|
230
231
|
/// Anything the runtime clone+hydrate path renders, this output has to render
|
|
231
232
|
/// identically — it is the compiled twin of compileBatchFn in runtime/iterate.js.
|
|
232
233
|
fn emit_template_literal(template: &str) -> String {
|
|
233
|
-
// Restore name-bindings the compiler relocated into `data-vibe-namebind` (their
|
|
234
|
+
// Restore name-bindings the compiler relocated into `data-vibe-internal-namebind` (their
|
|
234
235
|
// expression has whitespace, so it could not be an HTML attribute name) back to the
|
|
235
236
|
// `@[expr]=""` form the binding pass below renders as ` resolvedName=""`. The compiled
|
|
236
237
|
// twin of the same step in runtime/iterate.js compileBatchFn.
|
|
@@ -139,7 +139,7 @@ impl ManifestBuilder {
|
|
|
139
139
|
let attr_value = attr.value.to_string();
|
|
140
140
|
|
|
141
141
|
// A relocated name-binding: its expression(s) live verbatim in the
|
|
142
|
-
// VALUE of data-vibe-namebind because whitespace barred them from
|
|
142
|
+
// VALUE of data-vibe-internal-namebind because whitespace barred them from
|
|
143
143
|
// attribute-name position. Treat the value as the name-binding — no
|
|
144
144
|
// case restoration needed (value attrs keep their case).
|
|
145
145
|
if attr_name == crate::compiler::name_binding_protect::NAME_BIND_ATTR {
|
|
@@ -4,7 +4,7 @@ use std::sync::OnceLock;
|
|
|
4
4
|
/// The attribute a relocated name-binding is parked in. Mirrors the runtime
|
|
5
5
|
/// constant of the same name (runtime/constants.js) — the manifest builder reads
|
|
6
6
|
/// the binding back out of it, and the runtime consumes + strips it on hydrate.
|
|
7
|
-
pub const NAME_BIND_ATTR: &str =
|
|
7
|
+
pub const NAME_BIND_ATTR: &str = crate::constants::NAMEBIND_ATTR;
|
|
8
8
|
|
|
9
9
|
/// A name-binding lives in attribute-NAME position: `<icon @[expr]>`. HTML parsers
|
|
10
10
|
/// split an attribute token on whitespace, so once a prop is inlined into one and the
|
|
@@ -12,18 +12,18 @@ pub const NAME_BIND_ATTR: &str = "data-vibe-namebind";
|
|
|
12
12
|
/// — html5ever (and the browser) tear it into broken attributes and the binding is lost.
|
|
13
13
|
///
|
|
14
14
|
/// We relocate every whitespace-bearing name-binding into a single verbatim
|
|
15
|
-
/// value-attribute `data-vibe-namebind="@[expr]@[expr2]…"`. Value attributes are kept
|
|
15
|
+
/// value-attribute `data-vibe-internal-namebind="@[expr]@[expr2]…"`. Value attributes are kept
|
|
16
16
|
/// byte-for-byte through parsing, so the manifest builder recovers the exact expression
|
|
17
17
|
/// and the runtime evaluates it through the same path it uses in non-compiled mode —
|
|
18
18
|
/// identical behaviour, no expression rewriting. Whitespace-free name-bindings already
|
|
19
19
|
/// survive as-is and are left untouched.
|
|
20
20
|
pub fn protect(html: &str) -> String {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
let (masked, raw_blocks) = mask_raw_text(html);
|
|
21
|
+
crate::inert::protect(html, relocate_bindings)
|
|
22
|
+
}
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
fn relocate_bindings(html: &str) -> String {
|
|
25
|
+
tag_open_regex()
|
|
26
|
+
.replace_all(html, |caps: ®ex::Captures| {
|
|
27
27
|
let name = &caps[1];
|
|
28
28
|
let attrs = &caps[2];
|
|
29
29
|
let (kept, relocated) = relocate_unsafe(attrs);
|
|
@@ -41,9 +41,7 @@ pub fn protect(html: &str) -> String {
|
|
|
41
41
|
name, body, NAME_BIND_ATTR, relocated, self_close
|
|
42
42
|
)
|
|
43
43
|
})
|
|
44
|
-
.to_string()
|
|
45
|
-
|
|
46
|
-
restore_raw_text(&out, &raw_blocks)
|
|
44
|
+
.to_string()
|
|
47
45
|
}
|
|
48
46
|
|
|
49
47
|
/// Matches an opening tag and captures (name, attribute-section). Quoted values may
|
|
@@ -124,33 +122,6 @@ fn relocate_unsafe(attrs: &str) -> (String, String) {
|
|
|
124
122
|
(kept, relocated)
|
|
125
123
|
}
|
|
126
124
|
|
|
127
|
-
fn raw_text_regex() -> &'static Regex {
|
|
128
|
-
static RE: OnceLock<Regex> = OnceLock::new();
|
|
129
|
-
RE.get_or_init(|| Regex::new(r"(?is)<(script|style)\b[^>]*>.*?</(?:script|style)>").unwrap())
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
fn mask_raw_text(html: &str) -> (String, Vec<String>) {
|
|
133
|
-
let mut blocks = Vec::new();
|
|
134
|
-
let mut out = String::with_capacity(html.len());
|
|
135
|
-
let mut last = 0;
|
|
136
|
-
for mat in raw_text_regex().find_iter(html) {
|
|
137
|
-
out.push_str(&html[last..mat.start()]);
|
|
138
|
-
out.push_str(&format!("<!--VIBE_RAWTEXT_{}-->", blocks.len()));
|
|
139
|
-
blocks.push(html[mat.start()..mat.end()].to_string());
|
|
140
|
-
last = mat.end();
|
|
141
|
-
}
|
|
142
|
-
out.push_str(&html[last..]);
|
|
143
|
-
(out, blocks)
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
fn restore_raw_text(html: &str, blocks: &[String]) -> String {
|
|
147
|
-
let mut out = html.to_string();
|
|
148
|
-
for (i, block) in blocks.iter().enumerate() {
|
|
149
|
-
out = out.replace(&format!("<!--VIBE_RAWTEXT_{}-->", i), block);
|
|
150
|
-
}
|
|
151
|
-
out
|
|
152
|
-
}
|
|
153
|
-
|
|
154
125
|
#[cfg(test)]
|
|
155
126
|
mod tests {
|
|
156
127
|
use super::*;
|
|
@@ -160,7 +131,7 @@ mod tests {
|
|
|
160
131
|
let html = r#"<icon @[EQUIPMENT(item, true).element]></icon>"#;
|
|
161
132
|
let out = protect(html);
|
|
162
133
|
assert!(
|
|
163
|
-
out.contains(r#"data-vibe-namebind="@[EQUIPMENT(item, true).element]""#),
|
|
134
|
+
out.contains(r#"data-vibe-internal-namebind="@[EQUIPMENT(item, true).element]""#),
|
|
164
135
|
"got: {}",
|
|
165
136
|
out
|
|
166
137
|
);
|
|
@@ -191,7 +162,7 @@ mod tests {
|
|
|
191
162
|
let html = r#"<script>const x = "@[fn(a, b)]";</script><icon @[fn(a, b)]></icon>"#;
|
|
192
163
|
let out = protect(html);
|
|
193
164
|
assert!(out.contains(r#"const x = "@[fn(a, b)]";"#), "script mangled: {}", out);
|
|
194
|
-
assert!(out.contains(r#"data-vibe-namebind="@[fn(a, b)]""#), "got: {}", out);
|
|
165
|
+
assert!(out.contains(r#"data-vibe-internal-namebind="@[fn(a, b)]""#), "got: {}", out);
|
|
195
166
|
}
|
|
196
167
|
|
|
197
168
|
#[test]
|
|
@@ -199,7 +170,7 @@ mod tests {
|
|
|
199
170
|
let html = r#"<el @[fn(a, b)] @[gn(c, d)]></el>"#;
|
|
200
171
|
let out = protect(html);
|
|
201
172
|
assert!(
|
|
202
|
-
out.contains(r#"data-vibe-namebind="@[fn(a, b)]@[gn(c, d)]""#),
|
|
173
|
+
out.contains(r#"data-vibe-internal-namebind="@[fn(a, b)]@[gn(c, d)]""#),
|
|
203
174
|
"got: {}",
|
|
204
175
|
out
|
|
205
176
|
);
|
package/src/compiler/spa.rs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// filter over pages, not a rework.
|
|
10
10
|
|
|
11
11
|
use regex::Regex;
|
|
12
|
+
use crate::constants::{SPA_FRAGMENTS_DIR, FOUC_ATTR};
|
|
12
13
|
|
|
13
14
|
/// One page of the pages tree, dissected for SPA output.
|
|
14
15
|
pub struct SpaPage {
|
|
@@ -247,7 +248,7 @@ pub fn dissect_page(rel_path: &str, html: &str) -> Result<SpaPage, String> {
|
|
|
247
248
|
Ok(SpaPage {
|
|
248
249
|
rel_path: rel_path.to_string(),
|
|
249
250
|
route: route_for(rel_path),
|
|
250
|
-
src: format!("/components/
|
|
251
|
+
src: format!("/components/{}/{}", SPA_FRAGMENTS_DIR, rel_path),
|
|
251
252
|
title,
|
|
252
253
|
fragment,
|
|
253
254
|
head_items,
|
|
@@ -470,7 +471,7 @@ pub fn compose_shell(
|
|
|
470
471
|
// hyperspeed manifest loading on it). The shell is compiled output:
|
|
471
472
|
// strip the attribute from the union — there is nothing to flash, the
|
|
472
473
|
// outlet is empty and fragments hydrate off-DOM before insertion.
|
|
473
|
-
body_attrs.retain(|(n, _)| n !=
|
|
474
|
+
body_attrs.retain(|(n, _)| n != FOUC_ATTR);
|
|
474
475
|
|
|
475
476
|
let body_attr_str = body_attrs
|
|
476
477
|
.iter()
|
|
@@ -16,49 +16,8 @@ fn primitive_to_text(value: &Value) -> String {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const NON_REACTIVE_ELEMENTS: &[&str] = &["script", "head", "pre"];
|
|
22
|
-
|
|
23
|
-
// Attributes where the string value is meaningful (should NOT be removed when falsy)
|
|
24
|
-
// All other attributes are treated as boolean-like (removed when falsy)
|
|
25
|
-
// Matches runtime/constants.js VALUE_ATTRS
|
|
26
|
-
const VALUE_ATTRS: &[&str] = &[
|
|
27
|
-
// Global attributes
|
|
28
|
-
"class", "style", "id", "title", "lang", "dir", "tabindex", "accesskey",
|
|
29
|
-
"slot", "part", "is", "nonce", "popover", "anchor",
|
|
30
|
-
// Enumerated attributes
|
|
31
|
-
"contenteditable", "draggable", "spellcheck", "translate", "autocapitalize",
|
|
32
|
-
"inputmode", "enterkeyhint", "virtualkeyboardpolicy",
|
|
33
|
-
// URLs and sources
|
|
34
|
-
"href", "src", "action", "cite", "data", "poster", "srcset", "imagesrcset",
|
|
35
|
-
"formaction", "ping", "usemap", "manifest", "codebase",
|
|
36
|
-
// Form attributes
|
|
37
|
-
"name", "type", "value", "placeholder", "pattern", "min", "max", "step",
|
|
38
|
-
"minlength", "maxlength", "size", "accept", "autocomplete", "list", "form",
|
|
39
|
-
"formmethod", "formtarget", "formenctype", "wrap", "method", "enctype", "for", "dirname",
|
|
40
|
-
// Text/accessibility
|
|
41
|
-
"alt", "label", "summary", "abbr",
|
|
42
|
-
// Dimensions and layout
|
|
43
|
-
"width", "height", "cols", "rows", "span", "rowspan", "colspan",
|
|
44
|
-
"low", "high", "optimum",
|
|
45
|
-
// Link/resource hints
|
|
46
|
-
"target", "rel", "hreflang", "download", "as", "media", "charset",
|
|
47
|
-
"crossorigin", "integrity", "loading", "decoding", "fetchpriority",
|
|
48
|
-
"referrerpolicy", "blocking", "imagesizes", "sizes",
|
|
49
|
-
// Media
|
|
50
|
-
"preload", "kind", "srclang",
|
|
51
|
-
// Meta
|
|
52
|
-
"content", "http-equiv",
|
|
53
|
-
// iframe/embed
|
|
54
|
-
"sandbox", "allow", "srcdoc", "credentialless",
|
|
55
|
-
// Table
|
|
56
|
-
"headers", "scope",
|
|
57
|
-
// Datetime
|
|
58
|
-
"datetime",
|
|
59
|
-
// Object/embed legacy
|
|
60
|
-
"coords", "shape",
|
|
61
|
-
];
|
|
19
|
+
use crate::constants::{NON_REACTIVE_ELEMENTS, VALUE_ATTRS, DEHYDRATE_ATTR, NAMEBIND_ATTR, DEFER_ATTR_PREFIX};
|
|
20
|
+
|
|
62
21
|
|
|
63
22
|
pub struct ValueStamper<'a> {
|
|
64
23
|
state: &'a Value,
|
|
@@ -131,7 +90,7 @@ impl<'a> ValueStamper<'a> {
|
|
|
131
90
|
pub fn stamp_html(&self, html: String) -> Result<String, String> {
|
|
132
91
|
// Extract vibe-dehydrate content to protect it from processing
|
|
133
92
|
// This prevents @[...] markers and iterations/conditionals from being processed
|
|
134
|
-
let dehydrate_regex = Regex::new(r"(?s)<template[^>]*\
|
|
93
|
+
let dehydrate_regex = Regex::new(&format!(r"(?s)<template[^>]*\s{}[^>]*>.*?</template>", DEHYDRATE_ATTR)).unwrap();
|
|
135
94
|
let mut dehydrate_placeholders: Vec<String> = Vec::new();
|
|
136
95
|
let mut html_with_placeholders = html.clone();
|
|
137
96
|
|
|
@@ -160,7 +119,7 @@ impl<'a> ValueStamper<'a> {
|
|
|
160
119
|
|
|
161
120
|
// Skip elements with vibe-dehydrate attribute (content should not be processed)
|
|
162
121
|
// This is a secondary protection in case placeholders aren't used
|
|
163
|
-
let dehydrate_regex = Regex::new(r"(?s)<template[^>]*\
|
|
122
|
+
let dehydrate_regex = Regex::new(&format!(r"(?s)<template[^>]*\s{}[^>]*>.*?</template>", DEHYDRATE_ATTR)).unwrap();
|
|
164
123
|
for mat in dehydrate_regex.find_iter(&html) {
|
|
165
124
|
skip_regions.push((mat.start(), mat.end()));
|
|
166
125
|
}
|
|
@@ -185,52 +144,13 @@ impl<'a> ValueStamper<'a> {
|
|
|
185
144
|
skip_regions.sort_by_key(|r| r.0);
|
|
186
145
|
|
|
187
146
|
// Stamp bindings only in regions that are NOT in skip_regions
|
|
188
|
-
let mut result =
|
|
189
|
-
let mut last_pos = 0;
|
|
190
|
-
|
|
191
|
-
for binding_match in self.binding_regex.find_iter(&html) {
|
|
192
|
-
let match_start = binding_match.start();
|
|
193
|
-
let match_end = binding_match.end();
|
|
194
|
-
|
|
195
|
-
// Check if this binding is inside a skip region
|
|
196
|
-
let should_skip = skip_regions.iter().any(|(start, end)| {
|
|
197
|
-
match_start >= *start && match_end <= *end
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
// Add text before this match
|
|
201
|
-
result.push_str(&html[last_pos..match_start]);
|
|
202
|
-
|
|
203
|
-
if should_skip {
|
|
204
|
-
// Keep the binding marker as-is
|
|
205
|
-
result.push_str(binding_match.as_str());
|
|
206
|
-
} else {
|
|
207
|
-
// Stamp the binding
|
|
208
|
-
let caps = self.binding_regex.captures(binding_match.as_str()).unwrap();
|
|
209
|
-
let expr = &caps[1];
|
|
210
|
-
// A bare reference to a proven-constant global key (`@[version]`)
|
|
211
|
-
// stamps its build-time value. Any compound expression falls
|
|
212
|
-
// through to normal evaluation, where dynamic globals stay absent
|
|
213
|
-
// and the binding is left live for the runtime.
|
|
214
|
-
if let Some(text) = self.constant_texts.get(expr.trim()) {
|
|
215
|
-
result.push_str(text);
|
|
216
|
-
} else {
|
|
217
|
-
let stamped = self.eval_expression(expr)
|
|
218
|
-
.unwrap_or_else(|| binding_match.as_str().to_string());
|
|
219
|
-
result.push_str(&stamped);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
last_pos = match_end;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Add remaining text
|
|
227
|
-
result.push_str(&html[last_pos..]);
|
|
147
|
+
let (mut result, unresolved) = self.stamp_bindings_tracked(&html, &skip_regions);
|
|
228
148
|
|
|
229
149
|
// Clean up attributes that still contain unresolved markers. Selective
|
|
230
150
|
// mode keeps them: an unresolved marker there is runtime-owned state
|
|
231
151
|
// (the outlet's `src="@[page.src]"`), not an absent iteration property.
|
|
232
152
|
if !self.selective {
|
|
233
|
-
result = self.cleanup_unresolved_attributes(result);
|
|
153
|
+
result = self.cleanup_unresolved_attributes(result, &unresolved);
|
|
234
154
|
}
|
|
235
155
|
|
|
236
156
|
// Restore vibe-dehydrate content from placeholders
|
|
@@ -337,14 +257,10 @@ impl<'a> ValueStamper<'a> {
|
|
|
337
257
|
item_html = temp_stamper.render_conditionals(item_html)?;
|
|
338
258
|
|
|
339
259
|
// Finally stamp all bindings in this item's context
|
|
340
|
-
|
|
341
|
-
let expr = &caps[1];
|
|
342
|
-
temp_stamper.eval_expression(expr)
|
|
343
|
-
.unwrap_or_else(|| caps[0].to_string())
|
|
344
|
-
}).to_string();
|
|
260
|
+
let (stamped_item, item_unresolved) = temp_stamper.stamp_bindings_tracked(&item_html, &[]);
|
|
345
261
|
|
|
346
262
|
// Clean up attributes with unresolved markers (e.g., data-tutorial="@[item.id]" when item.id is undefined)
|
|
347
|
-
item_html = temp_stamper.cleanup_unresolved_attributes(
|
|
263
|
+
item_html = temp_stamper.cleanup_unresolved_attributes(stamped_item, &item_unresolved);
|
|
348
264
|
|
|
349
265
|
rendered_items.push(item_html);
|
|
350
266
|
}
|
|
@@ -744,14 +660,74 @@ impl<'a> ValueStamper<'a> {
|
|
|
744
660
|
/// - Present with empty value when truthy (HTML5 boolean attribute syntax)
|
|
745
661
|
/// Remove attributes that still contain unresolved markers (e.g., data-tutorial="@[item.id]")
|
|
746
662
|
/// These occur when iterating over items where some don't have the referenced property
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
663
|
+
/// Stamp every binding the state can resolve, keeping the rest as markers,
|
|
664
|
+
/// and record where each kept marker landed in the output so cleanup can
|
|
665
|
+
/// tell an unresolved binding apart from resolved data that happens to
|
|
666
|
+
/// contain literal binding syntax.
|
|
667
|
+
fn stamp_bindings_tracked(&self, html: &str, skip_regions: &[(usize, usize)]) -> (String, Vec<usize>) {
|
|
668
|
+
let mut result = String::new();
|
|
669
|
+
let mut unresolved: Vec<usize> = Vec::new();
|
|
670
|
+
let mut last_pos = 0;
|
|
671
|
+
|
|
672
|
+
for binding_match in self.binding_regex.find_iter(html) {
|
|
673
|
+
let match_start = binding_match.start();
|
|
674
|
+
let match_end = binding_match.end();
|
|
675
|
+
|
|
676
|
+
let should_skip = skip_regions.iter().any(|(start, end)| {
|
|
677
|
+
match_start >= *start && match_end <= *end
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
result.push_str(&html[last_pos..match_start]);
|
|
681
|
+
|
|
682
|
+
if should_skip {
|
|
683
|
+
result.push_str(binding_match.as_str());
|
|
684
|
+
} else {
|
|
685
|
+
let caps = self.binding_regex.captures(binding_match.as_str()).unwrap();
|
|
686
|
+
let expr = &caps[1];
|
|
687
|
+
// A bare reference to a proven-constant global key (`@[version]`)
|
|
688
|
+
// stamps its build-time value. Any compound expression falls
|
|
689
|
+
// through to normal evaluation, where dynamic globals stay absent
|
|
690
|
+
// and the binding is left live for the runtime.
|
|
691
|
+
if let Some(text) = self.constant_texts.get(expr.trim()) {
|
|
692
|
+
result.push_str(text);
|
|
693
|
+
} else if let Some(stamped) = self.eval_expression(expr) {
|
|
694
|
+
result.push_str(&stamped);
|
|
695
|
+
} else {
|
|
696
|
+
unresolved.push(result.len());
|
|
697
|
+
result.push_str(binding_match.as_str());
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
last_pos = match_end;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
result.push_str(&html[last_pos..]);
|
|
705
|
+
(result, unresolved)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
fn cleanup_unresolved_attributes(&self, html: String, unresolved: &[usize]) -> String {
|
|
709
|
+
// An attribute holding a genuinely unresolved binding is rewritten to
|
|
710
|
+
// its deferred data-vibe- form, so the browser never acts on the
|
|
711
|
+
// literal and the runtime folds it back at hydration. Attributes whose
|
|
712
|
+
// markers are resolved data are left verbatim.
|
|
713
|
+
let attr_with_marker = Regex::new(r#"\s([\w-]+)="([^"]*@\[[^"]*)""#).unwrap();
|
|
714
|
+
|
|
715
|
+
attr_with_marker.replace_all(&html, |caps: &Captures| {
|
|
716
|
+
let whole = caps.get(0).unwrap();
|
|
717
|
+
let carries_unresolved = unresolved
|
|
718
|
+
.iter()
|
|
719
|
+
.any(|pos| *pos >= whole.start() && *pos < whole.end());
|
|
720
|
+
if !carries_unresolved {
|
|
721
|
+
return caps[0].to_string();
|
|
722
|
+
}
|
|
723
|
+
let name = &caps[1];
|
|
724
|
+
if name == NAMEBIND_ATTR {
|
|
725
|
+
return String::new();
|
|
726
|
+
}
|
|
727
|
+
if name.starts_with(DEFER_ATTR_PREFIX) {
|
|
728
|
+
return caps[0].to_string();
|
|
729
|
+
}
|
|
730
|
+
format!(" {}{}=\"{}\"", DEFER_ATTR_PREFIX, name, &caps[2])
|
|
755
731
|
}).to_string()
|
|
756
732
|
}
|
|
757
733
|
|
package/src/compiler/watcher.rs
CHANGED
|
@@ -8,6 +8,7 @@ use regex::Regex;
|
|
|
8
8
|
|
|
9
9
|
use super::{Compiler, compile::should_skip_path};
|
|
10
10
|
use crate::config::Config;
|
|
11
|
+
use crate::constants::{HYPERSPEED_DIR, MANIFEST_SUFFIX};
|
|
11
12
|
|
|
12
13
|
/// Tracks which pages and components depend on which components
|
|
13
14
|
pub struct DependencyGraph {
|
|
@@ -803,7 +804,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
803
804
|
}
|
|
804
805
|
|
|
805
806
|
// Delete corresponding manifest
|
|
806
|
-
let manifest_path = canonical_output.join(
|
|
807
|
+
let manifest_path = canonical_output.join(HYPERSPEED_DIR).join(format!("{}{}", relative_path.display(), MANIFEST_SUFFIX));
|
|
807
808
|
if manifest_path.exists() {
|
|
808
809
|
if let Err(e) = std::fs::remove_file(&manifest_path) {
|
|
809
810
|
eprintln!("{}: Failed to delete manifest {}: {}", "Warning".yellow(), manifest_path.display(), e);
|
package/src/constants.rs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
pub const DEFER_ATTR_PREFIX: &str = "data-vibe-";
|
|
2
|
+
// Declared for the JS↔Rust drift check; the compiler only ever names the
|
|
3
|
+
// derived attributes below, and narrowing to internal is DEFER_ATTR_PREFIX's job.
|
|
4
|
+
#[allow(dead_code)]
|
|
5
|
+
pub const INTERNAL_ATTR_PREFIX: &str = "data-vibe-internal-";
|
|
6
|
+
pub const COMPONENT_ID_ATTR: &str = "data-vibe-internal-component-id";
|
|
7
|
+
pub const NAMEBIND_ATTR: &str = "data-vibe-internal-namebind";
|
|
8
|
+
pub const RECURSIVE_SRC_ATTR: &str = "data-vibe-internal-recursive-src";
|
|
9
|
+
pub const CSS_COVER_ATTR: &str = "data-vibe-internal-cover";
|
|
10
|
+
pub const CSS_LAYOUT_ATTR: &str = "data-vibe-internal-css";
|
|
11
|
+
|
|
12
|
+
pub const FOUC_ATTR: &str = "vibe-fouc";
|
|
13
|
+
pub const DEHYDRATE_ATTR: &str = "vibe-dehydrate";
|
|
14
|
+
pub const MODULE_SCRIPT_TYPE: &str = "vibe-module";
|
|
15
|
+
|
|
16
|
+
pub const HYPERSPEED_DIR: &str = "vibe-hyperspeed";
|
|
17
|
+
pub const MANIFEST_SUFFIX: &str = ".manifest.js";
|
|
18
|
+
pub const SPA_FRAGMENTS_DIR: &str = "vibe-spa";
|
|
19
|
+
|
|
20
|
+
pub const COMPONENT_ID_PREFIX: &str = "_c";
|
|
21
|
+
|
|
22
|
+
pub const NON_REACTIVE_ELEMENTS: &[&str] = &["script", "head", "pre"];
|
|
23
|
+
|
|
24
|
+
pub const VALUE_ATTRS: &[&str] = &[
|
|
25
|
+
"class", "style", "id", "title", "lang", "dir", "tabindex", "accesskey",
|
|
26
|
+
"slot", "part", "is", "nonce", "popover", "anchor",
|
|
27
|
+
"contenteditable", "draggable", "spellcheck", "translate", "autocapitalize",
|
|
28
|
+
"inputmode", "enterkeyhint", "virtualkeyboardpolicy",
|
|
29
|
+
"href", "src", "action", "cite", "data", "poster", "srcset", "imagesrcset",
|
|
30
|
+
"formaction", "ping", "usemap", "manifest", "codebase",
|
|
31
|
+
"name", "type", "value", "placeholder", "pattern", "min", "max", "step",
|
|
32
|
+
"minlength", "maxlength", "size", "accept", "autocomplete", "list", "form",
|
|
33
|
+
"formmethod", "formtarget", "formenctype", "wrap", "method", "enctype", "for", "dirname",
|
|
34
|
+
"alt", "label", "summary", "abbr",
|
|
35
|
+
"width", "height", "cols", "rows", "span", "rowspan", "colspan",
|
|
36
|
+
"low", "high", "optimum",
|
|
37
|
+
"target", "rel", "hreflang", "download", "as", "media", "charset",
|
|
38
|
+
"crossorigin", "integrity", "loading", "decoding", "fetchpriority",
|
|
39
|
+
"referrerpolicy", "blocking", "imagesizes", "sizes",
|
|
40
|
+
"preload", "kind", "srclang",
|
|
41
|
+
"content", "http-equiv",
|
|
42
|
+
"sandbox", "allow", "srcdoc", "credentialless",
|
|
43
|
+
"headers", "scope",
|
|
44
|
+
"datetime",
|
|
45
|
+
"coords", "shape",
|
|
46
|
+
];
|
package/src/inert.rs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
use regex::Regex;
|
|
2
|
+
use std::sync::OnceLock;
|
|
3
|
+
|
|
4
|
+
use crate::parser::html::HtmlParser;
|
|
5
|
+
|
|
6
|
+
pub const DEHYDRATE_ATTR: &str = crate::constants::DEHYDRATE_ATTR;
|
|
7
|
+
|
|
8
|
+
const RAW_TEXT_ELEMENTS: [&str; 2] = ["script", "style"];
|
|
9
|
+
const NON_REACTIVE_ELEMENTS: [&str; 1] = ["pre"];
|
|
10
|
+
|
|
11
|
+
#[derive(Default)]
|
|
12
|
+
pub struct Inert {
|
|
13
|
+
blocks: Vec<String>,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
impl Inert {
|
|
17
|
+
pub fn new() -> Self {
|
|
18
|
+
Self::default()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
pub fn mask(&mut self, html: &str) -> String {
|
|
22
|
+
let mut out = String::with_capacity(html.len());
|
|
23
|
+
let mut cursor = 0;
|
|
24
|
+
|
|
25
|
+
while let Some(caps) = open_tag_regex().captures(&html[cursor..]) {
|
|
26
|
+
let open_tag = caps.get(0).unwrap();
|
|
27
|
+
let start = cursor + open_tag.start();
|
|
28
|
+
let after_open = cursor + open_tag.end();
|
|
29
|
+
let name = caps[1].to_ascii_lowercase();
|
|
30
|
+
let attrs = caps.get(2).map(|m| m.as_str()).unwrap_or("");
|
|
31
|
+
|
|
32
|
+
let region_end = if RAW_TEXT_ELEMENTS.contains(&name.as_str()) {
|
|
33
|
+
raw_text_end(html, after_open, &name)
|
|
34
|
+
} else if NON_REACTIVE_ELEMENTS.contains(&name.as_str()) || is_dehydrated(attrs) {
|
|
35
|
+
HtmlParser::find_matching_close(html, after_open, &name).map(|(_, end)| end)
|
|
36
|
+
} else {
|
|
37
|
+
None
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
match region_end {
|
|
41
|
+
Some(end) => {
|
|
42
|
+
out.push_str(&html[cursor..start]);
|
|
43
|
+
out.push_str(&placeholder(self.blocks.len()));
|
|
44
|
+
self.blocks.push(html[start..end].to_string());
|
|
45
|
+
cursor = end;
|
|
46
|
+
}
|
|
47
|
+
None => {
|
|
48
|
+
out.push_str(&html[cursor..after_open]);
|
|
49
|
+
cursor = after_open;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
out.push_str(&html[cursor..]);
|
|
55
|
+
out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
pub fn restore(&self, html: &str) -> String {
|
|
59
|
+
let mut out = html.to_string();
|
|
60
|
+
for (index, block) in self.blocks.iter().enumerate().rev() {
|
|
61
|
+
out = out.replace(&placeholder(index), block);
|
|
62
|
+
}
|
|
63
|
+
out
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
pub fn protect(html: &str, rewrite: impl FnOnce(&str) -> String) -> String {
|
|
68
|
+
let mut inert = Inert::new();
|
|
69
|
+
let masked = inert.mask(html);
|
|
70
|
+
inert.restore(&rewrite(&masked))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fn placeholder(index: usize) -> String {
|
|
74
|
+
format!("<!--vibe-inert-{}-->", index)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn open_tag_regex() -> &'static Regex {
|
|
78
|
+
static RE: OnceLock<Regex> = OnceLock::new();
|
|
79
|
+
RE.get_or_init(|| {
|
|
80
|
+
Regex::new(r#"<([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^<>"'])*)>"#).unwrap()
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn close_tag_regex() -> &'static Regex {
|
|
85
|
+
static RE: OnceLock<Regex> = OnceLock::new();
|
|
86
|
+
RE.get_or_init(|| Regex::new(r"(?i)</([a-zA-Z][a-zA-Z0-9-]*)\s*>").unwrap())
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fn dehydrate_regex() -> &'static Regex {
|
|
90
|
+
static RE: OnceLock<Regex> = OnceLock::new();
|
|
91
|
+
RE.get_or_init(|| {
|
|
92
|
+
Regex::new(&format!(
|
|
93
|
+
r#"(?i)(?:^|[\s"']){}(?:[\s="'/]|$)"#,
|
|
94
|
+
regex::escape(DEHYDRATE_ATTR)
|
|
95
|
+
))
|
|
96
|
+
.unwrap()
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fn is_dehydrated(attrs: &str) -> bool {
|
|
101
|
+
dehydrate_regex().is_match(attrs)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fn raw_text_end(html: &str, after_open: usize, name: &str) -> Option<usize> {
|
|
105
|
+
close_tag_regex()
|
|
106
|
+
.captures_iter(&html[after_open..])
|
|
107
|
+
.find(|caps| caps[1].eq_ignore_ascii_case(name))
|
|
108
|
+
.map(|caps| after_open + caps.get(0).unwrap().end())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#[cfg(test)]
|
|
112
|
+
mod tests {
|
|
113
|
+
use super::*;
|
|
114
|
+
|
|
115
|
+
#[test]
|
|
116
|
+
fn masks_script_body_and_restores_it_verbatim() {
|
|
117
|
+
let html = r#"<a></a><script>const t = '<component src="/c.html"></component>';</script><b></b>"#;
|
|
118
|
+
let mut inert = Inert::new();
|
|
119
|
+
let masked = inert.mask(html);
|
|
120
|
+
|
|
121
|
+
assert!(!masked.contains("<component"), "script body still exposed: {masked}");
|
|
122
|
+
assert!(masked.contains("<a></a>") && masked.contains("<b></b>"));
|
|
123
|
+
assert_eq!(inert.restore(&masked), html);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#[test]
|
|
127
|
+
fn masks_dehydrated_subtree_by_attribute_and_by_class() {
|
|
128
|
+
let html = r#"<code vibe-dehydrate><badge></badge></code><code class="vibe-dehydrate"><badge></badge></code>"#;
|
|
129
|
+
let mut inert = Inert::new();
|
|
130
|
+
let masked = inert.mask(html);
|
|
131
|
+
|
|
132
|
+
assert!(!masked.contains("<badge>"), "dehydrated markup still exposed: {masked}");
|
|
133
|
+
assert_eq!(inert.restore(&masked), html);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[test]
|
|
137
|
+
fn leaves_ordinary_markup_untouched() {
|
|
138
|
+
let html = r#"<page><component src="/c.html"></component><data-vibe-dehydrate-note></data-vibe-dehydrate-note></page>"#;
|
|
139
|
+
let mut inert = Inert::new();
|
|
140
|
+
|
|
141
|
+
assert_eq!(inert.mask(html), html);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#[test]
|
|
145
|
+
fn masks_nested_same_tag_to_the_matching_close() {
|
|
146
|
+
let html = r#"<pre><pre><badge></badge></pre></pre><badge></badge>"#;
|
|
147
|
+
let mut inert = Inert::new();
|
|
148
|
+
let masked = inert.mask(html);
|
|
149
|
+
|
|
150
|
+
assert_eq!(masked.matches("<badge>").count(), 1, "nesting mis-counted: {masked}");
|
|
151
|
+
assert_eq!(inert.restore(&masked), html);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#[test]
|
|
155
|
+
fn restores_regions_masked_inside_earlier_placeholders() {
|
|
156
|
+
let mut inert = Inert::new();
|
|
157
|
+
let outer = inert.mask("<pre>kept</pre>");
|
|
158
|
+
let nested = inert.mask(&format!("<pre>{}</pre>", outer));
|
|
159
|
+
|
|
160
|
+
assert!(!nested.contains("kept"));
|
|
161
|
+
assert_eq!(inert.restore(&nested), "<pre><pre>kept</pre></pre>");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#[test]
|
|
165
|
+
fn leaves_unterminated_region_unmasked() {
|
|
166
|
+
let html = r#"<page><pre><badge></badge></page>"#;
|
|
167
|
+
let mut inert = Inert::new();
|
|
168
|
+
|
|
169
|
+
assert_eq!(inert.mask(html), html);
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/main.rs
CHANGED
package/src/parser/html.rs
CHANGED
|
@@ -4,6 +4,8 @@ use std::collections::{HashMap, HashSet};
|
|
|
4
4
|
use thiserror::Error;
|
|
5
5
|
|
|
6
6
|
use super::element::{Element, ElementCache};
|
|
7
|
+
use crate::inert::Inert;
|
|
8
|
+
use crate::constants::{RECURSIVE_SRC_ATTR, MODULE_SCRIPT_TYPE};
|
|
7
9
|
|
|
8
10
|
#[derive(Error, Debug)]
|
|
9
11
|
pub enum ParseError {
|
|
@@ -158,7 +160,7 @@ impl HtmlParser {
|
|
|
158
160
|
// escaped during cache build (see compile.rs::escape_recursive_src).
|
|
159
161
|
// These are runtime-handled component references that the inliner
|
|
160
162
|
// had to skip; the runtime needs them as real `src=` attributes.
|
|
161
|
-
result = result.replace("
|
|
163
|
+
result = result.replace(&format!("{}=", RECURSIVE_SRC_ATTR), "src=");
|
|
162
164
|
|
|
163
165
|
// Step 5: Restore DOCTYPE if it was present
|
|
164
166
|
if let Some(dt) = doctype {
|
|
@@ -178,7 +180,8 @@ impl HtmlParser {
|
|
|
178
180
|
return content.to_string(); // No custom elements to inline
|
|
179
181
|
}
|
|
180
182
|
|
|
181
|
-
let mut
|
|
183
|
+
let mut inert = Inert::new();
|
|
184
|
+
let mut result = inert.mask(content);
|
|
182
185
|
let mut changed = true;
|
|
183
186
|
let mut iterations = 0;
|
|
184
187
|
const MAX_ITERATIONS: usize = 10; // Reasonable limit for deeply nested custom elements
|
|
@@ -244,13 +247,13 @@ impl HtmlParser {
|
|
|
244
247
|
|
|
245
248
|
// Keep the wrapper for consistency with runtime (using generic <component> wrapper)
|
|
246
249
|
// No src attribute = wrapper won't be re-processed
|
|
247
|
-
let wrapper = format!("<component>{}</component>", replacement);
|
|
250
|
+
let wrapper = inert.mask(&format!("<component>{}</component>", replacement));
|
|
248
251
|
result.replace_range(start..end, &wrapper);
|
|
249
252
|
}
|
|
250
253
|
}
|
|
251
254
|
}
|
|
252
255
|
|
|
253
|
-
result
|
|
256
|
+
inert.restore(&result)
|
|
254
257
|
}
|
|
255
258
|
|
|
256
259
|
/// Recursively inline all <component> elements with their HTML content
|
|
@@ -262,7 +265,7 @@ impl HtmlParser {
|
|
|
262
265
|
/// the `<` of `</tag…>` (the slot-content boundary), `end` is just past its
|
|
263
266
|
/// `>`. Because the end tag may carry whitespace before `>` (`</tag\n>`), the
|
|
264
267
|
/// caller must use this `end` rather than assuming a `</tag>`-length tag.
|
|
265
|
-
fn find_matching_close(content: &str, after_open: usize, tag_name: &str) -> Option<(usize, usize)> {
|
|
268
|
+
pub(crate) fn find_matching_close(content: &str, after_open: usize, tag_name: &str) -> Option<(usize, usize)> {
|
|
266
269
|
// (?:\s|>) ensures <component> matches but not <component-foo> (hyphen is not \s or >)
|
|
267
270
|
let open_re = regex::Regex::new(&format!(r"<{}(?:\s|>|/>)", regex::escape(tag_name))).unwrap();
|
|
268
271
|
// `\s*>` tolerates whitespace before the `>` of an end tag — HTML allows
|
|
@@ -324,7 +327,8 @@ impl HtmlParser {
|
|
|
324
327
|
}
|
|
325
328
|
|
|
326
329
|
pub fn inline_single_component(&self, content: &str, component_src: &str, component_content: &str) -> String {
|
|
327
|
-
let mut
|
|
330
|
+
let mut inert = Inert::new();
|
|
331
|
+
let mut result = inert.mask(content);
|
|
328
332
|
|
|
329
333
|
// Normalize the src for matching
|
|
330
334
|
let normalized_src = if component_src.starts_with("http://") || component_src.starts_with("https://") {
|
|
@@ -373,7 +377,7 @@ impl HtmlParser {
|
|
|
373
377
|
// Iter-prop components stay as runtime `<component src>` tags (see
|
|
374
378
|
// is_iter_prop_root). The content is the same for every match, so skip all.
|
|
375
379
|
if is_iter_prop_root(component_content) {
|
|
376
|
-
return result;
|
|
380
|
+
return inert.restore(&result);
|
|
377
381
|
}
|
|
378
382
|
|
|
379
383
|
// Replace from end to start (sorted descending by start position)
|
|
@@ -389,15 +393,16 @@ impl HtmlParser {
|
|
|
389
393
|
replacement = replacement.replace("<slot/>", &slot_wrapped);
|
|
390
394
|
replacement = replacement.replace("<slot />", &slot_wrapped);
|
|
391
395
|
|
|
392
|
-
let wrapper = format!("<component>{}</component>", replacement);
|
|
396
|
+
let wrapper = inert.mask(&format!("<component>{}</component>", replacement));
|
|
393
397
|
result.replace_range(*start..*end, &wrapper);
|
|
394
398
|
}
|
|
395
399
|
|
|
396
|
-
result
|
|
400
|
+
inert.restore(&result)
|
|
397
401
|
}
|
|
398
402
|
|
|
399
403
|
fn inline_component_elements(&self, content: &str, external_cache: &HashMap<String, String>) -> String {
|
|
400
|
-
let mut
|
|
404
|
+
let mut inert = Inert::new();
|
|
405
|
+
let mut result = inert.mask(content);
|
|
401
406
|
|
|
402
407
|
// Match only the opening component tag — slot content is extracted via depth-counting
|
|
403
408
|
// to correctly handle slot content containing </div> or nested <component> elements.
|
|
@@ -471,7 +476,7 @@ impl HtmlParser {
|
|
|
471
476
|
replacement = replacement.replace("<slot/>", &slot_wrapped);
|
|
472
477
|
replacement = replacement.replace("<slot />", &slot_wrapped);
|
|
473
478
|
|
|
474
|
-
let wrapper = format!("<component>{}</component>", replacement);
|
|
479
|
+
let wrapper = inert.mask(&format!("<component>{}</component>", replacement));
|
|
475
480
|
result.replace_range(start..end, &wrapper);
|
|
476
481
|
applied = true;
|
|
477
482
|
break;
|
|
@@ -483,7 +488,7 @@ impl HtmlParser {
|
|
|
483
488
|
}
|
|
484
489
|
}
|
|
485
490
|
|
|
486
|
-
result
|
|
491
|
+
inert.restore(&result)
|
|
487
492
|
}
|
|
488
493
|
|
|
489
494
|
/// Parse component props from attributes string, in source order.
|
|
@@ -514,7 +519,7 @@ impl HtmlParser {
|
|
|
514
519
|
/// script-execution pipeline with identical semantics. Runs before slot
|
|
515
520
|
/// inlining so caller-authored slot scripts stay native.
|
|
516
521
|
fn neuter_component_scripts(html: &str) -> String {
|
|
517
|
-
html.replace("<script type=\"module\"", "<script type=\"
|
|
522
|
+
html.replace("<script type=\"module\"", &format!("<script type=\"{}\"", MODULE_SCRIPT_TYPE))
|
|
518
523
|
}
|
|
519
524
|
|
|
520
525
|
/// A run of HTML attributes where `>` is permitted only inside a quoted value.
|
|
@@ -736,6 +741,10 @@ fn substitute_props(template: &str, props: &[(String, String)]) -> String {
|
|
|
736
741
|
|
|
737
742
|
/// Transform custom HTML elements to divs with classes
|
|
738
743
|
fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) -> String {
|
|
744
|
+
crate::inert::protect(content, |content| transform_custom_tags(content, reserved_elements))
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
fn transform_custom_tags(content: &str, reserved_elements: &[String]) -> String {
|
|
739
748
|
let mut result = content.to_string();
|
|
740
749
|
|
|
741
750
|
// Standard HTML5 elements (should not be transformed)
|
|
@@ -986,6 +995,116 @@ mod tests {
|
|
|
986
995
|
);
|
|
987
996
|
}
|
|
988
997
|
|
|
998
|
+
#[test]
|
|
999
|
+
fn component_tag_inside_script_string_is_not_inlined() {
|
|
1000
|
+
let parser = HtmlParser::new(std::path::PathBuf::from("."));
|
|
1001
|
+
let page = concat!(
|
|
1002
|
+
r#"<page><script>const tpl = '<component src="/components/Badge.html"></component>';</script>"#,
|
|
1003
|
+
r#"<component src="/components/Badge.html"></component></page>"#,
|
|
1004
|
+
);
|
|
1005
|
+
let mut cache = HashMap::new();
|
|
1006
|
+
cache.insert("/components/Badge.html".to_string(), "<badge-pill>hi</badge-pill>".to_string());
|
|
1007
|
+
|
|
1008
|
+
let out = parser.inline_component_elements(page, &cache);
|
|
1009
|
+
|
|
1010
|
+
assert!(
|
|
1011
|
+
out.contains(r#"const tpl = '<component src="/components/Badge.html"></component>';"#),
|
|
1012
|
+
"script string literal was rewritten: {out}"
|
|
1013
|
+
);
|
|
1014
|
+
assert!(
|
|
1015
|
+
out.contains("<component><badge-pill>hi</badge-pill></component>"),
|
|
1016
|
+
"the real component was not inlined: {out}"
|
|
1017
|
+
);
|
|
1018
|
+
assert_eq!(out.matches("<badge-pill>").count(), 1, "template leaked into the script: {out}");
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
#[test]
|
|
1022
|
+
fn component_tag_inside_dehydrate_block_is_not_inlined() {
|
|
1023
|
+
let parser = HtmlParser::new(std::path::PathBuf::from("."));
|
|
1024
|
+
let page = concat!(
|
|
1025
|
+
r#"<docs><code vibe-dehydrate><component src="/components/Badge.html"></component></code>"#,
|
|
1026
|
+
r#"<component src="/components/Badge.html"></component></docs>"#,
|
|
1027
|
+
);
|
|
1028
|
+
let mut cache = HashMap::new();
|
|
1029
|
+
cache.insert("/components/Badge.html".to_string(), "<badge-pill>hi</badge-pill>".to_string());
|
|
1030
|
+
|
|
1031
|
+
let out = parser.inline_component_elements(page, &cache);
|
|
1032
|
+
|
|
1033
|
+
assert!(
|
|
1034
|
+
out.contains(r#"<code vibe-dehydrate><component src="/components/Badge.html"></component></code>"#),
|
|
1035
|
+
"dehydrated markup was inlined: {out}"
|
|
1036
|
+
);
|
|
1037
|
+
assert_eq!(out.matches("<badge-pill>").count(), 1, "template leaked into the dehydrate block: {out}");
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
#[test]
|
|
1041
|
+
fn component_tag_inside_pre_is_not_inlined() {
|
|
1042
|
+
let parser = HtmlParser::new(std::path::PathBuf::from("."));
|
|
1043
|
+
let page = concat!(
|
|
1044
|
+
r#"<docs><pre><component src="/components/Badge.html"></component></pre>"#,
|
|
1045
|
+
r#"<component src="/components/Badge.html"></component></docs>"#,
|
|
1046
|
+
);
|
|
1047
|
+
let mut cache = HashMap::new();
|
|
1048
|
+
cache.insert("/components/Badge.html".to_string(), "<badge-pill>hi</badge-pill>".to_string());
|
|
1049
|
+
|
|
1050
|
+
let out = parser.inline_component_elements(page, &cache);
|
|
1051
|
+
|
|
1052
|
+
assert!(
|
|
1053
|
+
out.contains(r#"<pre><component src="/components/Badge.html"></component></pre>"#),
|
|
1054
|
+
"markup inside <pre> was inlined: {out}"
|
|
1055
|
+
);
|
|
1056
|
+
assert_eq!(out.matches("<badge-pill>").count(), 1, "template leaked into the <pre>: {out}");
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
#[test]
|
|
1060
|
+
fn custom_element_inside_script_string_is_not_inlined() {
|
|
1061
|
+
let mut parser = HtmlParser::new(std::path::PathBuf::from("."));
|
|
1062
|
+
parser.cache.insert(
|
|
1063
|
+
"badge".to_string(),
|
|
1064
|
+
Element::new(
|
|
1065
|
+
"badge".to_string(),
|
|
1066
|
+
std::path::PathBuf::from("badge.html"),
|
|
1067
|
+
"<badge-pill>hi</badge-pill>".to_string(),
|
|
1068
|
+
),
|
|
1069
|
+
);
|
|
1070
|
+
|
|
1071
|
+
let page = concat!(
|
|
1072
|
+
r#"<page><script>el.innerHTML = '<badge></badge>';</script>"#,
|
|
1073
|
+
r#"<badge></badge></page>"#,
|
|
1074
|
+
);
|
|
1075
|
+
|
|
1076
|
+
let out = parser.inline_custom_elements(page);
|
|
1077
|
+
|
|
1078
|
+
assert!(
|
|
1079
|
+
out.contains(r#"el.innerHTML = '<badge></badge>';"#),
|
|
1080
|
+
"script string literal was rewritten: {out}"
|
|
1081
|
+
);
|
|
1082
|
+
assert_eq!(out.matches("<badge-pill>").count(), 1, "template leaked into the script: {out}");
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
#[test]
|
|
1086
|
+
fn custom_element_inside_dehydrate_block_is_not_inlined() {
|
|
1087
|
+
let mut parser = HtmlParser::new(std::path::PathBuf::from("."));
|
|
1088
|
+
parser.cache.insert(
|
|
1089
|
+
"badge".to_string(),
|
|
1090
|
+
Element::new(
|
|
1091
|
+
"badge".to_string(),
|
|
1092
|
+
std::path::PathBuf::from("badge.html"),
|
|
1093
|
+
"<badge-pill>hi</badge-pill>".to_string(),
|
|
1094
|
+
),
|
|
1095
|
+
);
|
|
1096
|
+
|
|
1097
|
+
let page = r#"<docs><code vibe-dehydrate><badge></badge></code><badge></badge></docs>"#;
|
|
1098
|
+
|
|
1099
|
+
let out = parser.inline_custom_elements(page);
|
|
1100
|
+
|
|
1101
|
+
assert!(
|
|
1102
|
+
out.contains(r#"<code vibe-dehydrate><badge></badge></code>"#),
|
|
1103
|
+
"dehydrated markup was inlined: {out}"
|
|
1104
|
+
);
|
|
1105
|
+
assert_eq!(out.matches("<badge-pill>").count(), 1, "template leaked into the dehydrate block: {out}");
|
|
1106
|
+
}
|
|
1107
|
+
|
|
989
1108
|
#[test]
|
|
990
1109
|
fn ordinary_component_still_inlines() {
|
|
991
1110
|
// Guard: a normal component (root is NOT an array-literal each) must still
|