@ape-egg/vibe 2.1.3 → 2.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +61 -6
- package/compiler/src/compiler/component_tagger.rs +119 -22
- package/compiler/src/compiler/js_analyzer.rs +57 -0
- package/compiler/src/parser/html.rs +33 -1
- package/package.json +1 -1
- package/runtime/pre-compiled-manifest.js +101 -60
- package/runtime/pre-compiled-manifest.test.mjs +58 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.1.4] - 2026-06-18
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Compiler 1.9.0 → 1.9.1 — `--minify` broke `<script>` and `<style>` blocks** (`compiler/src/compiler/compile.rs`) — `minify_html` only treated `<pre>` as whitespace-significant. Collapsing newlines inside a `<script>` turned a `//` line comment (or any ASI-dependent break) into a single line, so the comment swallowed the rest of the script and `new Function` threw a `SyntaxError` at runtime; `<style>` blocks were likewise flattened. Both tags now join `<pre>` as raw blocks emitted line-for-line, while the surrounding HTML still minifies normally. Repro: `minify_preserves_script_newlines_so_line_comments_dont_swallow_code`, `minify_preserves_style_newlines` (`compile.rs` unit tests).
|
|
8
|
+
- **Compiler 1.9.0 → 1.9.1 — component inliner overshot on end tags split across lines** (`compiler/src/parser/html.rs`) — `find_matching_close` matched `</tag>` exactly, but whitespace-controlled markup can split an end tag (`</component\n>`), which HTML permits. The depth counter then counted the nested open without ever seeing its close, so the outer-close search ran past the real boundary and swallowed every following sibling into the component. The close-tag regex now tolerates whitespace before `>` (`</tag\s*>`); `\s*` can't bridge into `</tag-foo>`, so matching stays exact on the tag name. Repro: `find_matching_close_tolerates_whitespace_in_end_tag` (`html.rs` unit test).
|
|
9
|
+
|
|
3
10
|
## [2.1.3] - 2026-06-18
|
|
4
11
|
|
|
5
12
|
### Added
|
|
Binary file
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -1969,20 +1969,28 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
|
|
|
1969
1969
|
/// Basic HTML minification
|
|
1970
1970
|
fn minify_html(html: &str) -> String {
|
|
1971
1971
|
let mut result = String::with_capacity(html.len());
|
|
1972
|
-
let mut
|
|
1972
|
+
let mut in_raw = false;
|
|
1973
1973
|
let mut last_was_space = false;
|
|
1974
1974
|
|
|
1975
1975
|
for line in html.lines() {
|
|
1976
1976
|
let trimmed = line.trim();
|
|
1977
1977
|
|
|
1978
|
-
|
|
1979
|
-
|
|
1978
|
+
// <pre>, <script> and <style> carry significant whitespace and must
|
|
1979
|
+
// survive minification verbatim: <pre> is literal text, while a JS
|
|
1980
|
+
// `//` line comment or ASI in <script> breaks the moment its trailing
|
|
1981
|
+
// newline is collapsed into a space (the comment swallows the rest of
|
|
1982
|
+
// the script). Keep these blocks line-for-line, exactly as <pre> always
|
|
1983
|
+
// did. The opening-tag line enters the block before we emit it; the
|
|
1984
|
+
// closing-tag line leaves it (and collapses, which only tightens the
|
|
1985
|
+
// bare `</pre>` / `</script>` / `</style>`).
|
|
1986
|
+
if trimmed.contains("<pre") || trimmed.contains("<script") || trimmed.contains("<style") {
|
|
1987
|
+
in_raw = true;
|
|
1980
1988
|
}
|
|
1981
|
-
if trimmed.contains("</pre>") {
|
|
1982
|
-
|
|
1989
|
+
if trimmed.contains("</pre>") || trimmed.contains("</script>") || trimmed.contains("</style>") {
|
|
1990
|
+
in_raw = false;
|
|
1983
1991
|
}
|
|
1984
1992
|
|
|
1985
|
-
if
|
|
1993
|
+
if in_raw {
|
|
1986
1994
|
result.push_str(line);
|
|
1987
1995
|
result.push('\n');
|
|
1988
1996
|
last_was_space = false;
|
|
@@ -2028,3 +2036,50 @@ fn find_bytes_ci(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
|
|
2028
2036
|
fn count_newlines(bytes: &[u8]) -> usize {
|
|
2029
2037
|
bytes.iter().filter(|&&b| b == b'\n').count()
|
|
2030
2038
|
}
|
|
2039
|
+
|
|
2040
|
+
#[cfg(test)]
|
|
2041
|
+
mod tests {
|
|
2042
|
+
use super::*;
|
|
2043
|
+
|
|
2044
|
+
#[test]
|
|
2045
|
+
fn minify_preserves_script_newlines_so_line_comments_dont_swallow_code() {
|
|
2046
|
+
// A `//` line comment inside a component script relies on its trailing
|
|
2047
|
+
// newline. If minify collapses newlines into spaces, the comment eats
|
|
2048
|
+
// the rest of the script → SyntaxError at runtime (new Function).
|
|
2049
|
+
let html = "<page>\n\
|
|
2050
|
+
<script type=\"module\">\n\
|
|
2051
|
+
\x20 const a = 1; // explain a\n\
|
|
2052
|
+
\x20 // keep explaining\n\
|
|
2053
|
+
\x20 const b = 2;\n\
|
|
2054
|
+
</script>\n\
|
|
2055
|
+
</page>";
|
|
2056
|
+
|
|
2057
|
+
let out = minify_html(html);
|
|
2058
|
+
|
|
2059
|
+
// The code after the comments must still be reachable, i.e. on its own
|
|
2060
|
+
// line rather than glued behind the `//`.
|
|
2061
|
+
let script = &out[out.find("<script").unwrap()..out.find("</script>").unwrap()];
|
|
2062
|
+
assert!(
|
|
2063
|
+
script.contains('\n'),
|
|
2064
|
+
"script newlines were collapsed, // comment swallows following code: {script:?}"
|
|
2065
|
+
);
|
|
2066
|
+
assert!(out.contains("const b = 2"), "code after // comment lost: {out:?}");
|
|
2067
|
+
|
|
2068
|
+
// Surrounding HTML must still be minified (tag boundaries tightened).
|
|
2069
|
+
assert!(out.contains("<page><script"), "non-script HTML not minified: {out:?}");
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
#[test]
|
|
2073
|
+
fn minify_preserves_style_newlines() {
|
|
2074
|
+
let html = "<page>\n\
|
|
2075
|
+
<style>\n\
|
|
2076
|
+
\x20 a { color: red; }\n\
|
|
2077
|
+
\x20 b { color: blue; }\n\
|
|
2078
|
+
</style>\n\
|
|
2079
|
+
</page>";
|
|
2080
|
+
|
|
2081
|
+
let out = minify_html(html);
|
|
2082
|
+
let style = &out[out.find("<style").unwrap()..out.find("</style>").unwrap()];
|
|
2083
|
+
assert!(style.contains('\n'), "style newlines collapsed: {style:?}");
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
use html5ever::parse_document;
|
|
2
2
|
use html5ever::tendril::TendrilSink;
|
|
3
3
|
use html5ever::serialize::{serialize, SerializeOpts};
|
|
4
|
-
use markup5ever_rcdom::{RcDom, NodeData, Handle, SerializableHandle};
|
|
4
|
+
use markup5ever_rcdom::{RcDom, NodeData, Handle, Node, SerializableHandle};
|
|
5
5
|
use markup5ever::{QualName, LocalName, Namespace};
|
|
6
6
|
use regex::Regex;
|
|
7
7
|
use serde_json::Value;
|
|
@@ -137,15 +137,26 @@ impl ComponentTagger {
|
|
|
137
137
|
for child in node.children.borrow().iter() {
|
|
138
138
|
if let NodeData::Element { name: ref child_name, .. } = child.data {
|
|
139
139
|
if child_name.local.as_ref() == "script" {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
|
|
140
|
+
// Read the script's raw text directly. Serializing the
|
|
141
|
+
// element would HTML-escape JS operators (`>` -> `>`,
|
|
142
|
+
// `&&` -> `&&`) because the serializer loses the
|
|
143
|
+
// rawtext context when it starts at the script's children;
|
|
144
|
+
// the escaped source then fails to parse, dropping a
|
|
145
|
+
// `component(stateVar)` component to the regex fallback
|
|
146
|
+
// (which only matches `component({` literals) and leaving
|
|
147
|
+
// it untagged.
|
|
148
|
+
let mut script_text = String::new();
|
|
149
|
+
for grandchild in child.children.borrow().iter() {
|
|
150
|
+
if let NodeData::Text { ref contents } = grandchild.data {
|
|
151
|
+
script_text.push_str(&contents.borrow());
|
|
152
|
+
}
|
|
148
153
|
}
|
|
154
|
+
// Wrap in <script> so extract_from_html routes it through
|
|
155
|
+
// the JS AST analyzer, which resolves `component(stateVar)`
|
|
156
|
+
// via its declaration (the regex fallback can't).
|
|
157
|
+
direct_scripts_html.push_str("<script>");
|
|
158
|
+
direct_scripts_html.push_str(&script_text);
|
|
159
|
+
direct_scripts_html.push_str("</script>");
|
|
149
160
|
}
|
|
150
161
|
}
|
|
151
162
|
}
|
|
@@ -200,36 +211,84 @@ impl ComponentTagger {
|
|
|
200
211
|
})
|
|
201
212
|
}
|
|
202
213
|
|
|
203
|
-
/// Rewrite this.
|
|
214
|
+
/// Rewrite `this.X` to `componentId.X` throughout a component's subtree:
|
|
215
|
+
/// inside `@[...]` bindings (text + attributes, nested paths and multi-ref
|
|
216
|
+
/// expressions) and inside if/each/else-if directive comments. Resolving the
|
|
217
|
+
/// directives at build time is what lets a component-local `<!-- if this.x -->`
|
|
218
|
+
/// work on compiled (manifest-restored) pages, where the runtime can't fall
|
|
219
|
+
/// back to a `data-vibe-component-id` ancestor lookup.
|
|
204
220
|
fn rewrite_this_to_component_id(node: &Handle, component_id: &str) {
|
|
205
221
|
use regex::Regex;
|
|
206
|
-
|
|
222
|
+
// The whole `@[...]` binding; `this.` is resolved within it so nested
|
|
223
|
+
// paths (this.x.y) and expressions (this.a + this.b) are all covered.
|
|
224
|
+
let binding_regex = Regex::new(r"@\[[^\]]*\]").unwrap();
|
|
225
|
+
let this_prop = Regex::new(r"\bthis\.").unwrap();
|
|
226
|
+
// `$.this.X` writes that live in event-handler bodies (onclick="$.this.mode
|
|
227
|
+
// = 'edit'"), outside any `@[...]`. The runtime lowers these via its
|
|
228
|
+
// STATE_THIS_PROP_REGEX pass (runtime/component.js); the compiler must do
|
|
229
|
+
// the same or compiled pages ship a literal `$.this.X` that resolves to
|
|
230
|
+
// undefined when the native handler fires.
|
|
231
|
+
let state_this_prop = Regex::new(r"\$\.this\.(\w+)").unwrap();
|
|
232
|
+
Self::rewrite_node_recursive(node, &binding_regex, &this_prop, &state_this_prop, component_id);
|
|
233
|
+
}
|
|
207
234
|
|
|
208
|
-
|
|
209
|
-
|
|
235
|
+
/// Resolve `this.` to `componentId.` inside every `@[...]` binding in a string.
|
|
236
|
+
fn rewrite_bindings(s: &str, binding_regex: &Regex, this_prop: &Regex, component_id: &str) -> String {
|
|
237
|
+
let replacement = format!("{}.", component_id);
|
|
238
|
+
binding_regex
|
|
239
|
+
.replace_all(s, |caps: ®ex::Captures| {
|
|
240
|
+
this_prop.replace_all(&caps[0], replacement.as_str()).to_string()
|
|
241
|
+
})
|
|
242
|
+
.to_string()
|
|
210
243
|
}
|
|
211
244
|
|
|
212
|
-
/// Recursively rewrite this.property in text
|
|
213
|
-
fn rewrite_node_recursive(node: &Handle,
|
|
214
|
-
// Rewrite text content
|
|
245
|
+
/// Recursively rewrite this.property in text, attributes, and directive comments
|
|
246
|
+
fn rewrite_node_recursive(node: &Handle, binding_regex: &Regex, this_prop: &Regex, state_this_prop: &Regex, component_id: &str) {
|
|
247
|
+
// Rewrite text content bindings
|
|
215
248
|
if let NodeData::Text { ref contents } = node.data {
|
|
216
249
|
let mut text = contents.borrow_mut();
|
|
217
|
-
let new_text =
|
|
218
|
-
*text = new_text.
|
|
250
|
+
let new_text = Self::rewrite_bindings(&text, binding_regex, this_prop, component_id);
|
|
251
|
+
*text = new_text.into();
|
|
219
252
|
}
|
|
220
253
|
|
|
221
|
-
// Rewrite
|
|
254
|
+
// Rewrite attribute bindings (`@[...]`) and event-handler `$.this.X` writes.
|
|
222
255
|
if let NodeData::Element { ref attrs, .. } = node.data {
|
|
223
256
|
let mut attrs_mut = attrs.borrow_mut();
|
|
224
257
|
for attr in attrs_mut.iter_mut() {
|
|
225
|
-
let new_value =
|
|
226
|
-
|
|
258
|
+
let mut new_value = Self::rewrite_bindings(&attr.value, binding_regex, this_prop, component_id);
|
|
259
|
+
if new_value.contains("$.this.") {
|
|
260
|
+
new_value = state_this_prop
|
|
261
|
+
.replace_all(&new_value, |c: ®ex::Captures| format!("$.{}.{}", component_id, &c[1]))
|
|
262
|
+
.to_string();
|
|
263
|
+
}
|
|
264
|
+
attr.value = new_value.into();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Directive comments are immutable in the RcDom, so swap any if/each/else-if
|
|
269
|
+
// comment carrying a `this.` expression for a freshly-built comment node.
|
|
270
|
+
{
|
|
271
|
+
let mut children = node.children.borrow_mut();
|
|
272
|
+
for child in children.iter_mut() {
|
|
273
|
+
if let NodeData::Comment { ref contents } = child.data {
|
|
274
|
+
let text = contents.to_string();
|
|
275
|
+
let trimmed = text.trim_start();
|
|
276
|
+
let is_directive = trimmed.starts_with("if ")
|
|
277
|
+
|| trimmed.starts_with("each ")
|
|
278
|
+
|| trimmed.starts_with("else if ");
|
|
279
|
+
if is_directive && text.contains("this.") {
|
|
280
|
+
let new_text = this_prop
|
|
281
|
+
.replace_all(&text, format!("{}.", component_id).as_str())
|
|
282
|
+
.to_string();
|
|
283
|
+
*child = Node::new(NodeData::Comment { contents: new_text.into() });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
227
286
|
}
|
|
228
287
|
}
|
|
229
288
|
|
|
230
289
|
// Recurse into children
|
|
231
290
|
for child in node.children.borrow().iter() {
|
|
232
|
-
Self::rewrite_node_recursive(child,
|
|
291
|
+
Self::rewrite_node_recursive(child, binding_regex, this_prop, state_this_prop, component_id);
|
|
233
292
|
}
|
|
234
293
|
}
|
|
235
294
|
}
|
|
@@ -238,6 +297,10 @@ impl ComponentTagger {
|
|
|
238
297
|
mod tests {
|
|
239
298
|
use super::*;
|
|
240
299
|
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
|
|
241
304
|
#[test]
|
|
242
305
|
fn tag_single_component() {
|
|
243
306
|
let html = r#"<!DOCTYPE html><html><body><component><script>component({ count: 0 })</script><div>@[this.count]</div></component></body></html>"#;
|
|
@@ -248,6 +311,40 @@ mod tests {
|
|
|
248
311
|
assert!(result.html.contains("data-vibe-component-id=\"_c0\""));
|
|
249
312
|
}
|
|
250
313
|
|
|
314
|
+
#[test]
|
|
315
|
+
fn rewrites_this_in_directive_comments_and_nested_bindings() {
|
|
316
|
+
// Conditionals/iterations keep `this.` in their directive comments and
|
|
317
|
+
// bindings can be nested (this.x.y). Compiled (manifest-restored) pages
|
|
318
|
+
// can't resolve `this.` via a runtime wrapper-tag lookup, so the compiler
|
|
319
|
+
// must resolve it to the component id at build time.
|
|
320
|
+
let html = r#"<!DOCTYPE html><html><body><component><script>const s = { x: null }; component(s);</script><page-content><!-- if this.x --><span>@[this.x.name]</span><!-- /if --><!-- each this.items as it --><b>@[it]</b><!-- /each --></page-content></component></body></html>"#;
|
|
321
|
+
|
|
322
|
+
let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
|
|
323
|
+
|
|
324
|
+
assert!(result.html.contains("if _c0.x"), "if comment not rewritten: {}", result.html);
|
|
325
|
+
assert!(result.html.contains("@[_c0.x.name]"), "nested binding not rewritten: {}", result.html);
|
|
326
|
+
assert!(result.html.contains("each _c0.items as it"), "each comment not rewritten: {}", result.html);
|
|
327
|
+
// A non-this global expression must be left alone.
|
|
328
|
+
assert!(!result.html.contains("_c0.items as _c0"), "over-rewrote loop alias: {}", result.html);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#[test]
|
|
332
|
+
fn tag_component_called_with_variable() {
|
|
333
|
+
// BrawlerDetailContent shape: state held in a `const`, passed to
|
|
334
|
+
// component(state) as a bare identifier, with a top-level
|
|
335
|
+
// `<!-- if this.X -->`. The wrapper must still be tagged so the
|
|
336
|
+
// conditional can resolve `this.` at runtime.
|
|
337
|
+
let html = r#"<!DOCTYPE html><html><body><component><script>const state = { currentCharacter: null }; component(state);</script><page-content><!-- if this.currentCharacter --><span>@[this.currentCharacter]</span><!-- /if --></page-content></component></body></html>"#;
|
|
338
|
+
|
|
339
|
+
let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
|
|
340
|
+
|
|
341
|
+
assert!(
|
|
342
|
+
result.html.contains("data-vibe-component-id=\"_c0\""),
|
|
343
|
+
"wrapper not tagged; html: {}",
|
|
344
|
+
result.html
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
251
348
|
#[test]
|
|
252
349
|
fn tag_multiple_components() {
|
|
253
350
|
let html = r#"<!DOCTYPE html><html><body><component></component><component></component><component></component></body></html>"#;
|
|
@@ -554,6 +554,63 @@ mod tests {
|
|
|
554
554
|
assert_eq!(state["c"].as_f64().unwrap(), 3.0);
|
|
555
555
|
}
|
|
556
556
|
|
|
557
|
+
#[test]
|
|
558
|
+
fn test_component_called_with_variable() {
|
|
559
|
+
// component(state) — the whole argument is a variable bound to an object
|
|
560
|
+
// literal above (BrawlerDetailContent does exactly this). The extractor
|
|
561
|
+
// must resolve the variable; otherwise the component wrapper is never
|
|
562
|
+
// tagged and its `this.` conditionals can't resolve at runtime.
|
|
563
|
+
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
564
|
+
let script = r#"
|
|
565
|
+
const state = {
|
|
566
|
+
currentCharacter: null,
|
|
567
|
+
currentSlots: [],
|
|
568
|
+
currentLastTickEnd: 0,
|
|
569
|
+
};
|
|
570
|
+
setupCharacter(state);
|
|
571
|
+
const id = component(state);
|
|
572
|
+
"#;
|
|
573
|
+
|
|
574
|
+
let state = analyzer.extract_state(script).expect("state should be extracted");
|
|
575
|
+
assert!(state.get("currentCharacter").is_some(), "got: {state}");
|
|
576
|
+
assert_eq!(state["currentLastTickEnd"].as_f64().unwrap(), 0.0);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
#[test]
|
|
580
|
+
fn test_component_var_amid_realistic_script() {
|
|
581
|
+
// Mirrors BrawlerDetailContent's script shape: top-level imports, a
|
|
582
|
+
// dynamic import().then(), an empty `catch {}`, optional chaining, and
|
|
583
|
+
// `component(state)`. A parse failure on any of these makes extract_state
|
|
584
|
+
// return None and the component goes untagged.
|
|
585
|
+
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
586
|
+
let script = r#"
|
|
587
|
+
import CHARACTERS from '/js/constants/CHARACTERS.js';
|
|
588
|
+
import { buildEquipmentSlots, unequip } from '/js/equipment.js';
|
|
589
|
+
|
|
590
|
+
const backParam = new URLSearchParams(location.search).get('back') || '';
|
|
591
|
+
const state = {
|
|
592
|
+
currentCharacter: null,
|
|
593
|
+
currentSlots: [],
|
|
594
|
+
currentLastTickEnd: 0,
|
|
595
|
+
backUrl: backParam.startsWith('/') ? backParam : '',
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const setupCharacter = (target) => {
|
|
599
|
+
const ref = $.characters?.[0];
|
|
600
|
+
try { ref.foo(); } catch {}
|
|
601
|
+
target.currentCharacter = ref;
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
setupCharacter(state);
|
|
605
|
+
const id = component(state);
|
|
606
|
+
|
|
607
|
+
import('/js/dnd.js').then(({ default: dnd }) => { dnd.init(); });
|
|
608
|
+
"#;
|
|
609
|
+
|
|
610
|
+
let state = analyzer.extract_state(script).expect("state should be extracted");
|
|
611
|
+
assert!(state.get("currentCharacter").is_some(), "got: {state}");
|
|
612
|
+
}
|
|
613
|
+
|
|
557
614
|
#[test]
|
|
558
615
|
fn test_absolute_path_import() {
|
|
559
616
|
let mut analyzer = JsAnalyzer::new(PathBuf::from("/tmp"));
|
|
@@ -261,7 +261,11 @@ impl HtmlParser {
|
|
|
261
261
|
fn find_matching_close(content: &str, after_open: usize, tag_name: &str) -> Option<usize> {
|
|
262
262
|
// (?:\s|>) ensures <component> matches but not <component-foo> (hyphen is not \s or >)
|
|
263
263
|
let open_re = regex::Regex::new(&format!(r"<{}(?:\s|>|/>)", regex::escape(tag_name))).unwrap();
|
|
264
|
-
|
|
264
|
+
// `\s*>` tolerates whitespace before the `>` of an end tag — HTML allows
|
|
265
|
+
// it, and whitespace-controlled markup splits end tags across lines
|
|
266
|
+
// (`</component\n>`). `\s*` can't bridge into `</component-foo>` (the `-`
|
|
267
|
+
// is neither whitespace nor `>`), so this stays exact on the tag name.
|
|
268
|
+
let close_re = regex::Regex::new(&format!(r"</{}\s*>", regex::escape(tag_name))).unwrap();
|
|
265
269
|
|
|
266
270
|
let mut depth = 1i32;
|
|
267
271
|
let mut cursor = after_open;
|
|
@@ -744,3 +748,31 @@ fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) ->
|
|
|
744
748
|
|
|
745
749
|
result
|
|
746
750
|
}
|
|
751
|
+
|
|
752
|
+
#[cfg(test)]
|
|
753
|
+
mod tests {
|
|
754
|
+
use super::*;
|
|
755
|
+
|
|
756
|
+
#[test]
|
|
757
|
+
fn find_matching_close_tolerates_whitespace_in_end_tag() {
|
|
758
|
+
// Whitespace-controlled markup splits an end tag across lines, e.g.
|
|
759
|
+
// <icon-cell
|
|
760
|
+
// ><component src="x"></component
|
|
761
|
+
// ></icon-cell>
|
|
762
|
+
// which reaches the inliner as `</component\n>`. HTML permits
|
|
763
|
+
// whitespace before the `>` of an end tag, so the depth counter must
|
|
764
|
+
// treat `</component\n>` as the nested close. If it doesn't, the nested
|
|
765
|
+
// open is counted but never closed and the OUTER close search overshoots,
|
|
766
|
+
// swallowing every following sibling into the component.
|
|
767
|
+
let content = "<component><a><component src=\"x\"></component\n></a></component><sibling></sibling>";
|
|
768
|
+
let after_open = "<component>".len();
|
|
769
|
+
|
|
770
|
+
let close = HtmlParser::find_matching_close(content, after_open, "component")
|
|
771
|
+
.expect("outer </component> must be found");
|
|
772
|
+
|
|
773
|
+
// The match must be the OUTER close (immediately before <sibling>), not
|
|
774
|
+
// an overshoot past it.
|
|
775
|
+
assert!(content[close..].starts_with("</component>"), "matched wrong close: {:?}", &content[close..close + 14]);
|
|
776
|
+
assert!(content[close..].contains("<sibling>"), "sibling was swallowed into the component");
|
|
777
|
+
}
|
|
778
|
+
}
|
package/package.json
CHANGED
|
@@ -126,6 +126,98 @@ export const buildHyperspeedManifest = (parsedTree) => {
|
|
|
126
126
|
let hyperspeedData = null;
|
|
127
127
|
let hyperspeedDetectionAttempted = false;
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Build the ordered list of manifest URLs to try for a page, most-likely first.
|
|
131
|
+
*
|
|
132
|
+
* `pathname` is window.location.pathname; `route` is the optional route template
|
|
133
|
+
* the page declares (window.__ROUTE__, e.g. "/brawlers/:index"). When the route
|
|
134
|
+
* marks a segment dynamic with `:param`, the compiler has collapsed that segment
|
|
135
|
+
* to `$` in the manifest path — so we point straight at the tokenized manifest
|
|
136
|
+
* instead of probing literal paths (`/brawlers/0.html.manifest.js`) that are
|
|
137
|
+
* guaranteed to 404. Without the route hint the original literal-first strategies
|
|
138
|
+
* apply unchanged. The result is de-duplicated (subdirectory pages otherwise
|
|
139
|
+
* produce the same candidate twice).
|
|
140
|
+
*
|
|
141
|
+
* @param {string} pathname
|
|
142
|
+
* @param {string|null|undefined} route
|
|
143
|
+
* @returns {string[]}
|
|
144
|
+
*/
|
|
145
|
+
export const buildManifestCandidatePaths = (pathname, route) => {
|
|
146
|
+
let pagePath = pathname;
|
|
147
|
+
|
|
148
|
+
// Normalize path: handle directory URLs and missing extensions
|
|
149
|
+
if (pagePath.endsWith("/")) {
|
|
150
|
+
pagePath = pagePath + "index.html";
|
|
151
|
+
} else if (!pagePath.includes(".")) {
|
|
152
|
+
const lastSlash = pagePath.lastIndexOf("/");
|
|
153
|
+
const lastSegment = pagePath.substring(lastSlash + 1);
|
|
154
|
+
if (lastSegment && !lastSegment.includes(".")) {
|
|
155
|
+
pagePath = pagePath + ".html";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const pathSegments = pagePath.split("/").filter((s) => s);
|
|
160
|
+
if (pathSegments.length === 0) return [];
|
|
161
|
+
|
|
162
|
+
const fileName = pathSegments[pathSegments.length - 1];
|
|
163
|
+
const dirSegments = pathSegments.slice(0, -1);
|
|
164
|
+
|
|
165
|
+
const possiblePaths = [];
|
|
166
|
+
|
|
167
|
+
// Route-aware fast path: a `:segment` in the declared route is a dynamic param
|
|
168
|
+
// the compiler tokenized to `$`. Tokenize exactly those positions (params can
|
|
169
|
+
// sit mid-path, e.g. /a/:id/b) and try that manifest first — a direct hit, no
|
|
170
|
+
// 404 probing. Skipped entirely when no route is declared.
|
|
171
|
+
const routeSegments = route ? route.split("/").filter((s) => s) : null;
|
|
172
|
+
if (routeSegments && routeSegments.some((s) => s.startsWith(":"))) {
|
|
173
|
+
const tokenized = pathSegments.map((seg, i) => {
|
|
174
|
+
if (!routeSegments[i]?.startsWith(":")) return seg;
|
|
175
|
+
const dot = seg.indexOf(".");
|
|
176
|
+
return dot >= 0 ? "$" + seg.slice(dot) : "$";
|
|
177
|
+
});
|
|
178
|
+
possiblePaths.push(`/vibe-hyperspeed/${tokenized.join("/")}.manifest.js`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Strategy 1: vibe-hyperspeed at the same level as parent directory
|
|
182
|
+
// /compiled/playground/test.html -> /compiled/vibe-hyperspeed/playground/test.html.manifest.js
|
|
183
|
+
if (dirSegments.length >= 1) {
|
|
184
|
+
const subPath = dirSegments.slice(1).join("/"); // Everything after first dir
|
|
185
|
+
const baseDir = "/" + dirSegments[0]; // First directory segment
|
|
186
|
+
possiblePaths.push(
|
|
187
|
+
`${baseDir}/vibe-hyperspeed/${subPath ? subPath + "/" : ""}${fileName}.manifest.js`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Strategy 2: vibe-hyperspeed at web root (original behavior)
|
|
192
|
+
// /compiled/playground/test.html -> /vibe-hyperspeed/compiled/playground/test.html.manifest.js
|
|
193
|
+
possiblePaths.push(`/vibe-hyperspeed${pagePath}.manifest.js`);
|
|
194
|
+
|
|
195
|
+
// Strategy 3: vibe-hyperspeed relative to immediate parent
|
|
196
|
+
// /playground/test.html -> /vibe-hyperspeed/playground/test.html.manifest.js
|
|
197
|
+
if (dirSegments.length > 0) {
|
|
198
|
+
const relativePath = dirSegments.join("/");
|
|
199
|
+
possiblePaths.push(
|
|
200
|
+
`/vibe-hyperspeed/${relativePath}/${fileName}.manifest.js`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Strategy 4: dynamic routes without a declared route. The compiler collapses a
|
|
205
|
+
// `$param` segment to a single `$` token (the-arena/$id.html ->
|
|
206
|
+
// the-arena/$.html.manifest.js), so a concrete URL only matches once its
|
|
207
|
+
// trailing segment is tokenized. Tried after the literal strategies, so static
|
|
208
|
+
// pages still win on an exact hit.
|
|
209
|
+
const dot = fileName.indexOf(".");
|
|
210
|
+
const tokenized = dot >= 0 ? "$" + fileName.slice(dot) : "$";
|
|
211
|
+
if (tokenized !== fileName) {
|
|
212
|
+
const dirPrefix = dirSegments.length ? `/${dirSegments.join("/")}` : "";
|
|
213
|
+
possiblePaths.push(`/vibe-hyperspeed${dirPrefix}/${tokenized}.manifest.js`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Subdirectory pages make strategies 2 and 3 collapse to the same URL — probe
|
|
217
|
+
// each candidate once.
|
|
218
|
+
return [...new Set(possiblePaths)];
|
|
219
|
+
};
|
|
220
|
+
|
|
129
221
|
/**
|
|
130
222
|
* Detect page-specific manifest (async, cached after first call)
|
|
131
223
|
* Returns { manifest, path } or null
|
|
@@ -143,66 +235,15 @@ const detectHyperspeed = async () => {
|
|
|
143
235
|
const skipNetwork = !!document.querySelector("[vibe-fouc], .vibe-fouc");
|
|
144
236
|
|
|
145
237
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const lastSegment = pagePath.substring(lastSlash + 1);
|
|
156
|
-
if (lastSegment && !lastSegment.includes(".")) {
|
|
157
|
-
pagePath = pagePath + ".html";
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const pathSegments = pagePath.split("/").filter((s) => s);
|
|
162
|
-
|
|
163
|
-
if (pathSegments.length === 0) return null;
|
|
164
|
-
|
|
165
|
-
// Extract file name and directory parts
|
|
166
|
-
// For /compiled/playground/test.html -> ['compiled', 'playground', 'test.html']
|
|
167
|
-
const fileName = pathSegments[pathSegments.length - 1];
|
|
168
|
-
const dirSegments = pathSegments.slice(0, -1); // All parts except filename
|
|
169
|
-
|
|
170
|
-
// Build possible manifest paths
|
|
171
|
-
const possiblePaths = [];
|
|
172
|
-
|
|
173
|
-
// Strategy 1: vibe-hyperspeed at the same level as parent directory
|
|
174
|
-
// /compiled/playground/test.html -> /compiled/vibe-hyperspeed/playground/test.html.manifest.js
|
|
175
|
-
if (dirSegments.length >= 1) {
|
|
176
|
-
const subPath = dirSegments.slice(1).join("/"); // Everything after first dir
|
|
177
|
-
const baseDir = "/" + dirSegments[0]; // First directory segment
|
|
178
|
-
possiblePaths.push(
|
|
179
|
-
`${baseDir}/vibe-hyperspeed/${subPath ? subPath + "/" : ""}${fileName}.manifest.js`,
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Strategy 2: vibe-hyperspeed at web root (original behavior)
|
|
184
|
-
// /compiled/playground/test.html -> /vibe-hyperspeed/compiled/playground/test.html.manifest.js
|
|
185
|
-
possiblePaths.push(`/vibe-hyperspeed${pagePath}.manifest.js`);
|
|
186
|
-
|
|
187
|
-
// Strategy 3: vibe-hyperspeed relative to immediate parent
|
|
188
|
-
// /playground/test.html -> /vibe-hyperspeed/playground/test.html.manifest.js
|
|
189
|
-
if (dirSegments.length > 0) {
|
|
190
|
-
const relativePath = dirSegments.join("/");
|
|
191
|
-
possiblePaths.push(
|
|
192
|
-
`/vibe-hyperspeed/${relativePath}/${fileName}.manifest.js`,
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Strategy 4: dynamic routes. The compiler collapses a `$param` segment to a
|
|
197
|
-
// single `$` token (the-arena/$id.html -> the-arena/$.html.manifest.js), so a
|
|
198
|
-
// concrete URL only matches once its trailing segment is tokenized. Tried
|
|
199
|
-
// after the literal strategies, so static pages still win on an exact hit.
|
|
200
|
-
const dot = fileName.indexOf(".");
|
|
201
|
-
const tokenized = dot >= 0 ? "$" + fileName.slice(dot) : "$";
|
|
202
|
-
if (tokenized !== fileName) {
|
|
203
|
-
const dirPrefix = dirSegments.length ? `/${dirSegments.join("/")}` : "";
|
|
204
|
-
possiblePaths.push(`/vibe-hyperspeed${dirPrefix}/${tokenized}.manifest.js`);
|
|
205
|
-
}
|
|
238
|
+
// window.__ROUTE__ is the page's route template (e.g. "/brawlers/:index"),
|
|
239
|
+
// injected by the compiler/dev server for dynamic pages. It lets us resolve
|
|
240
|
+
// the tokenized `$` manifest directly instead of probing literal 404s.
|
|
241
|
+
const possiblePaths = buildManifestCandidatePaths(
|
|
242
|
+
window.location.pathname,
|
|
243
|
+
typeof window !== "undefined" ? window.__ROUTE__ : null,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (possiblePaths.length === 0) return null;
|
|
206
247
|
|
|
207
248
|
if (!skipNetwork) {
|
|
208
249
|
// Fully-runtime dynamic import. Hidden behind `new Function` so any
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
|
|
3
|
+
// The module runs a top-level `await detectHyperspeed()` that touches the DOM,
|
|
4
|
+
// so stub the globals before importing it. querySelector returns truthy →
|
|
5
|
+
// skipNetwork → no import() probing during module init.
|
|
6
|
+
globalThis.window = { location: { pathname: '/' }, __ROUTE__: undefined };
|
|
7
|
+
globalThis.document = { querySelector: () => ({}) };
|
|
8
|
+
|
|
9
|
+
const { buildManifestCandidatePaths } = await import('./pre-compiled-manifest.js');
|
|
10
|
+
|
|
11
|
+
let passed = 0;
|
|
12
|
+
const test = (name, fn) => {
|
|
13
|
+
fn();
|
|
14
|
+
passed++;
|
|
15
|
+
console.log(` ok - ${name}`);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Dynamic route: window.__ROUTE__ tells us the trailing segment is a param, so
|
|
19
|
+
// the very first candidate must be the tokenized `$` manifest — no literal
|
|
20
|
+
// `0.html.manifest.js` probes that are guaranteed to 404.
|
|
21
|
+
test('dynamic route resolves the $ manifest first (no literal probing)', () => {
|
|
22
|
+
const paths = buildManifestCandidatePaths('/brawlers/0', '/brawlers/:index');
|
|
23
|
+
assert.strictEqual(paths[0], '/vibe-hyperspeed/brawlers/$.html.manifest.js');
|
|
24
|
+
assert.ok(
|
|
25
|
+
!paths.includes('/vibe-hyperspeed/brawlers/0.html.manifest.js') ||
|
|
26
|
+
paths.indexOf('/vibe-hyperspeed/brawlers/$.html.manifest.js') <
|
|
27
|
+
paths.indexOf('/vibe-hyperspeed/brawlers/0.html.manifest.js'),
|
|
28
|
+
'tokenized path must come before any literal path',
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Mid-path params tokenize by position, not just the filename.
|
|
33
|
+
test('tokenizes only the param segments named by the route', () => {
|
|
34
|
+
const paths = buildManifestCandidatePaths(
|
|
35
|
+
'/_internal/characters/troll',
|
|
36
|
+
'/_internal/characters/:key',
|
|
37
|
+
);
|
|
38
|
+
assert.strictEqual(
|
|
39
|
+
paths[0],
|
|
40
|
+
'/vibe-hyperspeed/_internal/characters/$.html.manifest.js',
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Static page: no route hint, behaviour unchanged, no duplicate candidates.
|
|
45
|
+
test('static top-level page resolves cleanly with no duplicates', () => {
|
|
46
|
+
const paths = buildManifestCandidatePaths('/armory', null);
|
|
47
|
+
assert.ok(paths.includes('/vibe-hyperspeed/armory.html.manifest.js'));
|
|
48
|
+
assert.strictEqual(paths.length, new Set(paths).size, 'no duplicate candidates');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Subdirectory static page: strategies 2 and 3 collapse to the same URL — it
|
|
52
|
+
// must be probed once, not twice.
|
|
53
|
+
test('subdirectory page dedupes identical candidates', () => {
|
|
54
|
+
const paths = buildManifestCandidatePaths('/foo/bar', null);
|
|
55
|
+
assert.strictEqual(paths.length, new Set(paths).size, 'no duplicate candidates');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
console.log(`\n${passed} passed`);
|