@ape-egg/vibe-compiler 4.1.4 → 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 +5 -5
- 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 +1 -0
- package/src/parser/html.rs +3 -2
|
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,7 +12,7 @@ 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
|
|
@@ -131,7 +131,7 @@ mod tests {
|
|
|
131
131
|
let html = r#"<icon @[EQUIPMENT(item, true).element]></icon>"#;
|
|
132
132
|
let out = protect(html);
|
|
133
133
|
assert!(
|
|
134
|
-
out.contains(r#"data-vibe-namebind="@[EQUIPMENT(item, true).element]""#),
|
|
134
|
+
out.contains(r#"data-vibe-internal-namebind="@[EQUIPMENT(item, true).element]""#),
|
|
135
135
|
"got: {}",
|
|
136
136
|
out
|
|
137
137
|
);
|
|
@@ -162,7 +162,7 @@ mod tests {
|
|
|
162
162
|
let html = r#"<script>const x = "@[fn(a, b)]";</script><icon @[fn(a, b)]></icon>"#;
|
|
163
163
|
let out = protect(html);
|
|
164
164
|
assert!(out.contains(r#"const x = "@[fn(a, b)]";"#), "script mangled: {}", out);
|
|
165
|
-
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);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
#[test]
|
|
@@ -170,7 +170,7 @@ mod tests {
|
|
|
170
170
|
let html = r#"<el @[fn(a, b)] @[gn(c, d)]></el>"#;
|
|
171
171
|
let out = protect(html);
|
|
172
172
|
assert!(
|
|
173
|
-
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)]""#),
|
|
174
174
|
"got: {}",
|
|
175
175
|
out
|
|
176
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
|
@@ -5,6 +5,7 @@ use thiserror::Error;
|
|
|
5
5
|
|
|
6
6
|
use super::element::{Element, ElementCache};
|
|
7
7
|
use crate::inert::Inert;
|
|
8
|
+
use crate::constants::{RECURSIVE_SRC_ATTR, MODULE_SCRIPT_TYPE};
|
|
8
9
|
|
|
9
10
|
#[derive(Error, Debug)]
|
|
10
11
|
pub enum ParseError {
|
|
@@ -159,7 +160,7 @@ impl HtmlParser {
|
|
|
159
160
|
// escaped during cache build (see compile.rs::escape_recursive_src).
|
|
160
161
|
// These are runtime-handled component references that the inliner
|
|
161
162
|
// had to skip; the runtime needs them as real `src=` attributes.
|
|
162
|
-
result = result.replace("
|
|
163
|
+
result = result.replace(&format!("{}=", RECURSIVE_SRC_ATTR), "src=");
|
|
163
164
|
|
|
164
165
|
// Step 5: Restore DOCTYPE if it was present
|
|
165
166
|
if let Some(dt) = doctype {
|
|
@@ -518,7 +519,7 @@ impl HtmlParser {
|
|
|
518
519
|
/// script-execution pipeline with identical semantics. Runs before slot
|
|
519
520
|
/// inlining so caller-authored slot scripts stay native.
|
|
520
521
|
fn neuter_component_scripts(html: &str) -> String {
|
|
521
|
-
html.replace("<script type=\"module\"", "<script type=\"
|
|
522
|
+
html.replace("<script type=\"module\"", &format!("<script type=\"{}\"", MODULE_SCRIPT_TYPE))
|
|
522
523
|
}
|
|
523
524
|
|
|
524
525
|
/// A run of HTML attributes where `>` is permitted only inside a quoted value.
|