@ape-egg/vibe 2.1.3 → 2.1.6
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 +26 -0
- package/README.md +48 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +198 -8
- package/compiler/src/compiler/component_tagger.rs +199 -50
- package/compiler/src/compiler/js_analyzer.rs +179 -36
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/reassignment_analyzer.rs +456 -0
- package/compiler/src/compiler/value_stamper.rs +128 -16
- package/compiler/src/parser/html.rs +114 -4
- 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,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.1.6] - 2026-06-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Compiler 1.9.2 → 1.9.3 — constant-vs-dynamic state analysis before value-stamping** (`compiler/src/compiler/reassignment_analyzer.rs` (new), `value_stamper.rs`, `compile.rs`, `compiler/mod.rs`) — the compiler now proves which global `$` state keys are compile-time constants before baking them into pre-rendered HTML. A key is stamped only when its initial value is a primitive (string/number/bool/null) **and** nothing anywhere writes it (assignment, compound, `++`/`--`, `delete`, or a nested `$.key.x = …`); every other key stays a live `@[...]` binding for the runtime to fill. The rule is deliberately one-sided: a wrong "constant" bakes stale content into production HTML (a correctness bug), a wrong "dynamic" only forgoes the optimization (a brief FOUC), so every uncertain case resolves to dynamic. Whole-program escape signals discard the optimization entirely — aliasing `$` itself (`const x = $`), reflective writes (`Object.assign($, …)`, `Object.defineProperty`), or any source that fails to parse. The `$.key` / `$['key']` (global) vs `$[expr]` (component-state-by-id) split mirrors Vibe's own convention, so a computed component-state write is ignored rather than treated as an escape. Objects and arrays are never constant (a value alias like `const a = $.items; a.push(x)` can mutate them invisibly). Built on an SWC AST visitor with broad unit coverage (`reassignment_analyzer.rs` tests).
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Compiler 1.9.2 → 1.9.3 — state initialized from imports could not be resolved** (`compiler/src/compiler/js_analyzer.rs`) — the state extractor now follows default-import chains across files (`resolve_import_value` / `resolve_export_from_file` / `visit_export_default_expr`), so a `$` key whose initial value comes from an imported module resolves to its real value for stamping instead of being treated as unknown. Also tags components whose state object is built at runtime.
|
|
12
|
+
- **Compiler 1.9.2 → 1.9.3 — `>` inside a prop binding broke component / custom-element inlining** (`compiler/src/parser/html.rs`, `component_tagger.rs`) — the inliner's attribute-run pattern was `[^>]*`, so a comparison inside a quoted binding value (`flipped="@[selectedBrawlers.length >= maxBrawlers]"`) truncated the open tag at that inner `>`, mis-parsing every following attribute and mangling the element. The attribute run now mirrors a real HTML tokenizer (`ATTR_RUN`) — a tag ends only on an *unquoted* `>`, so `>`/`>=` inside any quoted value is part of the value. Repro: `inline_component_tolerates_gt_in_prop_binding`, `inline_custom_element_tolerates_gt_in_prop_binding`.
|
|
13
|
+
|
|
14
|
+
## [2.1.5] - 2026-06-19
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Route-aware manifest resolution for dynamic pages** (`runtime/pre-compiled-manifest.js`) — the candidate-path builder is extracted into an exported, unit-tested `buildManifestCandidatePaths(pathname, route)` and now reads `window.__ROUTE__` (the route template the compiler/dev server injects for dynamic pages, e.g. `/brawlers/:index`). When the route marks a segment dynamic with `:param`, the compiler has already collapsed that segment to `$` in the manifest path, so the runtime points straight at the tokenized manifest (`/vibe-hyperspeed/brawlers/$.html.manifest.js`) first instead of probing literal URLs (`/brawlers/0.html.manifest.js`) that are guaranteed to 404. Params can sit mid-path (`/a/:id/b`), and the candidate list is de-duplicated (subdirectory pages otherwise produced the same URL twice). Static pages with no route hint behave exactly as before. Tests: `runtime/pre-compiled-manifest.test.mjs`.
|
|
19
|
+
- **Compiler 1.9.1 → 1.9.2 — component `<script>` source was HTML-escaped, dropping `component(stateVar)` components** (`compiler/src/compiler/component_tagger.rs`) — re-serializing a `<script>` element from its children loses the rawtext context, so JS operators were entity-escaped (`>` → `>`, `&&` → `&&`). The mangled source then failed to parse, demoting the component to the regex fallback (which only matches `component({` object literals) and leaving a component called with a bare variable untagged. The script's text is now read directly. Adds AST-analyzer coverage for `component(stateVar)` resolution against a realistic script shape (imports, dynamic `import().then()`, `catch {}`, optional chaining).
|
|
20
|
+
- **Compiler 1.9.1 → 1.9.2 — component-local `this.` was only resolved in simple text/attribute bindings** (`compiler/src/compiler/component_tagger.rs`) — build-time `this.` → component-id rewriting now also covers `if`/`each`/`else if` directive comments, nested binding paths (`@[this.x.y]`), multi-reference expressions, and `$.this.X` writes in event-handler bodies (`onclick="$.this.mode = 'edit'"`) — mirroring the runtime's `STATE_THIS_PROP_REGEX` pass. Without this, compiled (manifest-restored) pages — which can't fall back to a `data-vibe-component-id` ancestor lookup — shipped literal `this.`/`$.this.` that resolved to `undefined`. Loop aliases (`each _c0.items as it`) are left untouched.
|
|
21
|
+
|
|
22
|
+
## [2.1.4] - 2026-06-18
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **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).
|
|
27
|
+
- **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).
|
|
28
|
+
|
|
3
29
|
## [2.1.3] - 2026-06-18
|
|
4
30
|
|
|
5
31
|
### Added
|
package/README.md
CHANGED
|
@@ -491,6 +491,54 @@ The compiler generates optimized batch functions for `<!-- each -->` loops, prov
|
|
|
491
491
|
|
|
492
492
|
**Opt-out:** Set `iterationsAsIs: true` to use runtime rendering for all iterations.
|
|
493
493
|
|
|
494
|
+
### Pre-rendered Global State (write state the way the compiler expects)
|
|
495
|
+
|
|
496
|
+
To eliminate the flash of raw `@[...]` markers, the compiler **bakes** any global `$`
|
|
497
|
+
value it can prove is constant straight into the pre-rendered HTML. `BETA v@[version]`
|
|
498
|
+
ships as `BETA v0.1.5`. Everything else stays a live binding the runtime fills on
|
|
499
|
+
hydration.
|
|
500
|
+
|
|
501
|
+
A global key is baked **only** when both hold:
|
|
502
|
+
|
|
503
|
+
- **It's a primitive** — `string` / `number` / `boolean` / `null`. Objects and arrays
|
|
504
|
+
are never baked (their contents can be mutated through a reference the compiler
|
|
505
|
+
can't follow), so an `@[config.theme]` binding always stays live.
|
|
506
|
+
- **It's never reassigned** anywhere the compiler scans (every `.js` module, inline
|
|
507
|
+
`<script>`, and `on*` handler under `source`, minus `skipFiles`). Any `$.key = …`,
|
|
508
|
+
`$.key += …`, `$.key++`, `delete $.key`, or `$['key'] = …` marks it dynamic.
|
|
509
|
+
|
|
510
|
+
This is why **how you write a mutation matters** — write global state through a
|
|
511
|
+
**direct member assignment on `$`**:
|
|
512
|
+
|
|
513
|
+
```js
|
|
514
|
+
$.coins = gameState.coins; // ✅ seen → `coins` stays a live binding
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
If you mutate global state by a path static analysis can't follow, the compiler won't
|
|
518
|
+
see the write and may bake a stale initial value:
|
|
519
|
+
|
|
520
|
+
```js
|
|
521
|
+
const s = $; s.coins = 5; // ⚠️ aliasing $ — disables baking for ALL keys (safe, but loses the optimization)
|
|
522
|
+
applyState($, patch); // ⚠️ if applyState does `arg.coins = …`, that write is INVISIBLE → `coins` may wrongly bake
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Passing `$` as a read-only argument (`derive($, opts)`) is fine; only writing through
|
|
526
|
+
the alias is the problem. Reach for `$.key = …` and the classifier stays correct.
|
|
527
|
+
|
|
528
|
+
The baked value comes from your `vibe({ ... })` initial state, resolved statically
|
|
529
|
+
(including default imports, e.g. `version: VERSION` → `version.js`'s `export default`).
|
|
530
|
+
If the initial value is a runtime call (`settings: loadLocalStorage(...)`), it simply
|
|
531
|
+
isn't baked — the binding stays live, which is harmless.
|
|
532
|
+
|
|
533
|
+
**Iterations follow the same rule.** An `<!-- each -->` over a global/dynamic array is
|
|
534
|
+
rendered **empty** in the compiled HTML (the raw `@[item.x]` template body is dropped,
|
|
535
|
+
not painted), and the runtime restores it from the manifest once the array has data.
|
|
536
|
+
Don't rely on a loop's template body existing in the DOM before hydration.
|
|
537
|
+
|
|
538
|
+
**Still use `vibe-fouc`.** Baking removes raw markers for *constant* values, but
|
|
539
|
+
session/runtime state (auth, server data, anything you reassign) can't be pre-rendered
|
|
540
|
+
— keep the `vibe-fouc` guard so that state doesn't flash either.
|
|
541
|
+
|
|
494
542
|
### Output Structure
|
|
495
543
|
|
|
496
544
|
Mirrors source structure:
|
|
Binary file
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -8,6 +8,7 @@ use colored::Colorize;
|
|
|
8
8
|
use regex::Regex;
|
|
9
9
|
use glob::Pattern;
|
|
10
10
|
use rayon::prelude::*;
|
|
11
|
+
use serde_json::{Map, Value};
|
|
11
12
|
|
|
12
13
|
use crate::config::Config;
|
|
13
14
|
use crate::parser::HtmlParser;
|
|
@@ -116,6 +117,80 @@ pub fn should_skip_path(path: &Path, name: &str, patterns: &[String]) -> bool {
|
|
|
116
117
|
false
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/// Recursively gather JS sources that could write `$` state: every `.js` module
|
|
121
|
+
/// (full text — also a candidate for the global `vibe()` call) plus, from each
|
|
122
|
+
/// `.html` file, every inline `<script>` body and `on*` handler body. Skips the
|
|
123
|
+
/// compiled output directory and anything `should_skip_path` excludes.
|
|
124
|
+
fn collect_state_sources(
|
|
125
|
+
dir: &Path,
|
|
126
|
+
output_canon: Option<&Path>,
|
|
127
|
+
skip: &[String],
|
|
128
|
+
sources: &mut Vec<String>,
|
|
129
|
+
js_paths: &mut Vec<PathBuf>,
|
|
130
|
+
) {
|
|
131
|
+
let entries = match fs::read_dir(dir) {
|
|
132
|
+
Ok(e) => e,
|
|
133
|
+
Err(_) => return,
|
|
134
|
+
};
|
|
135
|
+
for entry in entries.flatten() {
|
|
136
|
+
let path = entry.path();
|
|
137
|
+
let name = match path.file_name().and_then(|n| n.to_str()) {
|
|
138
|
+
Some(n) => n,
|
|
139
|
+
None => continue,
|
|
140
|
+
};
|
|
141
|
+
if should_skip_path(&path, name, skip) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if path.is_dir() {
|
|
145
|
+
// Never descend into the compiled output — its JS is bundled/minified
|
|
146
|
+
// and would falsely trip the analysis.
|
|
147
|
+
if let (Some(oc), Some(pc)) = (output_canon, path.canonicalize().ok()) {
|
|
148
|
+
if pc.as_path() == oc {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
collect_state_sources(&path, output_canon, skip, sources, js_paths);
|
|
153
|
+
} else {
|
|
154
|
+
match path.extension().and_then(|e| e.to_str()) {
|
|
155
|
+
Some("js") => {
|
|
156
|
+
if let Ok(code) = fs::read_to_string(&path) {
|
|
157
|
+
sources.push(code);
|
|
158
|
+
js_paths.push(path);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
Some("html") => {
|
|
162
|
+
if let Ok(html) = fs::read_to_string(&path) {
|
|
163
|
+
sources.extend(extract_inline_scripts(&html));
|
|
164
|
+
sources.extend(extract_event_handlers(&html));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
_ => {}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/// Inner JS of every `<script>…</script>` block.
|
|
174
|
+
fn extract_inline_scripts(html: &str) -> Vec<String> {
|
|
175
|
+
let re = Regex::new(r"(?is)<script\b[^>]*>(.*?)</script>").unwrap();
|
|
176
|
+
re.captures_iter(html)
|
|
177
|
+
.map(|c| c[1].to_string())
|
|
178
|
+
.filter(|s| !s.trim().is_empty())
|
|
179
|
+
.collect()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/// Bodies of `on*="…"` / `on*='…'` event-handler attributes. The attribute-name
|
|
183
|
+
/// match is heuristic (it can catch a non-handler like `only="…"`), so each body
|
|
184
|
+
/// is kept only if it parses as JS — genuine handlers parse, false positives drop.
|
|
185
|
+
fn extract_event_handlers(html: &str) -> Vec<String> {
|
|
186
|
+
use crate::compiler::reassignment_analyzer::parses_as_js;
|
|
187
|
+
let re = Regex::new(r#"(?is)\son[a-z]+\s*=\s*(?:"([^"]*)"|'([^']*)')"#).unwrap();
|
|
188
|
+
re.captures_iter(html)
|
|
189
|
+
.filter_map(|c| c.get(1).or_else(|| c.get(2)).map(|m| m.as_str().to_string()))
|
|
190
|
+
.filter(|body| !body.trim().is_empty() && parses_as_js(body))
|
|
191
|
+
.collect()
|
|
192
|
+
}
|
|
193
|
+
|
|
119
194
|
/// Rename `src="{component_src}"` to `data-vibe-recursive-src="{component_src}"`
|
|
120
195
|
/// on `<component>` / `<div class="component">` tags. Used during cache build
|
|
121
196
|
/// to neutralize cyclic refs before they reach `inline_component_elements` —
|
|
@@ -791,6 +866,7 @@ impl Compiler {
|
|
|
791
866
|
let start = Instant::now();
|
|
792
867
|
let mut pages_processed = 0;
|
|
793
868
|
let mut pages_skipped = 0;
|
|
869
|
+
let global_constants = self.compute_global_constants();
|
|
794
870
|
|
|
795
871
|
for file_path in files {
|
|
796
872
|
// Convert source path to output path
|
|
@@ -825,6 +901,7 @@ impl Compiler {
|
|
|
825
901
|
self.config.components_as_is,
|
|
826
902
|
&self.config.source,
|
|
827
903
|
self.config.root.as_deref(),
|
|
904
|
+
&global_constants,
|
|
828
905
|
) {
|
|
829
906
|
Ok(()) => {
|
|
830
907
|
pages_processed += 1;
|
|
@@ -858,6 +935,7 @@ impl Compiler {
|
|
|
858
935
|
components_as_is: bool,
|
|
859
936
|
source_root: &Path,
|
|
860
937
|
manifest_root: Option<&str>,
|
|
938
|
+
global_constants: &Map<String, Value>,
|
|
861
939
|
) -> Result<(), String> {
|
|
862
940
|
use crate::compiler::manifest_builder::ManifestBuilder;
|
|
863
941
|
use crate::compiler::component_tagger::ComponentTagger;
|
|
@@ -909,7 +987,7 @@ impl Compiler {
|
|
|
909
987
|
// - Iterations are pre-rendered with initial array data
|
|
910
988
|
// The manifest (written above) preserves BOTH branches so the runtime can
|
|
911
989
|
// restore them and switch between branches reactively.
|
|
912
|
-
let stamper = ValueStamper::
|
|
990
|
+
let stamper = ValueStamper::with_constants(&state, components_as_is, global_constants)
|
|
913
991
|
.map_err(|e| format!("Failed to create value stamper: {}", e))?;
|
|
914
992
|
let stamped = stamper.stamp_html(html.to_string())
|
|
915
993
|
.map_err(|e| format!("Failed to stamp HTML: {}", e))?;
|
|
@@ -920,6 +998,60 @@ impl Compiler {
|
|
|
920
998
|
Ok(())
|
|
921
999
|
}
|
|
922
1000
|
|
|
1001
|
+
/// Resolve the global `$` state keys that reassignment analysis proves are
|
|
1002
|
+
/// compile-time constants, mapped to their resolved values — the only globals
|
|
1003
|
+
/// safe to value-stamp into pre-rendered HTML. Scans every JS source under the
|
|
1004
|
+
/// project source root (`.js` modules, inline `<script>` bodies, and `on*`
|
|
1005
|
+
/// handler bodies), skipping the compiled output and the configured skips.
|
|
1006
|
+
/// Returns an empty map on any doubt (see reassignment_analyzer soundness).
|
|
1007
|
+
fn compute_global_constants(&self) -> Map<String, Value> {
|
|
1008
|
+
use crate::compiler::js_analyzer::JsAnalyzer;
|
|
1009
|
+
use crate::compiler::reassignment_analyzer::classify_constant_keys;
|
|
1010
|
+
|
|
1011
|
+
let root = &self.config.source;
|
|
1012
|
+
let output_canon = self.config.output.canonicalize().ok();
|
|
1013
|
+
|
|
1014
|
+
let mut sources: Vec<String> = Vec::new();
|
|
1015
|
+
let mut js_paths: Vec<PathBuf> = Vec::new();
|
|
1016
|
+
collect_state_sources(
|
|
1017
|
+
root,
|
|
1018
|
+
output_canon.as_deref(),
|
|
1019
|
+
&self.config.skip_files,
|
|
1020
|
+
&mut sources,
|
|
1021
|
+
&mut js_paths,
|
|
1022
|
+
);
|
|
1023
|
+
|
|
1024
|
+
// Resolve the global vibe()/state() initial state. Partial resolution
|
|
1025
|
+
// keeps statically-resolvable keys (e.g. `version: VERSION`) and drops
|
|
1026
|
+
// runtime-only ones (`settings: loadLocalStorage(...)`), which is exactly
|
|
1027
|
+
// the set we could ever stamp.
|
|
1028
|
+
let mut global_state: Map<String, Value> = Map::new();
|
|
1029
|
+
for path in &js_paths {
|
|
1030
|
+
let code = match fs::read_to_string(path) {
|
|
1031
|
+
Ok(c) => c,
|
|
1032
|
+
Err(_) => continue,
|
|
1033
|
+
};
|
|
1034
|
+
if !(code.contains("vibe(") || code.contains("state(")) {
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
let mut analyzer = JsAnalyzer::new(root.clone());
|
|
1038
|
+
if let Some(Value::Object(state)) = analyzer.extract_state(&code) {
|
|
1039
|
+
for (key, value) in state {
|
|
1040
|
+
global_state.insert(key, value);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if global_state.is_empty() {
|
|
1045
|
+
return Map::new();
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
let constant_keys = classify_constant_keys(&global_state, &sources);
|
|
1049
|
+
global_state
|
|
1050
|
+
.into_iter()
|
|
1051
|
+
.filter(|(key, _)| constant_keys.contains(key))
|
|
1052
|
+
.collect()
|
|
1053
|
+
}
|
|
1054
|
+
|
|
923
1055
|
/// Generate manifests (called separately from main.rs if needed)
|
|
924
1056
|
pub fn generate_manifests(&self) -> Result<ManifestStats, CompileError> {
|
|
925
1057
|
|
|
@@ -941,6 +1073,9 @@ impl Compiler {
|
|
|
941
1073
|
let iterations_as_is = self.config.iterations_as_is;
|
|
942
1074
|
let components_as_is = self.config.components_as_is;
|
|
943
1075
|
let manifest_root = self.config.root.clone();
|
|
1076
|
+
// Resolve which global state keys are constant once, then share across all
|
|
1077
|
+
// pages (read-only; `&Map` is Sync so the parallel map can borrow it).
|
|
1078
|
+
let global_constants = self.compute_global_constants();
|
|
944
1079
|
|
|
945
1080
|
let results: Vec<_> = html_files
|
|
946
1081
|
.par_iter()
|
|
@@ -957,7 +1092,7 @@ impl Compiler {
|
|
|
957
1092
|
};
|
|
958
1093
|
|
|
959
1094
|
// Try to generate manifest for this file (skip on error)
|
|
960
|
-
match Self::generate_file_manifest(&html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root, manifest_root.as_deref()) {
|
|
1095
|
+
match Self::generate_file_manifest(&html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root, manifest_root.as_deref(), &global_constants) {
|
|
961
1096
|
Ok(()) => (true, None),
|
|
962
1097
|
Err(e) => {
|
|
963
1098
|
if verbose {
|
|
@@ -1969,20 +2104,28 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
|
|
|
1969
2104
|
/// Basic HTML minification
|
|
1970
2105
|
fn minify_html(html: &str) -> String {
|
|
1971
2106
|
let mut result = String::with_capacity(html.len());
|
|
1972
|
-
let mut
|
|
2107
|
+
let mut in_raw = false;
|
|
1973
2108
|
let mut last_was_space = false;
|
|
1974
2109
|
|
|
1975
2110
|
for line in html.lines() {
|
|
1976
2111
|
let trimmed = line.trim();
|
|
1977
2112
|
|
|
1978
|
-
|
|
1979
|
-
|
|
2113
|
+
// <pre>, <script> and <style> carry significant whitespace and must
|
|
2114
|
+
// survive minification verbatim: <pre> is literal text, while a JS
|
|
2115
|
+
// `//` line comment or ASI in <script> breaks the moment its trailing
|
|
2116
|
+
// newline is collapsed into a space (the comment swallows the rest of
|
|
2117
|
+
// the script). Keep these blocks line-for-line, exactly as <pre> always
|
|
2118
|
+
// did. The opening-tag line enters the block before we emit it; the
|
|
2119
|
+
// closing-tag line leaves it (and collapses, which only tightens the
|
|
2120
|
+
// bare `</pre>` / `</script>` / `</style>`).
|
|
2121
|
+
if trimmed.contains("<pre") || trimmed.contains("<script") || trimmed.contains("<style") {
|
|
2122
|
+
in_raw = true;
|
|
1980
2123
|
}
|
|
1981
|
-
if trimmed.contains("</pre>") {
|
|
1982
|
-
|
|
2124
|
+
if trimmed.contains("</pre>") || trimmed.contains("</script>") || trimmed.contains("</style>") {
|
|
2125
|
+
in_raw = false;
|
|
1983
2126
|
}
|
|
1984
2127
|
|
|
1985
|
-
if
|
|
2128
|
+
if in_raw {
|
|
1986
2129
|
result.push_str(line);
|
|
1987
2130
|
result.push('\n');
|
|
1988
2131
|
last_was_space = false;
|
|
@@ -2028,3 +2171,50 @@ fn find_bytes_ci(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
|
|
2028
2171
|
fn count_newlines(bytes: &[u8]) -> usize {
|
|
2029
2172
|
bytes.iter().filter(|&&b| b == b'\n').count()
|
|
2030
2173
|
}
|
|
2174
|
+
|
|
2175
|
+
#[cfg(test)]
|
|
2176
|
+
mod tests {
|
|
2177
|
+
use super::*;
|
|
2178
|
+
|
|
2179
|
+
#[test]
|
|
2180
|
+
fn minify_preserves_script_newlines_so_line_comments_dont_swallow_code() {
|
|
2181
|
+
// A `//` line comment inside a component script relies on its trailing
|
|
2182
|
+
// newline. If minify collapses newlines into spaces, the comment eats
|
|
2183
|
+
// the rest of the script → SyntaxError at runtime (new Function).
|
|
2184
|
+
let html = "<page>\n\
|
|
2185
|
+
<script type=\"module\">\n\
|
|
2186
|
+
\x20 const a = 1; // explain a\n\
|
|
2187
|
+
\x20 // keep explaining\n\
|
|
2188
|
+
\x20 const b = 2;\n\
|
|
2189
|
+
</script>\n\
|
|
2190
|
+
</page>";
|
|
2191
|
+
|
|
2192
|
+
let out = minify_html(html);
|
|
2193
|
+
|
|
2194
|
+
// The code after the comments must still be reachable, i.e. on its own
|
|
2195
|
+
// line rather than glued behind the `//`.
|
|
2196
|
+
let script = &out[out.find("<script").unwrap()..out.find("</script>").unwrap()];
|
|
2197
|
+
assert!(
|
|
2198
|
+
script.contains('\n'),
|
|
2199
|
+
"script newlines were collapsed, // comment swallows following code: {script:?}"
|
|
2200
|
+
);
|
|
2201
|
+
assert!(out.contains("const b = 2"), "code after // comment lost: {out:?}");
|
|
2202
|
+
|
|
2203
|
+
// Surrounding HTML must still be minified (tag boundaries tightened).
|
|
2204
|
+
assert!(out.contains("<page><script"), "non-script HTML not minified: {out:?}");
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
#[test]
|
|
2208
|
+
fn minify_preserves_style_newlines() {
|
|
2209
|
+
let html = "<page>\n\
|
|
2210
|
+
<style>\n\
|
|
2211
|
+
\x20 a { color: red; }\n\
|
|
2212
|
+
\x20 b { color: blue; }\n\
|
|
2213
|
+
</style>\n\
|
|
2214
|
+
</page>";
|
|
2215
|
+
|
|
2216
|
+
let out = minify_html(html);
|
|
2217
|
+
let style = &out[out.find("<style").unwrap()..out.find("</style>").unwrap()];
|
|
2218
|
+
assert!(style.contains('\n'), "style newlines collapsed: {style:?}");
|
|
2219
|
+
}
|
|
2220
|
+
}
|