@ape-egg/vibe 2.3.0 → 3.0.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/README.md +14 -4
- package/boot.js +4 -4
- package/component.js +27 -29
- package/hot-module-refresh.js +4 -4
- package/index.js +10 -15
- package/llms.txt +8 -6
- package/package.json +19 -14
- package/runtime/affected.js +159 -36
- package/runtime/cleanup.js +45 -1
- package/runtime/component.js +312 -99
- package/runtime/conditionals.js +111 -14
- package/runtime/debug.js +24 -0
- package/runtime/dispatch.js +172 -0
- package/runtime/hydrate.js +251 -111
- package/runtime/index.js +180 -71
- package/runtime/iterate.js +125 -50
- package/runtime/iteration-utils.js +59 -8
- package/runtime/manifest.js +77 -2
- package/runtime/parse.js +69 -5
- package/runtime/pre-compiled-iterations.js +19 -6
- package/runtime/pre-compiled-manifest.js +13 -4
- package/runtime/staging.js +153 -0
- package/runtime/state.js +31 -0
- package/runtime/tracking.js +173 -0
- package/runtime/utils.js +155 -78
- package/spa.js +77 -14
- package/vibe.css +8 -4
- package/CHANGELOG.md +0 -1196
- package/ROADMAP.md +0 -397
- package/compiler/bin/vibe-compile.js +0 -121
- package/compiler/native/.gitkeep +0 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +0 -2023
- package/compiler/src/Cargo.toml +0 -38
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
- package/compiler/src/compiler/binding_case.rs +0 -88
- package/compiler/src/compiler/compile.rs +0 -2880
- package/compiler/src/compiler/component_tagger.rs +0 -469
- package/compiler/src/compiler/iteration_optimizer.rs +0 -455
- package/compiler/src/compiler/js_analyzer.rs +0 -715
- package/compiler/src/compiler/manifest_builder.rs +0 -693
- package/compiler/src/compiler/mod.rs +0 -16
- package/compiler/src/compiler/name_binding_protect.rs +0 -207
- package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
- package/compiler/src/compiler/spa.rs +0 -477
- package/compiler/src/compiler/state_extractor.rs +0 -263
- package/compiler/src/compiler/value_stamper.rs +0 -921
- package/compiler/src/compiler/watcher.rs +0 -1278
- package/compiler/src/config.rs +0 -279
- package/compiler/src/main.rs +0 -358
- package/compiler/src/parser/element.rs +0 -96
- package/compiler/src/parser/html.rs +0 -1004
- package/compiler/src/parser/mod.rs +0 -8
- package/runtime/pre-compiled-manifest.test.mjs +0 -58
- package/runtime/scope.js +0 -50
- package/test-results/.last-run.json +0 -4
|
@@ -1,2880 +0,0 @@
|
|
|
1
|
-
use std::fs;
|
|
2
|
-
use std::path::{Path, PathBuf};
|
|
3
|
-
use std::process::Command;
|
|
4
|
-
use std::time::Instant;
|
|
5
|
-
use std::collections::{BTreeMap, HashSet, HashMap};
|
|
6
|
-
use thiserror::Error;
|
|
7
|
-
use colored::Colorize;
|
|
8
|
-
use regex::Regex;
|
|
9
|
-
use glob::Pattern;
|
|
10
|
-
use rayon::prelude::*;
|
|
11
|
-
use serde_json::{Map, Value};
|
|
12
|
-
|
|
13
|
-
use crate::config::Config;
|
|
14
|
-
use crate::parser::HtmlParser;
|
|
15
|
-
|
|
16
|
-
/// Map an output-relative HTML path to the URL path the runtime resolves a
|
|
17
|
-
/// manifest from. Strips the `root` dir (served at /) and collapses dynamic
|
|
18
|
-
/// `$param` segments to a `$` token, e.g. with root `pages`:
|
|
19
|
-
/// pages/armory.html -> armory.html
|
|
20
|
-
/// pages/the-arena/$id.html -> the-arena/$.html
|
|
21
|
-
fn manifest_url_path(relative_path: &str, root: Option<&str>) -> String {
|
|
22
|
-
let stripped = match root {
|
|
23
|
-
Some(r) if relative_path == r => "",
|
|
24
|
-
Some(r) if relative_path.starts_with(&format!("{}/", r)) => &relative_path[r.len() + 1..],
|
|
25
|
-
_ => relative_path,
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
stripped
|
|
29
|
-
.split('/')
|
|
30
|
-
.map(|seg| match seg.strip_prefix('$') {
|
|
31
|
-
// $id.html -> $.html ; $id -> $
|
|
32
|
-
Some(rest) => match rest.find('.') {
|
|
33
|
-
Some(dot) => format!("${}", &rest[dot..]),
|
|
34
|
-
None => "$".to_string(),
|
|
35
|
-
},
|
|
36
|
-
None => seg.to_string(),
|
|
37
|
-
})
|
|
38
|
-
.collect::<Vec<_>>()
|
|
39
|
-
.join("/")
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/// Write via a same-directory temp file + rename, so no reader — the dev
|
|
43
|
-
/// server, the browser, another compiler process — can ever observe a
|
|
44
|
-
/// partially-written file. Rename is atomic on POSIX; the temp name carries the
|
|
45
|
-
/// pid so two processes writing the same target never share a temp file.
|
|
46
|
-
pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
|
47
|
-
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("out");
|
|
48
|
-
let tmp = path.with_file_name(format!(".{}.{}.vibe-tmp", file_name, std::process::id()));
|
|
49
|
-
fs::write(&tmp, contents)?;
|
|
50
|
-
fs::rename(&tmp, path)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// =============================================================================
|
|
54
|
-
// MIRROR_MODE: Copy asset files from source to output as-is, preserving
|
|
55
|
-
// directory structure. HTML files are compiled separately.
|
|
56
|
-
//
|
|
57
|
-
// Asset whitelist approach: Common web asset extensions automatically copied.
|
|
58
|
-
// Future refactoring: Replace with configurable include/exclude patterns,
|
|
59
|
-
// or a more sophisticated asset pipeline.
|
|
60
|
-
// =============================================================================
|
|
61
|
-
// FOUC prevention class/attribute name (matches runtime/constants.js)
|
|
62
|
-
// Can be used as class (.vibe-fouc) or attribute ([vibe-fouc])
|
|
63
|
-
const FOUC_CLASS_OR_ATTR: &str = "vibe-fouc";
|
|
64
|
-
|
|
65
|
-
const MIRROR_EXTENSIONS: &[&str] = &[
|
|
66
|
-
"css", "js",
|
|
67
|
-
// Fonts
|
|
68
|
-
"ttf", "otf", "woff", "woff2", "eot",
|
|
69
|
-
// Images
|
|
70
|
-
"png", "jpg", "jpeg", "gif", "svg", "webp", "avif", "ico",
|
|
71
|
-
// Media
|
|
72
|
-
"mp4", "webm", "ogg", "mp3", "wav", "flac", "aac",
|
|
73
|
-
// Documents
|
|
74
|
-
"pdf",
|
|
75
|
-
// Data
|
|
76
|
-
"json", "xml", "csv",
|
|
77
|
-
];
|
|
78
|
-
|
|
79
|
-
// Built-in files and directories to skip when walking source (supports glob
|
|
80
|
-
// patterns). User-supplied `skipFiles` are appended to these in Config::load,
|
|
81
|
-
// so the effective list lives on `config.skip_files` and is passed into
|
|
82
|
-
// should_skip_path() at every call site.
|
|
83
|
-
// Note: Output directory is checked dynamically (not hardcoded here)
|
|
84
|
-
// Note: Dotfiles are handled by starts_with('.') check in should_skip_path()
|
|
85
|
-
pub const SKIP_FILES: &[&str] = &[
|
|
86
|
-
"target", // Rust build artifacts
|
|
87
|
-
"tests", // Test files
|
|
88
|
-
"test-results",
|
|
89
|
-
"playwright-report",
|
|
90
|
-
"node_modules", // Skip node_modules during scanning
|
|
91
|
-
"**/*.test.js", // Test files
|
|
92
|
-
"**/*.config.js", // Config files
|
|
93
|
-
"**/*.test.ts", // Test files
|
|
94
|
-
"**/*.config.ts", // Config files
|
|
95
|
-
];
|
|
96
|
-
|
|
97
|
-
/// Check if a path should be skipped based on the effective skip patterns
|
|
98
|
-
/// (built-in SKIP_FILES + user `skipFiles`, merged in Config::load).
|
|
99
|
-
pub fn should_skip_path(path: &Path, name: &str, patterns: &[String]) -> bool {
|
|
100
|
-
// Check if name starts with dot (dotfiles/directories)
|
|
101
|
-
if name.starts_with('.') {
|
|
102
|
-
return true;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Check exact name match (for directories and simple filenames)
|
|
106
|
-
if patterns.iter().any(|p| p == name) {
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Check glob patterns (e.g., **/*.test.js)
|
|
111
|
-
for pattern_str in patterns {
|
|
112
|
-
if pattern_str.contains('*') {
|
|
113
|
-
if let Ok(pattern) = Pattern::new(pattern_str) {
|
|
114
|
-
// Try matching against just the filename
|
|
115
|
-
if pattern.matches(name) {
|
|
116
|
-
return true;
|
|
117
|
-
}
|
|
118
|
-
// Try matching against the full path
|
|
119
|
-
if let Some(path_str) = path.to_str() {
|
|
120
|
-
if pattern.matches(path_str) {
|
|
121
|
-
return true;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
false
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/// Recursively gather JS sources that could write `$` state: every `.js` module
|
|
132
|
-
/// (full text — also a candidate for the global `vibe()` call) plus, from each
|
|
133
|
-
/// `.html` file, every inline `<script>` body and `on*` handler body. Skips the
|
|
134
|
-
/// compiled output directory and anything `should_skip_path` excludes.
|
|
135
|
-
fn collect_state_sources(
|
|
136
|
-
dir: &Path,
|
|
137
|
-
output_canon: Option<&Path>,
|
|
138
|
-
skip: &[String],
|
|
139
|
-
sources: &mut Vec<String>,
|
|
140
|
-
js_paths: &mut Vec<PathBuf>,
|
|
141
|
-
) {
|
|
142
|
-
let entries = match fs::read_dir(dir) {
|
|
143
|
-
Ok(e) => e,
|
|
144
|
-
Err(_) => return,
|
|
145
|
-
};
|
|
146
|
-
for entry in entries.flatten() {
|
|
147
|
-
let path = entry.path();
|
|
148
|
-
let name = match path.file_name().and_then(|n| n.to_str()) {
|
|
149
|
-
Some(n) => n,
|
|
150
|
-
None => continue,
|
|
151
|
-
};
|
|
152
|
-
if should_skip_path(&path, name, skip) {
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
if path.is_dir() {
|
|
156
|
-
// Never descend into the compiled output — its JS is bundled/minified
|
|
157
|
-
// and would falsely trip the analysis.
|
|
158
|
-
if let (Some(oc), Some(pc)) = (output_canon, path.canonicalize().ok()) {
|
|
159
|
-
if pc.as_path() == oc {
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
collect_state_sources(&path, output_canon, skip, sources, js_paths);
|
|
164
|
-
} else {
|
|
165
|
-
match path.extension().and_then(|e| e.to_str()) {
|
|
166
|
-
Some("js") => {
|
|
167
|
-
if let Ok(code) = fs::read_to_string(&path) {
|
|
168
|
-
sources.push(code);
|
|
169
|
-
js_paths.push(path);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
Some("html") => {
|
|
173
|
-
if let Ok(html) = fs::read_to_string(&path) {
|
|
174
|
-
sources.extend(extract_inline_scripts(&html));
|
|
175
|
-
sources.extend(extract_event_handlers(&html));
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
_ => {}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/// Inner JS of every `<script>…</script>` block.
|
|
185
|
-
fn extract_inline_scripts(html: &str) -> Vec<String> {
|
|
186
|
-
let re = Regex::new(r"(?is)<script\b[^>]*>(.*?)</script>").unwrap();
|
|
187
|
-
re.captures_iter(html)
|
|
188
|
-
.map(|c| c[1].to_string())
|
|
189
|
-
.filter(|s| !s.trim().is_empty())
|
|
190
|
-
.collect()
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/// Bodies of `on*="…"` / `on*='…'` event-handler attributes. The attribute-name
|
|
194
|
-
/// match is heuristic (it can catch a non-handler like `only="…"`), so each body
|
|
195
|
-
/// is kept only if it parses as JS — genuine handlers parse, false positives drop.
|
|
196
|
-
fn extract_event_handlers(html: &str) -> Vec<String> {
|
|
197
|
-
use crate::compiler::reassignment_analyzer::parses_as_js;
|
|
198
|
-
let re = Regex::new(r#"(?is)\son[a-z]+\s*=\s*(?:"([^"]*)"|'([^']*)')"#).unwrap();
|
|
199
|
-
re.captures_iter(html)
|
|
200
|
-
.filter_map(|c| c.get(1).or_else(|| c.get(2)).map(|m| m.as_str().to_string()))
|
|
201
|
-
.filter(|body| !body.trim().is_empty() && parses_as_js(body))
|
|
202
|
-
.collect()
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/// Rename `src="{component_src}"` to `data-vibe-recursive-src="{component_src}"`
|
|
206
|
-
/// on `<component>` / `<div class="component">` tags. Used during cache build
|
|
207
|
-
/// to neutralize cyclic refs before they reach `inline_component_elements` —
|
|
208
|
-
/// the inliner's `src=` regex won't match the renamed attribute, so it stops
|
|
209
|
-
/// re-expanding. `process_html_with_cache` restores the attribute to plain
|
|
210
|
-
/// `src=` at the end of compilation so the runtime fetches it normally.
|
|
211
|
-
fn escape_recursive_src(content: &str, component_src: &str) -> String {
|
|
212
|
-
let pattern = format!(
|
|
213
|
-
r#"(<(?:component|div)\s+[^>]*\b)src="{}""#,
|
|
214
|
-
regex::escape(component_src),
|
|
215
|
-
);
|
|
216
|
-
let re = Regex::new(&pattern).unwrap();
|
|
217
|
-
re.replace_all(content, format!(r#"$1data-vibe-recursive-src="{}""#, component_src))
|
|
218
|
-
.into_owned()
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/// Remove FOUC prevention class and/or attribute from compiled HTML
|
|
222
|
-
/// Since HTML is pre-rendered, there's no need for FOUC prevention
|
|
223
|
-
fn remove_fouc_prevention(html: String) -> String {
|
|
224
|
-
let mut result = html;
|
|
225
|
-
|
|
226
|
-
// Remove as class: class="vibe-fouc" or class="vibe-fouc other-class" or class="other-class vibe-fouc"
|
|
227
|
-
result = Regex::new(&format!(r#"\s+class="{}""#, FOUC_CLASS_OR_ATTR))
|
|
228
|
-
.unwrap()
|
|
229
|
-
.replace_all(&result, "")
|
|
230
|
-
.to_string();
|
|
231
|
-
result = Regex::new(&format!(r#"class="{}\s+"#, FOUC_CLASS_OR_ATTR))
|
|
232
|
-
.unwrap()
|
|
233
|
-
.replace_all(&result, r#"class=""#)
|
|
234
|
-
.to_string();
|
|
235
|
-
result = Regex::new(&format!(r#"class="([^"]*\s+){}(\s+[^"]*)""#, FOUC_CLASS_OR_ATTR))
|
|
236
|
-
.unwrap()
|
|
237
|
-
.replace_all(&result, r#"class="$1$2""#)
|
|
238
|
-
.to_string();
|
|
239
|
-
|
|
240
|
-
// Remove as attribute: vibe-fouc or vibe-fouc=""
|
|
241
|
-
result = Regex::new(&format!(r#"\s+{}(?:="[^"]*")?"#, FOUC_CLASS_OR_ATTR))
|
|
242
|
-
.unwrap()
|
|
243
|
-
.replace_all(&result, "")
|
|
244
|
-
.to_string();
|
|
245
|
-
|
|
246
|
-
result
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
#[derive(Error, Debug)]
|
|
250
|
-
pub enum CompileError {
|
|
251
|
-
#[error("Source directory not found: {0}")]
|
|
252
|
-
SourceNotFound(String),
|
|
253
|
-
#[error("Failed to create output directory: {0}")]
|
|
254
|
-
CreateDirError(String),
|
|
255
|
-
#[error("Failed to read file {path}: {source}")]
|
|
256
|
-
ReadError {
|
|
257
|
-
path: String,
|
|
258
|
-
#[source]
|
|
259
|
-
source: std::io::Error,
|
|
260
|
-
},
|
|
261
|
-
#[error("Failed to write file {path}: {source}")]
|
|
262
|
-
WriteError {
|
|
263
|
-
path: String,
|
|
264
|
-
#[source]
|
|
265
|
-
source: std::io::Error,
|
|
266
|
-
},
|
|
267
|
-
#[error("{file}:{line} - {message}")]
|
|
268
|
-
ValidationError {
|
|
269
|
-
file: String,
|
|
270
|
-
line: usize,
|
|
271
|
-
message: String,
|
|
272
|
-
},
|
|
273
|
-
#[error("Parse error: {0}")]
|
|
274
|
-
ParseError(#[from] crate::parser::html::ParseError),
|
|
275
|
-
#[error("Failed to execute command: {0}")]
|
|
276
|
-
CommandError(String),
|
|
277
|
-
#[error("Package manager not found")]
|
|
278
|
-
PackageManagerNotFound,
|
|
279
|
-
#[error("Component name '{component_name}' in '{file_path}' conflicts with reserved element. Component filenames cannot match standard HTML elements (case-insensitive). Use PascalCase (e.g., 'Nav.html' instead of 'nav.html') to avoid conflicts.")]
|
|
280
|
-
ReservedComponentName {
|
|
281
|
-
component_name: String,
|
|
282
|
-
file_path: String,
|
|
283
|
-
},
|
|
284
|
-
#[error("SPA mode: {0}")]
|
|
285
|
-
SpaError(String),
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
pub struct CompileStats {
|
|
289
|
-
pub files_compiled: usize,
|
|
290
|
-
pub files_copied: usize,
|
|
291
|
-
pub internal_components_unique: usize,
|
|
292
|
-
pub external_components_unique: usize,
|
|
293
|
-
pub internal_components_total: usize,
|
|
294
|
-
pub external_components_total: usize,
|
|
295
|
-
pub compile_time_ms: f64,
|
|
296
|
-
pub copy_time_ms: f64,
|
|
297
|
-
pub components_time_ms: f64,
|
|
298
|
-
pub validation_time_ms: Option<f64>,
|
|
299
|
-
pub node_modules_time_ms: Option<f64>,
|
|
300
|
-
pub package_manager: Option<String>,
|
|
301
|
-
pub node_modules_copied_as_is: bool,
|
|
302
|
-
pub components_as_is: bool,
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
pub struct ManifestStats {
|
|
306
|
-
pub pages_processed: usize,
|
|
307
|
-
pub pages_skipped: usize,
|
|
308
|
-
pub total_time_ms: f64,
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
#[derive(Debug, Clone, PartialEq)]
|
|
312
|
-
enum FileOperation {
|
|
313
|
-
Compiled,
|
|
314
|
-
Copied,
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
struct VerboseLogger {
|
|
318
|
-
operations: BTreeMap<String, Vec<(String, FileOperation)>>, // dir -> [(filename, operation)]
|
|
319
|
-
component_counts: HashMap<String, usize>, // component src -> count
|
|
320
|
-
component_children: HashMap<String, HashSet<String>>, // component src -> direct children
|
|
321
|
-
components_as_is: bool,
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
impl VerboseLogger {
|
|
325
|
-
fn new(components_as_is: bool) -> Self {
|
|
326
|
-
Self {
|
|
327
|
-
operations: BTreeMap::new(),
|
|
328
|
-
component_counts: HashMap::new(),
|
|
329
|
-
component_children: HashMap::new(),
|
|
330
|
-
components_as_is,
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
fn log_component_occurrence(&mut self, src: String) {
|
|
335
|
-
// Increment count for this component
|
|
336
|
-
*self.component_counts.entry(src).or_insert(0) += 1;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
fn log_component_children(&mut self, src: String, children: Vec<String>) {
|
|
340
|
-
// Track children relationships
|
|
341
|
-
if !children.is_empty() {
|
|
342
|
-
self.component_children
|
|
343
|
-
.entry(src)
|
|
344
|
-
.or_insert_with(HashSet::new)
|
|
345
|
-
.extend(children);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
fn log(&mut self, path: &Path, operation: FileOperation, source_root: &Path) {
|
|
350
|
-
let relative = path.strip_prefix(source_root).unwrap_or(path);
|
|
351
|
-
let dir = relative.parent()
|
|
352
|
-
.map(|p| p.to_string_lossy().to_string())
|
|
353
|
-
.unwrap_or_else(|| String::from(""));
|
|
354
|
-
let filename = relative.file_name()
|
|
355
|
-
.map(|f| f.to_string_lossy().to_string())
|
|
356
|
-
.unwrap_or_default();
|
|
357
|
-
|
|
358
|
-
self.operations
|
|
359
|
-
.entry(dir)
|
|
360
|
-
.or_insert_with(Vec::new)
|
|
361
|
-
.push((filename, operation));
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
fn print_components(&self) {
|
|
365
|
-
if self.components_as_is {
|
|
366
|
-
println!("\n{} (0 {}, 0 {}) - \"components-as-is\": true",
|
|
367
|
-
"Compiled components".bright_cyan(),
|
|
368
|
-
"internal".bright_green(),
|
|
369
|
-
"external".bright_magenta()
|
|
370
|
-
);
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
if self.component_counts.is_empty() {
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Count internal vs external
|
|
379
|
-
let mut internal_total = 0;
|
|
380
|
-
let mut external_total = 0;
|
|
381
|
-
for (src, count) in &self.component_counts {
|
|
382
|
-
if src.starts_with("http://") || src.starts_with("https://") {
|
|
383
|
-
external_total += count;
|
|
384
|
-
} else {
|
|
385
|
-
internal_total += count;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
println!("\n{} ({} {}, {} {})",
|
|
390
|
-
"Compiled components".bright_cyan().bold(),
|
|
391
|
-
internal_total,
|
|
392
|
-
"internal".bright_green(),
|
|
393
|
-
external_total,
|
|
394
|
-
"external".bright_magenta()
|
|
395
|
-
);
|
|
396
|
-
println!();
|
|
397
|
-
|
|
398
|
-
// Sort components by count (descending) then alphabetically
|
|
399
|
-
let mut components: Vec<_> = self.component_counts.iter().collect();
|
|
400
|
-
components.sort_by(|(a_src, a_count), (b_src, b_count)| {
|
|
401
|
-
b_count.cmp(a_count).then_with(|| a_src.cmp(b_src))
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
// Find top-level components (those not referenced as children of others)
|
|
405
|
-
let all_children: HashSet<String> = self.component_children
|
|
406
|
-
.values()
|
|
407
|
-
.flat_map(|children| children.iter().cloned())
|
|
408
|
-
.collect();
|
|
409
|
-
|
|
410
|
-
// Print each top-level component with its tree
|
|
411
|
-
for (src, count) in &components {
|
|
412
|
-
// Only show as top-level if it's not a child of another component
|
|
413
|
-
if !all_children.contains(*src) {
|
|
414
|
-
let is_external = src.starts_with("http://") || src.starts_with("https://");
|
|
415
|
-
|
|
416
|
-
let display = if is_external {
|
|
417
|
-
format!("{} ({})", src, count).bright_magenta()
|
|
418
|
-
} else {
|
|
419
|
-
format!("{} ({})", src, count).bright_green()
|
|
420
|
-
};
|
|
421
|
-
|
|
422
|
-
println!("{}", display);
|
|
423
|
-
|
|
424
|
-
// Print children if any
|
|
425
|
-
if let Some(children) = self.component_children.get(*src) {
|
|
426
|
-
let mut children_vec: Vec<_> = children.iter().collect();
|
|
427
|
-
children_vec.sort();
|
|
428
|
-
|
|
429
|
-
for child in children_vec {
|
|
430
|
-
let is_external = child.starts_with("http://") || child.starts_with("https://");
|
|
431
|
-
let display = if is_external {
|
|
432
|
-
format!(" └─ {}", child).bright_magenta()
|
|
433
|
-
} else {
|
|
434
|
-
format!(" └─ {}", child).bright_green()
|
|
435
|
-
};
|
|
436
|
-
println!("{}", display);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
fn print_compiled(&self) {
|
|
444
|
-
self.print_operations("Compiled HTML", FileOperation::Compiled);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
fn print_copied(&self) {
|
|
448
|
-
self.print_operations("Copied files", FileOperation::Copied);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
fn print_operations(&self, label: &str, op_type: FileOperation) {
|
|
452
|
-
let matching: Vec<_> = self.operations.iter()
|
|
453
|
-
.map(|(dir, files)| {
|
|
454
|
-
let filtered: Vec<_> = files.iter()
|
|
455
|
-
.filter(|(_, op)| {
|
|
456
|
-
match (&op_type, op) {
|
|
457
|
-
(FileOperation::Compiled, FileOperation::Compiled) => true,
|
|
458
|
-
(FileOperation::Copied, FileOperation::Copied) => true,
|
|
459
|
-
_ => false,
|
|
460
|
-
}
|
|
461
|
-
})
|
|
462
|
-
.map(|(name, _)| name.clone())
|
|
463
|
-
.collect();
|
|
464
|
-
(dir.clone(), filtered)
|
|
465
|
-
})
|
|
466
|
-
.filter(|(_, files)| !files.is_empty())
|
|
467
|
-
.collect();
|
|
468
|
-
|
|
469
|
-
if matching.is_empty() {
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
let total_count: usize = matching.iter().map(|(_, files)| files.len()).sum();
|
|
474
|
-
println!("\n{} ({})", label.bright_cyan().bold(), total_count);
|
|
475
|
-
println!();
|
|
476
|
-
|
|
477
|
-
// Root files first
|
|
478
|
-
if let Some((_, files)) = matching.iter().find(|(dir, _)| dir.is_empty()) {
|
|
479
|
-
for file in files {
|
|
480
|
-
println!(" {}", file);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// Then directories hierarchically
|
|
485
|
-
self.print_directories(&matching, "", 1);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
fn print_directories(&self, matching: &[(String, Vec<String>)], parent: &str, depth: usize) {
|
|
489
|
-
// Find all directories at this level
|
|
490
|
-
let mut dirs_at_level: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
|
491
|
-
|
|
492
|
-
for (dir, _) in matching {
|
|
493
|
-
if dir.is_empty() {
|
|
494
|
-
continue;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
let remainder = if parent.is_empty() {
|
|
498
|
-
if dir.starts_with(parent) {
|
|
499
|
-
dir.as_str()
|
|
500
|
-
} else {
|
|
501
|
-
continue;
|
|
502
|
-
}
|
|
503
|
-
} else {
|
|
504
|
-
if dir.starts_with(parent) && dir.len() > parent.len() {
|
|
505
|
-
&dir[parent.len() + 1..] // +1 for the separator
|
|
506
|
-
} else {
|
|
507
|
-
continue;
|
|
508
|
-
}
|
|
509
|
-
};
|
|
510
|
-
|
|
511
|
-
if let Some(slash_pos) = remainder.find('/') {
|
|
512
|
-
dirs_at_level.insert(remainder[..slash_pos].to_string());
|
|
513
|
-
} else if !remainder.is_empty() {
|
|
514
|
-
dirs_at_level.insert(remainder.to_string());
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// Print each directory
|
|
519
|
-
for dir_name in dirs_at_level {
|
|
520
|
-
let full_path = if parent.is_empty() {
|
|
521
|
-
dir_name.clone()
|
|
522
|
-
} else {
|
|
523
|
-
format!("{}/{}", parent, dir_name)
|
|
524
|
-
};
|
|
525
|
-
|
|
526
|
-
let indent = " ".repeat(depth);
|
|
527
|
-
|
|
528
|
-
// Count files directly in this directory (not subdirectories)
|
|
529
|
-
let file_count = matching.iter()
|
|
530
|
-
.find(|(d, _)| d == &full_path)
|
|
531
|
-
.map(|(_, files)| files.len())
|
|
532
|
-
.unwrap_or(0);
|
|
533
|
-
|
|
534
|
-
if file_count > 0 {
|
|
535
|
-
println!("{}{} ({})", indent, format!("/{}", dir_name).cyan(), file_count);
|
|
536
|
-
} else {
|
|
537
|
-
println!("{}{}", indent, format!("/{}", dir_name).cyan());
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// Print files in this directory
|
|
541
|
-
if let Some((_, files)) = matching.iter().find(|(d, _)| d == &full_path) {
|
|
542
|
-
for file in files {
|
|
543
|
-
println!("{} {}", indent, file);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// Recurse into subdirectories
|
|
548
|
-
self.print_directories(matching, &full_path, depth + 1);
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
pub struct Compiler {
|
|
554
|
-
config: Config,
|
|
555
|
-
verbose: bool,
|
|
556
|
-
logger: Option<VerboseLogger>,
|
|
557
|
-
unique_components: HashSet<String>,
|
|
558
|
-
/// Cache for all components (both internal paths and external URLs)
|
|
559
|
-
component_cache: std::collections::HashMap<String, String>,
|
|
560
|
-
/// Compiled (pre-stamp) HTML of every page this compiler wrote, keyed by
|
|
561
|
-
/// output path. Manifest generation builds from these bytes — never from a
|
|
562
|
-
/// disk read-back — so a concurrent writer rewriting the output directory
|
|
563
|
-
/// (e.g. a second compiler) can't feed a torn read into a manifest.
|
|
564
|
-
compiled_html: HashMap<PathBuf, String>,
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
impl Compiler {
|
|
568
|
-
pub fn new(config: Config, verbose: bool) -> Self {
|
|
569
|
-
let logger = if verbose { Some(VerboseLogger::new(config.components_as_is)) } else { None };
|
|
570
|
-
Self {
|
|
571
|
-
config,
|
|
572
|
-
verbose,
|
|
573
|
-
logger,
|
|
574
|
-
unique_components: HashSet::new(),
|
|
575
|
-
component_cache: HashMap::new(),
|
|
576
|
-
compiled_html: HashMap::new(),
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/// Drop only the named cache entries, forcing those components to be
|
|
581
|
-
/// re-read + re-inlined on the next compile while every other component is
|
|
582
|
-
/// reused from cache. `keys` are normalized component srcs (e.g.
|
|
583
|
-
/// `/components/Sidebar.html`). Returns how many entries were actually
|
|
584
|
-
/// removed. This is the incremental-watch path: invalidate the edited
|
|
585
|
-
/// component and its inlining ancestors, nothing else.
|
|
586
|
-
pub fn invalidate_components(&mut self, keys: &std::collections::HashSet<String>) -> usize {
|
|
587
|
-
let before = self.component_cache.len();
|
|
588
|
-
self.component_cache.retain(|key, _| !keys.contains(key));
|
|
589
|
-
before - self.component_cache.len()
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/// Number of components currently held in the inlining cache.
|
|
593
|
-
pub fn cached_component_count(&self) -> usize {
|
|
594
|
-
self.component_cache.len()
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/// Take over another compiler's warm component cache, leaving it empty.
|
|
598
|
-
/// The watch loop uses this to carry the initial full-compile cache into the
|
|
599
|
-
/// incremental compiler, so even the first edit reuses everything unchanged
|
|
600
|
-
/// instead of re-fetching the whole tree.
|
|
601
|
-
pub fn adopt_component_cache(&mut self, other: &mut Compiler) {
|
|
602
|
-
self.component_cache = std::mem::take(&mut other.component_cache);
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
pub fn compile(&mut self) -> Result<CompileStats, CompileError> {
|
|
606
|
-
let mut stats = CompileStats {
|
|
607
|
-
files_compiled: 0,
|
|
608
|
-
files_copied: 0,
|
|
609
|
-
internal_components_unique: 0,
|
|
610
|
-
external_components_unique: 0,
|
|
611
|
-
internal_components_total: 0,
|
|
612
|
-
external_components_total: 0,
|
|
613
|
-
compile_time_ms: 0.0,
|
|
614
|
-
copy_time_ms: 0.0,
|
|
615
|
-
components_time_ms: 0.0,
|
|
616
|
-
validation_time_ms: None,
|
|
617
|
-
node_modules_time_ms: None,
|
|
618
|
-
package_manager: None,
|
|
619
|
-
node_modules_copied_as_is: false,
|
|
620
|
-
components_as_is: self.config.components_as_is,
|
|
621
|
-
};
|
|
622
|
-
|
|
623
|
-
// Validate source exists
|
|
624
|
-
if !self.config.source.exists() {
|
|
625
|
-
return Err(CompileError::SourceNotFound(
|
|
626
|
-
self.config.source.display().to_string(),
|
|
627
|
-
));
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// Load components for HTML compilation
|
|
631
|
-
let mut parser = HtmlParser::new(self.config.components_path());
|
|
632
|
-
parser.load_elements()?;
|
|
633
|
-
|
|
634
|
-
// Validate component names don't conflict with reserved elements
|
|
635
|
-
self.validate_component_names()?;
|
|
636
|
-
|
|
637
|
-
// Fetch and cache all components for inlining (when not components_as_is)
|
|
638
|
-
// This is separate from validation - we need component content for inlining regardless of validate flag
|
|
639
|
-
if !self.config.components_as_is {
|
|
640
|
-
let fetch_start = Instant::now();
|
|
641
|
-
if self.verbose {
|
|
642
|
-
println!("\nFetching components for inlining...");
|
|
643
|
-
}
|
|
644
|
-
self.fetch_all_components(&parser)?;
|
|
645
|
-
stats.components_time_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
|
|
646
|
-
if self.verbose {
|
|
647
|
-
println!(" All components fetched successfully");
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// Validate HTML syntax (always runs)
|
|
652
|
-
let validation_start = Instant::now();
|
|
653
|
-
if self.verbose {
|
|
654
|
-
println!("\nValidating HTML syntax...");
|
|
655
|
-
}
|
|
656
|
-
self.validate_html_syntax()?;
|
|
657
|
-
stats.validation_time_ms = Some(validation_start.elapsed().as_secs_f64() * 1000.0);
|
|
658
|
-
if self.verbose {
|
|
659
|
-
println!(" HTML validation completed successfully");
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// Clean output directory if not in no-clean mode
|
|
663
|
-
if !self.config.no_clean && self.config.output.exists() {
|
|
664
|
-
if self.verbose {
|
|
665
|
-
println!(" Cleaning output directory: {}", self.config.output.display());
|
|
666
|
-
}
|
|
667
|
-
fs::remove_dir_all(&self.config.output).map_err(|e| {
|
|
668
|
-
CompileError::WriteError {
|
|
669
|
-
path: self.config.output.display().to_string(),
|
|
670
|
-
source: e,
|
|
671
|
-
}
|
|
672
|
-
})?;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// Create output directory
|
|
676
|
-
if !self.config.output.exists() {
|
|
677
|
-
fs::create_dir_all(&self.config.output).map_err(|_| {
|
|
678
|
-
CompileError::CreateDirError(self.config.output.display().to_string())
|
|
679
|
-
})?;
|
|
680
|
-
if self.verbose {
|
|
681
|
-
println!(" Created output directory: {}", self.config.output.display());
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// Canonicalize both output and source paths for reliable comparison
|
|
686
|
-
let canonical_output = self.config.output.canonicalize()
|
|
687
|
-
.unwrap_or_else(|_| self.config.output.clone());
|
|
688
|
-
let canonical_source = self.config.source.canonicalize()
|
|
689
|
-
.unwrap_or_else(|_| self.config.source.clone());
|
|
690
|
-
|
|
691
|
-
// Track compile and copy times separately
|
|
692
|
-
let compile_start = Instant::now();
|
|
693
|
-
|
|
694
|
-
// Process all HTML files in source (excluding components directory)
|
|
695
|
-
self.process_directory_html_only(&canonical_source, &parser, "", &canonical_output, &canonical_source, &mut stats)?;
|
|
696
|
-
|
|
697
|
-
// Process source directory for assets (CSS, JS, images, etc.)
|
|
698
|
-
self.process_directory_assets_only(&canonical_source, "", &canonical_output, &canonical_source, &mut stats)?;
|
|
699
|
-
|
|
700
|
-
// SPA mode: pages tree → fragments + route table + composed shell.
|
|
701
|
-
// Runs after both passes so the shell wins over any root index.html
|
|
702
|
-
// and fragments land beside the mirrored components.
|
|
703
|
-
if self.config.spa {
|
|
704
|
-
stats.files_compiled += self.compile_spa(&parser)?;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
let process_time = compile_start.elapsed();
|
|
708
|
-
|
|
709
|
-
// Print verbose output after processing (components first, then HTML, then copied)
|
|
710
|
-
if let Some(ref logger) = self.logger {
|
|
711
|
-
logger.print_components();
|
|
712
|
-
logger.print_compiled();
|
|
713
|
-
logger.print_copied();
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Calculate unique components after processing all files
|
|
717
|
-
let internal_unique = self.unique_components.iter()
|
|
718
|
-
.filter(|src| !src.starts_with("http://") && !src.starts_with("https://"))
|
|
719
|
-
.count();
|
|
720
|
-
let external_unique = self.unique_components.iter()
|
|
721
|
-
.filter(|src| src.starts_with("http://") || src.starts_with("https://"))
|
|
722
|
-
.count();
|
|
723
|
-
|
|
724
|
-
stats.internal_components_unique = internal_unique;
|
|
725
|
-
stats.external_components_unique = external_unique;
|
|
726
|
-
|
|
727
|
-
// Calculate times based on counts (rough estimate)
|
|
728
|
-
// Component operations are part of compile time but tracked separately for user visibility
|
|
729
|
-
let total_files = stats.files_compiled + stats.files_copied;
|
|
730
|
-
let total_components = stats.internal_components_total + stats.external_components_total;
|
|
731
|
-
|
|
732
|
-
if total_files > 0 {
|
|
733
|
-
let total_ms = process_time.as_secs_f64() * 1000.0;
|
|
734
|
-
|
|
735
|
-
if total_components > 0 {
|
|
736
|
-
// Component processing is slowest, compile is medium, copy is fastest
|
|
737
|
-
// Rough weights: component=3, compile=2, copy=1
|
|
738
|
-
let total_weight = (total_components * 3 + stats.files_compiled * 2 + stats.files_copied) as f64;
|
|
739
|
-
stats.components_time_ms = (total_ms * (total_components * 3) as f64) / total_weight;
|
|
740
|
-
stats.compile_time_ms = (total_ms * (stats.files_compiled * 2) as f64) / total_weight;
|
|
741
|
-
stats.copy_time_ms = (total_ms * stats.files_copied as f64) / total_weight;
|
|
742
|
-
} else {
|
|
743
|
-
// No component operations, just split between compile and copy
|
|
744
|
-
stats.compile_time_ms = (total_ms * stats.files_compiled as f64) / total_files as f64;
|
|
745
|
-
stats.copy_time_ms = (total_ms * stats.files_copied as f64) / total_files as f64;
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
// Handle node_modules after successful compilation
|
|
750
|
-
if self.config.working_dir.join("node_modules").exists() {
|
|
751
|
-
if self.config.node_modules_as_is {
|
|
752
|
-
// Copy node_modules as-is (no install, just copy)
|
|
753
|
-
let nm_start = Instant::now();
|
|
754
|
-
self.copy_node_modules_as_is()?;
|
|
755
|
-
stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
|
|
756
|
-
stats.node_modules_copied_as_is = true;
|
|
757
|
-
|
|
758
|
-
if self.verbose {
|
|
759
|
-
println!("\n Copied node_modules as-is");
|
|
760
|
-
}
|
|
761
|
-
} else {
|
|
762
|
-
// Use production-only strategy (runs install commands)
|
|
763
|
-
let pkg_manager = detect_package_manager(&self.config.working_dir).ok();
|
|
764
|
-
stats.package_manager = pkg_manager.clone();
|
|
765
|
-
|
|
766
|
-
let nm_start = Instant::now();
|
|
767
|
-
self.copy_production_node_modules()?;
|
|
768
|
-
stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
|
|
769
|
-
|
|
770
|
-
if self.verbose {
|
|
771
|
-
println!(" Copied production dependencies");
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
Ok(stats)
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
/// Compile specific HTML files (incremental compilation for watch mode)
|
|
780
|
-
pub fn compile_specific_html_files(
|
|
781
|
-
&mut self,
|
|
782
|
-
files: &[PathBuf],
|
|
783
|
-
parser: &HtmlParser,
|
|
784
|
-
) -> Result<CompileStats, CompileError> {
|
|
785
|
-
let mut stats = CompileStats {
|
|
786
|
-
files_compiled: 0,
|
|
787
|
-
files_copied: 0,
|
|
788
|
-
internal_components_unique: 0,
|
|
789
|
-
external_components_unique: 0,
|
|
790
|
-
internal_components_total: 0,
|
|
791
|
-
external_components_total: 0,
|
|
792
|
-
compile_time_ms: 0.0,
|
|
793
|
-
copy_time_ms: 0.0,
|
|
794
|
-
components_time_ms: 0.0,
|
|
795
|
-
validation_time_ms: None,
|
|
796
|
-
node_modules_time_ms: None,
|
|
797
|
-
package_manager: None,
|
|
798
|
-
node_modules_copied_as_is: false,
|
|
799
|
-
components_as_is: self.config.components_as_is,
|
|
800
|
-
};
|
|
801
|
-
|
|
802
|
-
let start = Instant::now();
|
|
803
|
-
|
|
804
|
-
// Pre-fetch components needed by these specific files (only if not components_as_is)
|
|
805
|
-
if !self.config.components_as_is {
|
|
806
|
-
let fetch_start = Instant::now();
|
|
807
|
-
self.fetch_components_for_files(files, parser)?;
|
|
808
|
-
stats.components_time_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
for file_path in files {
|
|
812
|
-
// Validate HTML before compiling (same as full compile path)
|
|
813
|
-
let content = fs::read_to_string(file_path).map_err(|e| CompileError::ReadError {
|
|
814
|
-
path: file_path.display().to_string(),
|
|
815
|
-
source: e,
|
|
816
|
-
})?;
|
|
817
|
-
self.validate_html(&content, file_path)?;
|
|
818
|
-
|
|
819
|
-
// Calculate relative path
|
|
820
|
-
let relative_path = file_path
|
|
821
|
-
.strip_prefix(&self.config.source)
|
|
822
|
-
.map(|p| p.parent().unwrap_or(Path::new("")))
|
|
823
|
-
.unwrap_or(Path::new(""))
|
|
824
|
-
.to_str()
|
|
825
|
-
.unwrap_or("");
|
|
826
|
-
|
|
827
|
-
let (internal, external, component_srcs) = self.compile_html_file(file_path, parser, relative_path)?;
|
|
828
|
-
stats.files_compiled += 1;
|
|
829
|
-
stats.internal_components_total += internal;
|
|
830
|
-
stats.external_components_total += external;
|
|
831
|
-
|
|
832
|
-
// Track unique components
|
|
833
|
-
for src in &component_srcs {
|
|
834
|
-
self.unique_components.insert(src.clone());
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
if let Some(ref mut logger) = self.logger {
|
|
838
|
-
logger.log(file_path, FileOperation::Compiled, &self.config.source);
|
|
839
|
-
for src in component_srcs {
|
|
840
|
-
logger.log_component_occurrence(src);
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// Calculate unique components
|
|
846
|
-
let internal_unique = self.unique_components.iter()
|
|
847
|
-
.filter(|src| !src.starts_with("http://") && !src.starts_with("https://"))
|
|
848
|
-
.count();
|
|
849
|
-
let external_unique = self.unique_components.iter()
|
|
850
|
-
.filter(|src| src.starts_with("http://") || src.starts_with("https://"))
|
|
851
|
-
.count();
|
|
852
|
-
|
|
853
|
-
stats.internal_components_unique = internal_unique;
|
|
854
|
-
stats.external_components_unique = external_unique;
|
|
855
|
-
|
|
856
|
-
// Calculate compile time (excluding component fetch time which is already tracked)
|
|
857
|
-
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
|
|
858
|
-
stats.compile_time_ms = elapsed - stats.components_time_ms;
|
|
859
|
-
|
|
860
|
-
Ok(stats)
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
/// Copy specific asset files (incremental compilation for watch mode)
|
|
864
|
-
pub fn copy_specific_asset_files(
|
|
865
|
-
&mut self,
|
|
866
|
-
files: &[PathBuf],
|
|
867
|
-
) -> Result<CompileStats, CompileError> {
|
|
868
|
-
let mut stats = CompileStats {
|
|
869
|
-
files_compiled: 0,
|
|
870
|
-
files_copied: 0,
|
|
871
|
-
internal_components_unique: 0,
|
|
872
|
-
external_components_unique: 0,
|
|
873
|
-
internal_components_total: 0,
|
|
874
|
-
external_components_total: 0,
|
|
875
|
-
compile_time_ms: 0.0,
|
|
876
|
-
copy_time_ms: 0.0,
|
|
877
|
-
components_time_ms: 0.0,
|
|
878
|
-
validation_time_ms: None,
|
|
879
|
-
node_modules_time_ms: None,
|
|
880
|
-
package_manager: None,
|
|
881
|
-
node_modules_copied_as_is: false,
|
|
882
|
-
components_as_is: self.config.components_as_is,
|
|
883
|
-
};
|
|
884
|
-
|
|
885
|
-
let start = Instant::now();
|
|
886
|
-
|
|
887
|
-
for file_path in files {
|
|
888
|
-
// Calculate relative path for preserving directory structure
|
|
889
|
-
let relative_path = file_path
|
|
890
|
-
.strip_prefix(&self.config.source)
|
|
891
|
-
.map(|p| p.parent().unwrap_or(Path::new("")))
|
|
892
|
-
.unwrap_or(Path::new(""))
|
|
893
|
-
.to_str()
|
|
894
|
-
.unwrap_or("");
|
|
895
|
-
|
|
896
|
-
self.copy_file(file_path, relative_path)?;
|
|
897
|
-
stats.files_copied += 1;
|
|
898
|
-
|
|
899
|
-
if let Some(ref mut logger) = self.logger {
|
|
900
|
-
logger.log(file_path, FileOperation::Copied, &self.config.source);
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
|
|
905
|
-
stats.copy_time_ms = elapsed;
|
|
906
|
-
|
|
907
|
-
Ok(stats)
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/// Generate manifests for specific HTML files (incremental compilation for watch mode)
|
|
911
|
-
pub fn generate_manifests_for_files(&self, files: &[PathBuf]) -> Result<ManifestStats, CompileError> {
|
|
912
|
-
let start = Instant::now();
|
|
913
|
-
|
|
914
|
-
// Mirror generate_manifests: process each page's manifest in parallel.
|
|
915
|
-
// Manifest generation is the bulk of an incremental recompile (static
|
|
916
|
-
// analysis + serialization per page), so a sequential loop over the
|
|
917
|
-
// affected pages left most cores idle and dominated watch latency.
|
|
918
|
-
let output_dir = self.config.output.clone();
|
|
919
|
-
let source_root = self.config.source.clone();
|
|
920
|
-
let verbose = self.verbose;
|
|
921
|
-
let iterations_as_is = self.config.iterations_as_is;
|
|
922
|
-
let components_as_is = self.config.components_as_is;
|
|
923
|
-
let manifest_root = self.config.root.clone();
|
|
924
|
-
let spa = self.config.spa;
|
|
925
|
-
// Resolve constant global-state keys once, shared read-only across pages.
|
|
926
|
-
let global_constants = self.compute_global_constants();
|
|
927
|
-
|
|
928
|
-
let compiled_html = &self.compiled_html;
|
|
929
|
-
let results: Vec<bool> = files
|
|
930
|
-
.par_iter()
|
|
931
|
-
.map(|file_path| {
|
|
932
|
-
let relative_path = match file_path.strip_prefix(&source_root).unwrap_or(file_path).to_str() {
|
|
933
|
-
Some(r) => r,
|
|
934
|
-
None => return false,
|
|
935
|
-
};
|
|
936
|
-
|
|
937
|
-
let output_path = output_dir.join(relative_path);
|
|
938
|
-
if !output_path.exists() {
|
|
939
|
-
return false;
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
// The HTML this compiler produced in memory is the source of
|
|
943
|
-
// truth; the on-disk file may have been rewritten by another
|
|
944
|
-
// process since we wrote it. Disk is only a fallback for pages
|
|
945
|
-
// this compiler never compiled (e.g. no-clean leftovers).
|
|
946
|
-
let disk_html;
|
|
947
|
-
let html: &str = match compiled_html.get(&output_path) {
|
|
948
|
-
Some(h) => h,
|
|
949
|
-
None => {
|
|
950
|
-
disk_html = match fs::read_to_string(&output_path) {
|
|
951
|
-
Ok(h) => h,
|
|
952
|
-
Err(_) => return false,
|
|
953
|
-
};
|
|
954
|
-
&disk_html
|
|
955
|
-
}
|
|
956
|
-
};
|
|
957
|
-
|
|
958
|
-
match Self::generate_file_manifest(
|
|
959
|
-
html,
|
|
960
|
-
&output_path,
|
|
961
|
-
&output_dir,
|
|
962
|
-
relative_path,
|
|
963
|
-
verbose,
|
|
964
|
-
iterations_as_is,
|
|
965
|
-
components_as_is,
|
|
966
|
-
&source_root,
|
|
967
|
-
manifest_root.as_deref(),
|
|
968
|
-
&global_constants,
|
|
969
|
-
!(spa && relative_path == "index.html"),
|
|
970
|
-
) {
|
|
971
|
-
Ok(()) => true,
|
|
972
|
-
Err(e) => {
|
|
973
|
-
if verbose {
|
|
974
|
-
eprintln!(" Skipped ({}): {}", e, relative_path);
|
|
975
|
-
}
|
|
976
|
-
false
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
})
|
|
980
|
-
.collect();
|
|
981
|
-
|
|
982
|
-
let pages_processed = results.iter().filter(|&&ok| ok).count();
|
|
983
|
-
let pages_skipped = results.len() - pages_processed;
|
|
984
|
-
|
|
985
|
-
let total_time_ms = start.elapsed().as_millis() as f64;
|
|
986
|
-
|
|
987
|
-
Ok(ManifestStats {
|
|
988
|
-
pages_processed,
|
|
989
|
-
pages_skipped,
|
|
990
|
-
total_time_ms,
|
|
991
|
-
})
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
/// Generate manifest for a single file
|
|
995
|
-
fn generate_file_manifest(
|
|
996
|
-
html: &str,
|
|
997
|
-
html_path: &Path,
|
|
998
|
-
output_dir: &Path,
|
|
999
|
-
relative_path: &str,
|
|
1000
|
-
_verbose: bool,
|
|
1001
|
-
iterations_as_is: bool,
|
|
1002
|
-
components_as_is: bool,
|
|
1003
|
-
source_root: &Path,
|
|
1004
|
-
manifest_root: Option<&str>,
|
|
1005
|
-
global_constants: &Map<String, Value>,
|
|
1006
|
-
stamp: bool,
|
|
1007
|
-
) -> Result<(), String> {
|
|
1008
|
-
use crate::compiler::manifest_builder::ManifestBuilder;
|
|
1009
|
-
use crate::compiler::component_tagger::ComponentTagger;
|
|
1010
|
-
use crate::compiler::value_stamper::ValueStamper;
|
|
1011
|
-
|
|
1012
|
-
// Relocate whitespace-bearing name-bindings into a value-attribute BEFORE any
|
|
1013
|
-
// html5ever round-trip splits them on their spaces (see name_binding_protect).
|
|
1014
|
-
let protected = crate::compiler::name_binding_protect::protect(html);
|
|
1015
|
-
|
|
1016
|
-
// Tag components with deterministic IDs and structure state
|
|
1017
|
-
let tagged = ComponentTagger::tag_components(&protected, &source_root.to_path_buf())?;
|
|
1018
|
-
let html = &tagged.html; // Use modified HTML with data-vibe-component-id attributes
|
|
1019
|
-
let state = tagged.state;
|
|
1020
|
-
|
|
1021
|
-
// Build manifest from pre-stamp HTML (both conditional branches present, @[...] markers intact)
|
|
1022
|
-
// This ensures restoration.template has both branches so runtime can switch between them
|
|
1023
|
-
let manifest_builder = ManifestBuilder::new();
|
|
1024
|
-
let manifest = manifest_builder.build_from_html(html, &state, iterations_as_is)?;
|
|
1025
|
-
|
|
1026
|
-
// Map the output-relative path to the served-URL path: drop the `root`
|
|
1027
|
-
// dir (the folder served at /, e.g. `pages`) and collapse dynamic
|
|
1028
|
-
// `$param` segments to a single `$` token. This lets the runtime resolve
|
|
1029
|
-
// a manifest from a clean URL — `/armory` and `/the-arena/123` find
|
|
1030
|
-
// `armory.html.manifest.js` and `the-arena/$.html.manifest.js`.
|
|
1031
|
-
let manifest_rel = manifest_url_path(relative_path, manifest_root);
|
|
1032
|
-
|
|
1033
|
-
// Write manifest
|
|
1034
|
-
let manifest_path = output_dir
|
|
1035
|
-
.join("vibe-hyperspeed")
|
|
1036
|
-
.join(format!("{}.manifest.js", manifest_rel));
|
|
1037
|
-
|
|
1038
|
-
// Ensure directory exists
|
|
1039
|
-
if let Some(parent) = manifest_path.parent() {
|
|
1040
|
-
fs::create_dir_all(parent)
|
|
1041
|
-
.map_err(|e| format!("Failed to create manifest directory: {}", e))?;
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
let manifest_json = serde_json::to_string(&manifest)
|
|
1045
|
-
.map_err(|e| format!("Failed to serialize manifest: {}", e))?;
|
|
1046
|
-
|
|
1047
|
-
let manifest_js = format!(
|
|
1048
|
-
"// Pre-compiled manifest for /{}\n// Generated by Vibe compiler\n\nexport default {};\n",
|
|
1049
|
-
manifest_rel,
|
|
1050
|
-
manifest_json
|
|
1051
|
-
);
|
|
1052
|
-
|
|
1053
|
-
atomic_write(&manifest_path, &manifest_js)
|
|
1054
|
-
.map_err(|e| format!("Failed to write manifest: {}", e))?;
|
|
1055
|
-
|
|
1056
|
-
// Stamp the compiled HTML with initial state values for FOUC prevention:
|
|
1057
|
-
// - @[...] bindings replaced with their initial values
|
|
1058
|
-
// - Only the active conditional branch is kept (inactive branch stripped)
|
|
1059
|
-
// - Iterations are pre-rendered with initial array data
|
|
1060
|
-
// The manifest (written above) preserves BOTH branches so the runtime can
|
|
1061
|
-
// restore them and switch between branches reactively.
|
|
1062
|
-
//
|
|
1063
|
-
// The SPA shell opts out (stamp: false): its state is location-dependent
|
|
1064
|
-
// — unstampable by design — and it is served at EVERY route path while
|
|
1065
|
-
// its manifest only resolves at /. Stamping would strip the raw
|
|
1066
|
-
// `@[page.src]` outlet binding and leave deep links with nothing to
|
|
1067
|
-
// hydrate from; unstamped, the runtime processes the raw binding at any
|
|
1068
|
-
// URL (vibe-fouc covers the flash).
|
|
1069
|
-
if stamp {
|
|
1070
|
-
let stamper = ValueStamper::with_constants(&state, components_as_is, global_constants)
|
|
1071
|
-
.map_err(|e| format!("Failed to create value stamper: {}", e))?;
|
|
1072
|
-
let stamped = stamper.stamp_html(html.to_string())
|
|
1073
|
-
.map_err(|e| format!("Failed to stamp HTML: {}", e))?;
|
|
1074
|
-
|
|
1075
|
-
atomic_write(html_path, &stamped)
|
|
1076
|
-
.map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
Ok(())
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
/// Resolve the global `$` state keys that reassignment analysis proves are
|
|
1083
|
-
/// compile-time constants, mapped to their resolved values — the only globals
|
|
1084
|
-
/// safe to value-stamp into pre-rendered HTML. Scans every JS source under the
|
|
1085
|
-
/// project source root (`.js` modules, inline `<script>` bodies, and `on*`
|
|
1086
|
-
/// handler bodies), skipping the compiled output and the configured skips.
|
|
1087
|
-
/// Returns an empty map on any doubt (see reassignment_analyzer soundness).
|
|
1088
|
-
fn compute_global_constants(&self) -> Map<String, Value> {
|
|
1089
|
-
use crate::compiler::js_analyzer::JsAnalyzer;
|
|
1090
|
-
use crate::compiler::reassignment_analyzer::classify_constant_keys;
|
|
1091
|
-
|
|
1092
|
-
let root = &self.config.source;
|
|
1093
|
-
let output_canon = self.config.output.canonicalize().ok();
|
|
1094
|
-
|
|
1095
|
-
let mut sources: Vec<String> = Vec::new();
|
|
1096
|
-
let mut js_paths: Vec<PathBuf> = Vec::new();
|
|
1097
|
-
collect_state_sources(
|
|
1098
|
-
root,
|
|
1099
|
-
output_canon.as_deref(),
|
|
1100
|
-
&self.config.skip_files,
|
|
1101
|
-
&mut sources,
|
|
1102
|
-
&mut js_paths,
|
|
1103
|
-
);
|
|
1104
|
-
|
|
1105
|
-
// Resolve the global vibe()/state() initial state. Partial resolution
|
|
1106
|
-
// keeps statically-resolvable keys (e.g. `version: VERSION`) and drops
|
|
1107
|
-
// runtime-only ones (`settings: loadLocalStorage(...)`), which is exactly
|
|
1108
|
-
// the set we could ever stamp.
|
|
1109
|
-
let mut global_state: Map<String, Value> = Map::new();
|
|
1110
|
-
for path in &js_paths {
|
|
1111
|
-
let code = match fs::read_to_string(path) {
|
|
1112
|
-
Ok(c) => c,
|
|
1113
|
-
Err(_) => continue,
|
|
1114
|
-
};
|
|
1115
|
-
if !(code.contains("vibe(") || code.contains("state(")) {
|
|
1116
|
-
continue;
|
|
1117
|
-
}
|
|
1118
|
-
let mut analyzer = JsAnalyzer::new(root.clone());
|
|
1119
|
-
if let Some(Value::Object(state)) = analyzer.extract_state(&code) {
|
|
1120
|
-
for (key, value) in state {
|
|
1121
|
-
global_state.insert(key, value);
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
if global_state.is_empty() {
|
|
1126
|
-
return Map::new();
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
let constant_keys = classify_constant_keys(&global_state, &sources);
|
|
1130
|
-
global_state
|
|
1131
|
-
.into_iter()
|
|
1132
|
-
.filter(|(key, _)| constant_keys.contains(key))
|
|
1133
|
-
.collect()
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
/// Generate manifests (called separately from main.rs if needed)
|
|
1137
|
-
pub fn generate_manifests(&self) -> Result<ManifestStats, CompileError> {
|
|
1138
|
-
|
|
1139
|
-
let start = Instant::now();
|
|
1140
|
-
let mut pages_processed = 0;
|
|
1141
|
-
let mut pages_skipped = 0;
|
|
1142
|
-
|
|
1143
|
-
if self.verbose {
|
|
1144
|
-
println!("\nGenerating manifests (static analysis)");
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
// Find all HTML files in compiled output (excluding components directory)
|
|
1148
|
-
let html_files = self.find_all_html_files(&self.config.output)?;
|
|
1149
|
-
|
|
1150
|
-
// Process manifests in parallel
|
|
1151
|
-
let output_dir = self.config.output.clone();
|
|
1152
|
-
let source_root = self.config.source.clone();
|
|
1153
|
-
let verbose = self.verbose;
|
|
1154
|
-
let iterations_as_is = self.config.iterations_as_is;
|
|
1155
|
-
let components_as_is = self.config.components_as_is;
|
|
1156
|
-
let manifest_root = self.config.root.clone();
|
|
1157
|
-
let spa = self.config.spa;
|
|
1158
|
-
// Resolve which global state keys are constant once, then share across all
|
|
1159
|
-
// pages (read-only; `&Map` is Sync so the parallel map can borrow it).
|
|
1160
|
-
let global_constants = self.compute_global_constants();
|
|
1161
|
-
|
|
1162
|
-
let compiled_html = &self.compiled_html;
|
|
1163
|
-
let results: Vec<_> = html_files
|
|
1164
|
-
.par_iter()
|
|
1165
|
-
.map(|html_path| {
|
|
1166
|
-
let relative_path = html_path.strip_prefix(&output_dir)
|
|
1167
|
-
.unwrap()
|
|
1168
|
-
.to_str()
|
|
1169
|
-
.unwrap();
|
|
1170
|
-
|
|
1171
|
-
// Prefer the HTML this compiler produced in memory — the disk
|
|
1172
|
-
// copy may have been rewritten by another process since. Disk
|
|
1173
|
-
// is only a fallback for pages this compiler never compiled
|
|
1174
|
-
// (e.g. no-clean leftovers from an earlier run).
|
|
1175
|
-
let disk_html;
|
|
1176
|
-
let html: &str = match compiled_html.get(html_path.as_path()) {
|
|
1177
|
-
Some(h) => h,
|
|
1178
|
-
None => {
|
|
1179
|
-
disk_html = match fs::read_to_string(html_path) {
|
|
1180
|
-
Ok(h) => h,
|
|
1181
|
-
Err(_) => return (false, Some(relative_path.to_string())),
|
|
1182
|
-
};
|
|
1183
|
-
&disk_html
|
|
1184
|
-
}
|
|
1185
|
-
};
|
|
1186
|
-
|
|
1187
|
-
// Try to generate manifest for this file (skip on error).
|
|
1188
|
-
// The SPA shell keeps its raw bindings (manifest yes, stamp no).
|
|
1189
|
-
let stamp = !(spa && relative_path == "index.html");
|
|
1190
|
-
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, stamp) {
|
|
1191
|
-
Ok(()) => (true, None),
|
|
1192
|
-
Err(e) => {
|
|
1193
|
-
if verbose {
|
|
1194
|
-
eprintln!(" Skipped ({}): {}", e, relative_path);
|
|
1195
|
-
}
|
|
1196
|
-
(false, Some(relative_path.to_string()))
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
})
|
|
1200
|
-
.collect();
|
|
1201
|
-
|
|
1202
|
-
// Count results
|
|
1203
|
-
for (success, _) in results {
|
|
1204
|
-
if success {
|
|
1205
|
-
pages_processed += 1;
|
|
1206
|
-
} else {
|
|
1207
|
-
pages_skipped += 1;
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
let total_time_ms = start.elapsed().as_millis() as f64;
|
|
1212
|
-
|
|
1213
|
-
Ok(ManifestStats {
|
|
1214
|
-
pages_processed,
|
|
1215
|
-
pages_skipped,
|
|
1216
|
-
total_time_ms,
|
|
1217
|
-
})
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
/// Process directory for HTML files only (used for pages directory)
|
|
1221
|
-
fn process_directory_html_only(
|
|
1222
|
-
&mut self,
|
|
1223
|
-
dir: &Path,
|
|
1224
|
-
parser: &HtmlParser,
|
|
1225
|
-
relative_path: &str,
|
|
1226
|
-
canonical_output: &Path,
|
|
1227
|
-
canonical_source: &Path,
|
|
1228
|
-
stats: &mut CompileStats,
|
|
1229
|
-
) -> Result<(), CompileError> {
|
|
1230
|
-
let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
|
|
1231
|
-
path: dir.display().to_string(),
|
|
1232
|
-
source: e,
|
|
1233
|
-
})?;
|
|
1234
|
-
|
|
1235
|
-
for entry in entries.flatten() {
|
|
1236
|
-
let path = entry.path();
|
|
1237
|
-
let file_name = path.file_name().unwrap().to_str().unwrap();
|
|
1238
|
-
|
|
1239
|
-
if path.is_dir() {
|
|
1240
|
-
// Skip output directory (dynamically check, not hardcoded "compiled")
|
|
1241
|
-
let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
|
|
1242
|
-
if canonical_path == *canonical_output || canonical_path.starts_with(canonical_output) {
|
|
1243
|
-
continue;
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
// Skip special directories
|
|
1247
|
-
if should_skip_path(&path, file_name, &self.config.skip_files) {
|
|
1248
|
-
continue;
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
// Skip components directory
|
|
1252
|
-
if file_name == self.config.components {
|
|
1253
|
-
continue;
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
// SPA mode: the pages tree becomes fragments + shell via
|
|
1257
|
-
// compile_spa — no mirrored MPA documents in the output.
|
|
1258
|
-
if self.config.spa && self.is_spa_pages_dir(&canonical_path) {
|
|
1259
|
-
continue;
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
// Recurse into subdirectory
|
|
1263
|
-
let new_relative = if relative_path.is_empty() {
|
|
1264
|
-
file_name.to_string()
|
|
1265
|
-
} else {
|
|
1266
|
-
format!("{}/{}", relative_path, file_name)
|
|
1267
|
-
};
|
|
1268
|
-
|
|
1269
|
-
self.process_directory_html_only(&path, parser, &new_relative, canonical_output, canonical_source, stats)?;
|
|
1270
|
-
} else if path.extension().and_then(|e| e.to_str()) == Some("html") {
|
|
1271
|
-
let (internal, external, component_srcs) = self.compile_html_file(&path, parser, relative_path)?;
|
|
1272
|
-
stats.files_compiled += 1;
|
|
1273
|
-
stats.internal_components_total += internal;
|
|
1274
|
-
stats.external_components_total += external;
|
|
1275
|
-
|
|
1276
|
-
// Track unique components
|
|
1277
|
-
for src in &component_srcs {
|
|
1278
|
-
self.unique_components.insert(src.clone());
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
// Build component relationships and count occurrences before borrowing logger
|
|
1282
|
-
let (component_relationships, all_srcs) = if self.logger.is_some() && !component_srcs.is_empty() {
|
|
1283
|
-
let relationships = self.build_component_relationships(&component_srcs);
|
|
1284
|
-
(Some(relationships), Some(component_srcs))
|
|
1285
|
-
} else {
|
|
1286
|
-
(None, None)
|
|
1287
|
-
};
|
|
1288
|
-
|
|
1289
|
-
if let Some(ref mut logger) = self.logger {
|
|
1290
|
-
logger.log(&path, FileOperation::Compiled, canonical_source);
|
|
1291
|
-
|
|
1292
|
-
// Log each component occurrence (for counting)
|
|
1293
|
-
if let Some(all_srcs) = all_srcs {
|
|
1294
|
-
for src in all_srcs {
|
|
1295
|
-
logger.log_component_occurrence(src);
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
// Log unique relationships (for tree structure)
|
|
1300
|
-
if let Some(relationships) = component_relationships {
|
|
1301
|
-
for (src, children) in relationships {
|
|
1302
|
-
logger.log_component_children(src, children);
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
Ok(())
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
/// Process directory for assets only (CSS, JS, images, etc.) - skip HTML
|
|
1313
|
-
fn process_directory_assets_only(
|
|
1314
|
-
&mut self,
|
|
1315
|
-
dir: &Path,
|
|
1316
|
-
relative_path: &str,
|
|
1317
|
-
canonical_output: &Path,
|
|
1318
|
-
canonical_source: &Path,
|
|
1319
|
-
stats: &mut CompileStats,
|
|
1320
|
-
) -> Result<(), CompileError> {
|
|
1321
|
-
let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
|
|
1322
|
-
path: dir.display().to_string(),
|
|
1323
|
-
source: e,
|
|
1324
|
-
})?;
|
|
1325
|
-
|
|
1326
|
-
for entry in entries.flatten() {
|
|
1327
|
-
let path = entry.path();
|
|
1328
|
-
let file_name = path.file_name().unwrap().to_str().unwrap();
|
|
1329
|
-
|
|
1330
|
-
if path.is_dir() {
|
|
1331
|
-
// Skip output directory (dynamically check, not hardcoded "compiled")
|
|
1332
|
-
let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
|
|
1333
|
-
if canonical_path == *canonical_output || canonical_path.starts_with(canonical_output) {
|
|
1334
|
-
continue;
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
// Skip special directories
|
|
1338
|
-
if should_skip_path(&path, file_name, &self.config.skip_files) {
|
|
1339
|
-
continue;
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
// Skip node_modules (handled separately)
|
|
1343
|
-
if file_name == "node_modules" {
|
|
1344
|
-
continue;
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
// The components directory is always mirrored to the output. With
|
|
1348
|
-
// components_as_is every component is loaded at runtime; otherwise
|
|
1349
|
-
// most are inlined at build time, but iter-prop each-root components
|
|
1350
|
-
// are deliberately left as runtime `<component src>` tags (see
|
|
1351
|
-
// is_iter_prop_root in parser/html.rs) and the runtime fetches their
|
|
1352
|
-
// source from here — exactly as it does in non-compiled mode.
|
|
1353
|
-
if file_name == self.config.components {
|
|
1354
|
-
let new_relative = if relative_path.is_empty() {
|
|
1355
|
-
file_name.to_string()
|
|
1356
|
-
} else {
|
|
1357
|
-
format!("{}/{}", relative_path, file_name)
|
|
1358
|
-
};
|
|
1359
|
-
self.copy_directory(&path, &new_relative, canonical_source, stats)?;
|
|
1360
|
-
// Skip further processing (don't recurse into components)
|
|
1361
|
-
continue;
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
// Recurse into subdirectory
|
|
1365
|
-
let new_relative = if relative_path.is_empty() {
|
|
1366
|
-
file_name.to_string()
|
|
1367
|
-
} else {
|
|
1368
|
-
format!("{}/{}", relative_path, file_name)
|
|
1369
|
-
};
|
|
1370
|
-
|
|
1371
|
-
self.process_directory_assets_only(&path, &new_relative, canonical_output, canonical_source, stats)?;
|
|
1372
|
-
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
|
1373
|
-
// Skip files matching skip patterns
|
|
1374
|
-
if should_skip_path(&path, file_name, &self.config.skip_files) {
|
|
1375
|
-
continue;
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
// Only copy non-HTML assets
|
|
1379
|
-
if ext != "html" && MIRROR_EXTENSIONS.contains(&ext) {
|
|
1380
|
-
self.copy_file(&path, relative_path)?;
|
|
1381
|
-
stats.files_copied += 1;
|
|
1382
|
-
if let Some(ref mut logger) = self.logger {
|
|
1383
|
-
logger.log(&path, FileOperation::Copied, canonical_source);
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
Ok(())
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
fn compile_html_file(
|
|
1393
|
-
&mut self,
|
|
1394
|
-
path: &Path,
|
|
1395
|
-
parser: &HtmlParser,
|
|
1396
|
-
relative_path: &str,
|
|
1397
|
-
) -> Result<(usize, usize, Vec<String>), CompileError> {
|
|
1398
|
-
let content = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
|
|
1399
|
-
path: path.display().to_string(),
|
|
1400
|
-
source: e,
|
|
1401
|
-
})?;
|
|
1402
|
-
|
|
1403
|
-
// Count <component src="..."> instances before processing (only if not components_as_is)
|
|
1404
|
-
let (internal_count, external_count, component_srcs) = if !self.config.components_as_is {
|
|
1405
|
-
self.extract_components(&content)
|
|
1406
|
-
} else {
|
|
1407
|
-
(0, 0, Vec::new())
|
|
1408
|
-
};
|
|
1409
|
-
|
|
1410
|
-
// Compile: transform custom tags to <component>, inline if needed, transform custom elements
|
|
1411
|
-
let processed = parser.process_html_with_cache(
|
|
1412
|
-
&content,
|
|
1413
|
-
self.config.elements_as_is,
|
|
1414
|
-
&self.config.reserved_elements,
|
|
1415
|
-
self.config.components_as_is,
|
|
1416
|
-
&self.config.components,
|
|
1417
|
-
&self.component_cache,
|
|
1418
|
-
);
|
|
1419
|
-
|
|
1420
|
-
// Minify if requested
|
|
1421
|
-
let mut output = if self.config.minify {
|
|
1422
|
-
minify_html(&processed)
|
|
1423
|
-
} else {
|
|
1424
|
-
processed
|
|
1425
|
-
};
|
|
1426
|
-
|
|
1427
|
-
// Remove FOUC prevention unless fouc_as_is is enabled
|
|
1428
|
-
if !self.config.fouc_as_is {
|
|
1429
|
-
output = remove_fouc_prevention(output);
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
// Write to output
|
|
1433
|
-
let output_path = self.get_output_path(path, relative_path)?;
|
|
1434
|
-
atomic_write(&output_path, &output).map_err(|e| CompileError::WriteError {
|
|
1435
|
-
path: output_path.display().to_string(),
|
|
1436
|
-
source: e,
|
|
1437
|
-
})?;
|
|
1438
|
-
|
|
1439
|
-
// Keep the exact bytes for this pass's manifest generation.
|
|
1440
|
-
self.compiled_html.insert(output_path, output);
|
|
1441
|
-
|
|
1442
|
-
// Return component counts and src list
|
|
1443
|
-
Ok((internal_count, external_count, component_srcs))
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
/// True when a canonicalized directory is the configured pages tree.
|
|
1447
|
-
fn is_spa_pages_dir(&self, canonical_path: &Path) -> bool {
|
|
1448
|
-
self.config
|
|
1449
|
-
.source
|
|
1450
|
-
.join(&self.config.pages)
|
|
1451
|
-
.canonicalize()
|
|
1452
|
-
.map(|pages| pages == *canonical_path)
|
|
1453
|
-
.unwrap_or(false)
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
fn collect_page_files(dir: &Path, skip: &[String], files: &mut Vec<PathBuf>) {
|
|
1457
|
-
let Ok(entries) = fs::read_dir(dir) else { return };
|
|
1458
|
-
for entry in entries.flatten() {
|
|
1459
|
-
let path = entry.path();
|
|
1460
|
-
let name = entry.file_name();
|
|
1461
|
-
let name = name.to_string_lossy();
|
|
1462
|
-
if should_skip_path(&path, &name, skip) {
|
|
1463
|
-
continue;
|
|
1464
|
-
}
|
|
1465
|
-
if path.is_dir() {
|
|
1466
|
-
Self::collect_page_files(&path, skip, files);
|
|
1467
|
-
} else if path.extension().and_then(|e| e.to_str()) == Some("html") {
|
|
1468
|
-
files.push(path);
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
|
-
/// SPA mode (fetched): transform every page into a fragment under
|
|
1474
|
-
/// output/components/vibe-spa/, then compose the shell at output root.
|
|
1475
|
-
/// Per-page stamped HTML, per-page manifests, and the pages/ output dir
|
|
1476
|
-
/// do not exist in SPA mode — fragments are runtime-parsed on mount; the
|
|
1477
|
-
/// shell flows through the normal manifest pipeline via compiled_html.
|
|
1478
|
-
/// Returns the number of files written (pages + shell) for stats.
|
|
1479
|
-
///
|
|
1480
|
-
/// Watch mode re-runs this whole pass on any pages-tree change — the
|
|
1481
|
-
/// shell depends on every page's head/title/body attrs and the pass is
|
|
1482
|
-
/// a few milliseconds, so per-page incrementality would buy nothing.
|
|
1483
|
-
pub(crate) fn compile_spa(&mut self, parser: &HtmlParser) -> Result<usize, CompileError> {
|
|
1484
|
-
use crate::compiler::spa;
|
|
1485
|
-
|
|
1486
|
-
let pages_dir = self.config.source.join(&self.config.pages);
|
|
1487
|
-
if !pages_dir.exists() {
|
|
1488
|
-
return Err(CompileError::SpaError(format!(
|
|
1489
|
-
"needs a pages directory at {}",
|
|
1490
|
-
pages_dir.display()
|
|
1491
|
-
)));
|
|
1492
|
-
}
|
|
1493
|
-
let reserved = self.config.components_path().join("vibe-spa");
|
|
1494
|
-
if reserved.exists() {
|
|
1495
|
-
return Err(CompileError::SpaError(format!(
|
|
1496
|
-
"{} is reserved for compiled page fragments — move or rename it",
|
|
1497
|
-
reserved.display()
|
|
1498
|
-
)));
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
let mut page_files = Vec::new();
|
|
1502
|
-
Self::collect_page_files(&pages_dir, &self.config.skip_files, &mut page_files);
|
|
1503
|
-
page_files.sort();
|
|
1504
|
-
if page_files.is_empty() {
|
|
1505
|
-
return Err(CompileError::SpaError(format!(
|
|
1506
|
-
"found no pages under {}",
|
|
1507
|
-
pages_dir.display()
|
|
1508
|
-
)));
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
let mut pages = Vec::new();
|
|
1512
|
-
for path in &page_files {
|
|
1513
|
-
let rel = path
|
|
1514
|
-
.strip_prefix(&pages_dir)
|
|
1515
|
-
.unwrap()
|
|
1516
|
-
.to_string_lossy()
|
|
1517
|
-
.replace('\\', "/");
|
|
1518
|
-
let content = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
|
|
1519
|
-
path: path.display().to_string(),
|
|
1520
|
-
source: e,
|
|
1521
|
-
})?;
|
|
1522
|
-
let processed = parser.process_html_with_cache(
|
|
1523
|
-
&content,
|
|
1524
|
-
self.config.elements_as_is,
|
|
1525
|
-
&self.config.reserved_elements,
|
|
1526
|
-
self.config.components_as_is,
|
|
1527
|
-
&self.config.components,
|
|
1528
|
-
&self.component_cache,
|
|
1529
|
-
);
|
|
1530
|
-
// Build-inlined children get the compiled-document treatment:
|
|
1531
|
-
// tagged wrapper ids + this.→_cN rewrites, matching their
|
|
1532
|
-
// vibe-module scripts (the runtime's mounted-subtree pass claims
|
|
1533
|
-
// ids via closest wrapper). Same protect→tag order as the
|
|
1534
|
-
// manifest pipeline; the extracted state is a page concern and
|
|
1535
|
-
// fragments have none to stamp.
|
|
1536
|
-
let protected = crate::compiler::name_binding_protect::protect(&processed);
|
|
1537
|
-
let tagged =
|
|
1538
|
-
crate::compiler::component_tagger::ComponentTagger::tag_components(
|
|
1539
|
-
&protected,
|
|
1540
|
-
&self.config.source,
|
|
1541
|
-
)
|
|
1542
|
-
.map_err(CompileError::SpaError)?;
|
|
1543
|
-
let page = spa::dissect_page(&rel, &tagged.html).map_err(CompileError::SpaError)?;
|
|
1544
|
-
|
|
1545
|
-
if let Some(api) = page.hygiene_offender {
|
|
1546
|
-
eprintln!(
|
|
1547
|
-
"{}: pages/{} starts side effects ({}) without an $.on('unmount') teardown — under SPA these outlive navigation",
|
|
1548
|
-
"Warning".yellow(),
|
|
1549
|
-
rel,
|
|
1550
|
-
api
|
|
1551
|
-
);
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
let fragment_path = self
|
|
1555
|
-
.config
|
|
1556
|
-
.output
|
|
1557
|
-
.join("components")
|
|
1558
|
-
.join("vibe-spa")
|
|
1559
|
-
.join(&rel);
|
|
1560
|
-
if let Some(parent) = fragment_path.parent() {
|
|
1561
|
-
fs::create_dir_all(parent).map_err(|_| {
|
|
1562
|
-
CompileError::CreateDirError(parent.display().to_string())
|
|
1563
|
-
})?;
|
|
1564
|
-
}
|
|
1565
|
-
atomic_write(&fragment_path, &page.fragment).map_err(|e| CompileError::WriteError {
|
|
1566
|
-
path: fragment_path.display().to_string(),
|
|
1567
|
-
source: e,
|
|
1568
|
-
})?;
|
|
1569
|
-
|
|
1570
|
-
pages.push(page);
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
// The fragments dir mirrors the pages tree: prune fragments whose
|
|
1574
|
-
// page no longer exists (watch-mode page removals, no-clean runs).
|
|
1575
|
-
let fragments_root = self.config.output.join("components").join("vibe-spa");
|
|
1576
|
-
let live: HashSet<String> = pages.iter().map(|p| p.rel_path.clone()).collect();
|
|
1577
|
-
let mut existing = Vec::new();
|
|
1578
|
-
Self::collect_page_files(&fragments_root, &self.config.skip_files, &mut existing);
|
|
1579
|
-
for stale in existing {
|
|
1580
|
-
let rel = stale
|
|
1581
|
-
.strip_prefix(&fragments_root)
|
|
1582
|
-
.unwrap()
|
|
1583
|
-
.to_string_lossy()
|
|
1584
|
-
.replace('\\', "/");
|
|
1585
|
-
if !live.contains(&rel) {
|
|
1586
|
-
let _ = fs::remove_file(&stale);
|
|
1587
|
-
}
|
|
1588
|
-
}
|
|
1589
|
-
|
|
1590
|
-
let order = spa::route_order(&pages);
|
|
1591
|
-
let shell = spa::compose_shell(&pages, &order);
|
|
1592
|
-
if self.verbose {
|
|
1593
|
-
for note in &shell.notes {
|
|
1594
|
-
println!(" spa: {}", note);
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
if self.config.source.join("index.html").exists() {
|
|
1599
|
-
eprintln!(
|
|
1600
|
-
"{}: SPA shell overwrites the compiled root index.html (source has its own index.html outside the pages tree)",
|
|
1601
|
-
"Warning".yellow()
|
|
1602
|
-
);
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
let shell_html = if self.config.minify {
|
|
1606
|
-
minify_html(&shell.html)
|
|
1607
|
-
} else {
|
|
1608
|
-
shell.html
|
|
1609
|
-
};
|
|
1610
|
-
let shell_path = self.config.output.join("index.html");
|
|
1611
|
-
atomic_write(&shell_path, &shell_html).map_err(|e| CompileError::WriteError {
|
|
1612
|
-
path: shell_path.display().to_string(),
|
|
1613
|
-
source: e,
|
|
1614
|
-
})?;
|
|
1615
|
-
// The shell rides the normal manifest pipeline like any page.
|
|
1616
|
-
self.compiled_html.insert(shell_path, shell_html);
|
|
1617
|
-
|
|
1618
|
-
Ok(pages.len() + 1)
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
/// Regenerate the shell's manifest after a watch-mode recompose (the full
|
|
1622
|
-
/// build gets it via generate_manifests; incremental recompiles target
|
|
1623
|
-
/// only changed files, and the shell has no source counterpart to list).
|
|
1624
|
-
/// Manifest yes, stamp no — same rule as the full pipeline.
|
|
1625
|
-
pub(crate) fn generate_shell_manifest(&self) -> Result<(), CompileError> {
|
|
1626
|
-
let shell_path = self.config.output.join("index.html");
|
|
1627
|
-
let disk_html;
|
|
1628
|
-
let html: &str = match self.compiled_html.get(&shell_path) {
|
|
1629
|
-
Some(html) => html,
|
|
1630
|
-
None => {
|
|
1631
|
-
disk_html = fs::read_to_string(&shell_path).map_err(|e| CompileError::ReadError {
|
|
1632
|
-
path: shell_path.display().to_string(),
|
|
1633
|
-
source: e,
|
|
1634
|
-
})?;
|
|
1635
|
-
&disk_html
|
|
1636
|
-
}
|
|
1637
|
-
};
|
|
1638
|
-
Self::generate_file_manifest(
|
|
1639
|
-
html,
|
|
1640
|
-
&shell_path,
|
|
1641
|
-
&self.config.output,
|
|
1642
|
-
"index.html",
|
|
1643
|
-
self.verbose,
|
|
1644
|
-
self.config.iterations_as_is,
|
|
1645
|
-
self.config.components_as_is,
|
|
1646
|
-
&self.config.source,
|
|
1647
|
-
self.config.root.as_deref(),
|
|
1648
|
-
&self.compute_global_constants(),
|
|
1649
|
-
false,
|
|
1650
|
-
)
|
|
1651
|
-
.map_err(CompileError::SpaError)
|
|
1652
|
-
}
|
|
1653
|
-
|
|
1654
|
-
fn extract_components(&self, content: &str) -> (usize, usize, Vec<String>) {
|
|
1655
|
-
let mut internal = 0;
|
|
1656
|
-
let mut external = 0;
|
|
1657
|
-
let mut srcs = Vec::new();
|
|
1658
|
-
|
|
1659
|
-
// Simple regex-based extraction of <component src="..."> tags
|
|
1660
|
-
let re = Regex::new(r#"<component[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
|
|
1661
|
-
for cap in re.captures_iter(content) {
|
|
1662
|
-
if let Some(src) = cap.get(1) {
|
|
1663
|
-
let src_str = src.as_str().to_string();
|
|
1664
|
-
if src_str.starts_with("http://") || src_str.starts_with("https://") {
|
|
1665
|
-
external += 1;
|
|
1666
|
-
} else {
|
|
1667
|
-
internal += 1;
|
|
1668
|
-
}
|
|
1669
|
-
srcs.push(src_str);
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
(internal, external, srcs)
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
fn build_component_relationships(&mut self, component_srcs: &[String]) -> Vec<(String, Vec<String>)> {
|
|
1677
|
-
let mut relationships = Vec::new();
|
|
1678
|
-
let mut visited = HashSet::new();
|
|
1679
|
-
|
|
1680
|
-
for src in component_srcs {
|
|
1681
|
-
self.collect_component_children(src, &mut relationships, &mut visited);
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
relationships
|
|
1685
|
-
}
|
|
1686
|
-
|
|
1687
|
-
fn collect_component_children(&mut self, component_src: &str, relationships: &mut Vec<(String, Vec<String>)>, visited: &mut HashSet<String>) {
|
|
1688
|
-
if visited.contains(component_src) {
|
|
1689
|
-
return;
|
|
1690
|
-
}
|
|
1691
|
-
visited.insert(component_src.to_string());
|
|
1692
|
-
|
|
1693
|
-
// Read component content
|
|
1694
|
-
let content = if component_src.starts_with("http://") || component_src.starts_with("https://") {
|
|
1695
|
-
match self.fetch_external_component_raw(component_src) {
|
|
1696
|
-
Ok(c) => c,
|
|
1697
|
-
Err(_) => return,
|
|
1698
|
-
}
|
|
1699
|
-
} else {
|
|
1700
|
-
match self.resolve_component_path(component_src) {
|
|
1701
|
-
Ok(path) => match fs::read_to_string(&path) {
|
|
1702
|
-
Ok(c) => c,
|
|
1703
|
-
Err(_) => return,
|
|
1704
|
-
},
|
|
1705
|
-
Err(_) => return,
|
|
1706
|
-
}
|
|
1707
|
-
};
|
|
1708
|
-
|
|
1709
|
-
// Extract immediate children
|
|
1710
|
-
let children = self.extract_component_srcs(&content);
|
|
1711
|
-
|
|
1712
|
-
// Add this component and its children to relationships
|
|
1713
|
-
relationships.push((component_src.to_string(), children.clone()));
|
|
1714
|
-
|
|
1715
|
-
// Recursively collect children's relationships
|
|
1716
|
-
for child in children {
|
|
1717
|
-
self.collect_component_children(&child, relationships, visited);
|
|
1718
|
-
}
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
/// MIRROR_MODE: Copy file to output, preserving relative path
|
|
1723
|
-
fn copy_file(&self, path: &Path, relative_path: &str) -> Result<(), CompileError> {
|
|
1724
|
-
let output_path = self.get_output_path(path, relative_path)?;
|
|
1725
|
-
|
|
1726
|
-
fs::copy(path, &output_path).map_err(|e| CompileError::WriteError {
|
|
1727
|
-
path: output_path.display().to_string(),
|
|
1728
|
-
source: e,
|
|
1729
|
-
})?;
|
|
1730
|
-
|
|
1731
|
-
Ok(())
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
|
-
/// Re-mirror specific source files under the components directory into the
|
|
1735
|
-
/// output. The full build always mirrors components/ verbatim (see
|
|
1736
|
-
/// process_directory_assets_only): runtime-fetched components —
|
|
1737
|
-
/// `<component src="@[page.src]">` targets, iter-prop each-roots,
|
|
1738
|
-
/// components_as_is — are served from that mirror at request time. Watch
|
|
1739
|
-
/// mode calls this for every changed component so the mirror tracks edits
|
|
1740
|
-
/// and deletions even when no page inlines the component (zero graph
|
|
1741
|
-
/// dependents). Writes are atomic (same tmp+rename as compiled pages) so a
|
|
1742
|
-
/// runtime fetch mid-copy never sees a torn file. Returns how many files
|
|
1743
|
-
/// were copied.
|
|
1744
|
-
pub fn mirror_component_files(&self, files: &[PathBuf]) -> Result<usize, CompileError> {
|
|
1745
|
-
let mut copied = 0;
|
|
1746
|
-
for path in files {
|
|
1747
|
-
let relative = path.strip_prefix(&self.config.source).unwrap_or(path);
|
|
1748
|
-
let output_path = self.config.output.join(relative);
|
|
1749
|
-
if path.exists() {
|
|
1750
|
-
if let Some(parent) = output_path.parent() {
|
|
1751
|
-
fs::create_dir_all(parent).map_err(|e| CompileError::WriteError {
|
|
1752
|
-
path: parent.display().to_string(),
|
|
1753
|
-
source: e,
|
|
1754
|
-
})?;
|
|
1755
|
-
}
|
|
1756
|
-
let contents = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
|
|
1757
|
-
path: path.display().to_string(),
|
|
1758
|
-
source: e,
|
|
1759
|
-
})?;
|
|
1760
|
-
atomic_write(&output_path, &contents).map_err(|e| CompileError::WriteError {
|
|
1761
|
-
path: output_path.display().to_string(),
|
|
1762
|
-
source: e,
|
|
1763
|
-
})?;
|
|
1764
|
-
copied += 1;
|
|
1765
|
-
} else if output_path.exists() {
|
|
1766
|
-
fs::remove_file(&output_path).map_err(|e| CompileError::WriteError {
|
|
1767
|
-
path: output_path.display().to_string(),
|
|
1768
|
-
source: e,
|
|
1769
|
-
})?;
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
Ok(copied)
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
/// Fetch components only for specific files (used in incremental compilation)
|
|
1776
|
-
fn fetch_components_for_files(&mut self, files: &[PathBuf], parser: &HtmlParser) -> Result<(), CompileError> {
|
|
1777
|
-
for html_file in files {
|
|
1778
|
-
let content = match fs::read_to_string(html_file) {
|
|
1779
|
-
Ok(c) => c,
|
|
1780
|
-
Err(e) => {
|
|
1781
|
-
if self.verbose {
|
|
1782
|
-
eprintln!(" Warning: Failed to read {}: {}", html_file.display(), e);
|
|
1783
|
-
}
|
|
1784
|
-
continue;
|
|
1785
|
-
}
|
|
1786
|
-
};
|
|
1787
|
-
|
|
1788
|
-
// Transform custom tags to <component> tags
|
|
1789
|
-
let transformed = parser.process_html(
|
|
1790
|
-
&content,
|
|
1791
|
-
self.config.elements_as_is,
|
|
1792
|
-
&self.config.reserved_elements,
|
|
1793
|
-
true, // components_as_is (don't inline, just transform)
|
|
1794
|
-
&self.config.components,
|
|
1795
|
-
);
|
|
1796
|
-
|
|
1797
|
-
// Extract and fetch components recursively
|
|
1798
|
-
let components = self.extract_component_srcs(&transformed);
|
|
1799
|
-
for component_src in components {
|
|
1800
|
-
let mut visiting = std::collections::HashSet::new();
|
|
1801
|
-
self.fetch_component_recursive(&component_src, parser, &mut visiting);
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
Ok(())
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
/// Fetch and cache all components for inlining (separate from validation)
|
|
1809
|
-
/// This recursively fetches all components (external and internal) and populates component_cache
|
|
1810
|
-
fn fetch_all_components(&mut self, parser: &HtmlParser) -> Result<(), CompileError> {
|
|
1811
|
-
// Find all HTML files in source directory (need to scan pages to find external component references)
|
|
1812
|
-
let html_files = self.find_all_html_files(&self.config.source)?;
|
|
1813
|
-
|
|
1814
|
-
// Scan each HTML file for components
|
|
1815
|
-
for html_file in html_files {
|
|
1816
|
-
let content = match fs::read_to_string(&html_file) {
|
|
1817
|
-
Ok(c) => c,
|
|
1818
|
-
Err(e) => {
|
|
1819
|
-
if self.verbose {
|
|
1820
|
-
eprintln!(" Warning: Failed to read {}: {}", html_file.display(), e);
|
|
1821
|
-
}
|
|
1822
|
-
continue;
|
|
1823
|
-
}
|
|
1824
|
-
};
|
|
1825
|
-
|
|
1826
|
-
// Transform custom tags to <component> tags
|
|
1827
|
-
let transformed = parser.process_html(
|
|
1828
|
-
&content,
|
|
1829
|
-
self.config.elements_as_is,
|
|
1830
|
-
&self.config.reserved_elements,
|
|
1831
|
-
true, // components_as_is (don't inline, just transform)
|
|
1832
|
-
&self.config.components,
|
|
1833
|
-
);
|
|
1834
|
-
|
|
1835
|
-
// Extract and fetch components recursively
|
|
1836
|
-
let components = self.extract_component_srcs(&transformed);
|
|
1837
|
-
for component_src in components {
|
|
1838
|
-
let mut visiting = std::collections::HashSet::new();
|
|
1839
|
-
self.fetch_component_recursive(&component_src, parser, &mut visiting);
|
|
1840
|
-
}
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
Ok(())
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
/// Recursively fetch a component and its nested components (for inlining).
|
|
1847
|
-
/// Returns the fully inlined content (all nested components resolved).
|
|
1848
|
-
///
|
|
1849
|
-
/// `visiting` tracks the chain of components currently being fetched. A
|
|
1850
|
-
/// `<component src>` that points back into the chain is a cycle (direct
|
|
1851
|
-
/// self-reference, or A→B→A) — those are valid runtime patterns (recursive
|
|
1852
|
-
/// tree components bounded by data depth) but would expand infinitely at
|
|
1853
|
-
/// compile time. Cycle references are escaped to `data-vibe-recursive-src`
|
|
1854
|
-
/// in the cached content so the inliner's `src=` regex won't keep matching
|
|
1855
|
-
/// them; `process_html_with_cache` restores the attribute name at the end
|
|
1856
|
-
/// of compilation so the runtime sees a normal `<component src>` tag.
|
|
1857
|
-
fn fetch_component_recursive(
|
|
1858
|
-
&mut self,
|
|
1859
|
-
component_src: &str,
|
|
1860
|
-
parser: &HtmlParser,
|
|
1861
|
-
visiting: &mut std::collections::HashSet<String>,
|
|
1862
|
-
) -> Option<String> {
|
|
1863
|
-
// Normalize path: ensure it starts with / (unless it's a URL)
|
|
1864
|
-
let normalized_src = if component_src.starts_with("http://") || component_src.starts_with("https://") {
|
|
1865
|
-
component_src.to_string()
|
|
1866
|
-
} else {
|
|
1867
|
-
let without_prefix = component_src.trim_start_matches("./");
|
|
1868
|
-
if without_prefix.starts_with('/') {
|
|
1869
|
-
without_prefix.to_string()
|
|
1870
|
-
} else {
|
|
1871
|
-
format!("/{}", without_prefix)
|
|
1872
|
-
}
|
|
1873
|
-
};
|
|
1874
|
-
|
|
1875
|
-
// Return cached if already fetched and fully resolved
|
|
1876
|
-
if let Some(cached) = self.component_cache.get(&normalized_src) {
|
|
1877
|
-
return Some(cached.clone());
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
// Cycle: this component is already higher in the fetch chain. Don't
|
|
1881
|
-
// recurse — the caller will leave the `<component src>` tag as-is and
|
|
1882
|
-
// the runtime handles the recursion with real data bounds.
|
|
1883
|
-
if visiting.contains(&normalized_src) {
|
|
1884
|
-
return None;
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
// Fetch external or read internal component (raw content)
|
|
1888
|
-
let content = if normalized_src.starts_with("http://") || normalized_src.starts_with("https://") {
|
|
1889
|
-
match self.fetch_external_component_raw(&normalized_src) {
|
|
1890
|
-
Ok(c) => c,
|
|
1891
|
-
Err(e) => {
|
|
1892
|
-
if self.verbose {
|
|
1893
|
-
eprintln!(" Warning: Failed to fetch {}: {}", normalized_src, e);
|
|
1894
|
-
}
|
|
1895
|
-
return None;
|
|
1896
|
-
}
|
|
1897
|
-
}
|
|
1898
|
-
} else {
|
|
1899
|
-
match self.resolve_component_path(&normalized_src) {
|
|
1900
|
-
Ok(path) => match fs::read_to_string(&path) {
|
|
1901
|
-
Ok(c) => c,
|
|
1902
|
-
Err(e) => {
|
|
1903
|
-
if self.verbose {
|
|
1904
|
-
eprintln!(" Warning: Failed to read component {}: {}", normalized_src, e);
|
|
1905
|
-
}
|
|
1906
|
-
return None;
|
|
1907
|
-
}
|
|
1908
|
-
},
|
|
1909
|
-
Err(e) => {
|
|
1910
|
-
if self.verbose {
|
|
1911
|
-
eprintln!(" Warning: {}", e);
|
|
1912
|
-
}
|
|
1913
|
-
return None;
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
};
|
|
1917
|
-
|
|
1918
|
-
// Transform custom tags in component (but keep component tags as-is for now)
|
|
1919
|
-
let mut transformed = parser.process_html(
|
|
1920
|
-
&content,
|
|
1921
|
-
self.config.elements_as_is, // respect global config
|
|
1922
|
-
&self.config.reserved_elements.clone(), // respect global config
|
|
1923
|
-
true, // components_as_is = true (keep <component> tags for now so we can recursively resolve them)
|
|
1924
|
-
&self.config.components,
|
|
1925
|
-
);
|
|
1926
|
-
|
|
1927
|
-
// Mark this component as in-flight before recursing into its nested
|
|
1928
|
-
// refs. Cycles (direct self-reference or A→B→A chains) are detected
|
|
1929
|
-
// by the visiting check at the top of this function.
|
|
1930
|
-
visiting.insert(normalized_src.clone());
|
|
1931
|
-
|
|
1932
|
-
// Recursively fetch and inline all nested components
|
|
1933
|
-
let nested = self.extract_component_srcs(&transformed);
|
|
1934
|
-
for nested_src in nested {
|
|
1935
|
-
if let Some(nested_content) = self.fetch_component_recursive(&nested_src, parser, visiting) {
|
|
1936
|
-
// Inline this nested component into the current component
|
|
1937
|
-
transformed = parser.inline_single_component(&transformed, &nested_src, &nested_content);
|
|
1938
|
-
}
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
visiting.remove(&normalized_src);
|
|
1942
|
-
|
|
1943
|
-
// Any `<component src>` still in `transformed` whose target is in the
|
|
1944
|
-
// visiting chain (a cycle) didn't get inlined. Escape its `src=` so
|
|
1945
|
-
// `inline_component_elements` won't keep re-expanding it forever when
|
|
1946
|
-
// a page inlines this cached content. `process_html_with_cache`
|
|
1947
|
-
// restores the attribute to plain `src=` at the end of compilation so
|
|
1948
|
-
// the runtime fetches it normally.
|
|
1949
|
-
for ancestor in visiting.iter() {
|
|
1950
|
-
transformed = escape_recursive_src(&transformed, ancestor);
|
|
1951
|
-
}
|
|
1952
|
-
// Also escape direct self-references — a component pointing at itself
|
|
1953
|
-
// never made it into `visiting` (the cycle check short-circuited
|
|
1954
|
-
// before insertion), so handle it explicitly.
|
|
1955
|
-
transformed = escape_recursive_src(&transformed, &normalized_src);
|
|
1956
|
-
|
|
1957
|
-
// Cache the fully resolved content
|
|
1958
|
-
self.component_cache.insert(normalized_src.clone(), transformed.clone());
|
|
1959
|
-
|
|
1960
|
-
Some(transformed)
|
|
1961
|
-
}
|
|
1962
|
-
|
|
1963
|
-
/// Validate that component filenames don't match reserved elements
|
|
1964
|
-
fn validate_component_names(&self) -> Result<(), CompileError> {
|
|
1965
|
-
let components_dir = self.config.components_path();
|
|
1966
|
-
|
|
1967
|
-
if !components_dir.exists() {
|
|
1968
|
-
return Ok(()); // No components directory
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
let component_files = self.find_all_html_files(&components_dir)?;
|
|
1972
|
-
|
|
1973
|
-
for component_file in component_files {
|
|
1974
|
-
let file_name = component_file
|
|
1975
|
-
.file_stem()
|
|
1976
|
-
.and_then(|s| s.to_str())
|
|
1977
|
-
.unwrap_or("");
|
|
1978
|
-
|
|
1979
|
-
// Check if filename (case-sensitive) matches any reserved element
|
|
1980
|
-
if self.config.reserved_elements.contains(&file_name.to_string()) {
|
|
1981
|
-
let relative_path = component_file
|
|
1982
|
-
.strip_prefix(&self.config.source)
|
|
1983
|
-
.unwrap_or(&component_file);
|
|
1984
|
-
|
|
1985
|
-
return Err(CompileError::ReservedComponentName {
|
|
1986
|
-
component_name: file_name.to_string(),
|
|
1987
|
-
file_path: relative_path.display().to_string(),
|
|
1988
|
-
});
|
|
1989
|
-
}
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
Ok(())
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
/// Validate HTML syntax for all files (controlled by validate flag)
|
|
1996
|
-
fn validate_html_syntax(&self) -> Result<(), CompileError> {
|
|
1997
|
-
// Find all HTML files in source directory
|
|
1998
|
-
let html_files = self.find_all_html_files(&self.config.source)?;
|
|
1999
|
-
|
|
2000
|
-
for html_file in html_files {
|
|
2001
|
-
let content = fs::read_to_string(&html_file).map_err(|e| CompileError::ReadError {
|
|
2002
|
-
path: html_file.display().to_string(),
|
|
2003
|
-
source: e,
|
|
2004
|
-
})?;
|
|
2005
|
-
|
|
2006
|
-
// Validate HTML syntax
|
|
2007
|
-
self.validate_html(&content, &html_file)?;
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
Ok(())
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
/// Fetch external component without caching (returns raw content)
|
|
2014
|
-
fn fetch_external_component_raw(&self, url: &str) -> Result<String, String> {
|
|
2015
|
-
match ureq::get(url).call() {
|
|
2016
|
-
Ok(response) => response.into_string().map_err(|e| format!("Failed to read response body: {}", e)),
|
|
2017
|
-
Err(ureq::Error::Status(code, response)) => Err(format!("HTTP {} - {}", code, response.status_text().to_string())),
|
|
2018
|
-
Err(e) => Err(format!("Failed to fetch: {}", e)),
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
fn resolve_component_path(&self, component_src: &str) -> Result<PathBuf, String> {
|
|
2023
|
-
// Normalize path (remove leading ./ or /)
|
|
2024
|
-
let normalized = component_src.trim_start_matches("./").trim_start_matches('/');
|
|
2025
|
-
|
|
2026
|
-
// Try relative to source root
|
|
2027
|
-
let path = self.config.source.join(normalized);
|
|
2028
|
-
if path.exists() {
|
|
2029
|
-
return Ok(path);
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
Err(format!("Component file not found: {}", component_src))
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2035
|
-
fn extract_component_srcs(&self, content: &str) -> Vec<String> {
|
|
2036
|
-
let mut srcs = Vec::new();
|
|
2037
|
-
// Match both <component src="..."> and <div class="component" src="...">
|
|
2038
|
-
// After elements_as_is=false transform, <component> becomes <div class="component">
|
|
2039
|
-
let re = Regex::new(r#"(?:<component|<div\s+class="component")[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
|
|
2040
|
-
for cap in re.captures_iter(content) {
|
|
2041
|
-
if let Some(src) = cap.get(1) {
|
|
2042
|
-
srcs.push(src.as_str().to_string());
|
|
2043
|
-
}
|
|
2044
|
-
}
|
|
2045
|
-
srcs
|
|
2046
|
-
}
|
|
2047
|
-
|
|
2048
|
-
fn find_all_html_files(&self, dir: &Path) -> Result<Vec<PathBuf>, CompileError> {
|
|
2049
|
-
let mut html_files = Vec::new();
|
|
2050
|
-
// Get output directory basename to skip nested directories with same name
|
|
2051
|
-
let output_dir_name = self.config.output.file_name()
|
|
2052
|
-
.and_then(|n| n.to_str())
|
|
2053
|
-
.unwrap_or("");
|
|
2054
|
-
self.find_html_files_recursive(dir, &mut html_files, output_dir_name)?;
|
|
2055
|
-
Ok(html_files)
|
|
2056
|
-
}
|
|
2057
|
-
|
|
2058
|
-
fn find_html_files_recursive(&self, dir: &Path, files: &mut Vec<PathBuf>, output_dir_name: &str) -> Result<(), CompileError> {
|
|
2059
|
-
if !dir.is_dir() {
|
|
2060
|
-
return Ok(());
|
|
2061
|
-
}
|
|
2062
|
-
|
|
2063
|
-
let entries = match fs::read_dir(dir) {
|
|
2064
|
-
Ok(e) => e,
|
|
2065
|
-
Err(_) => return Ok(()), // Skip directories we can't read
|
|
2066
|
-
};
|
|
2067
|
-
|
|
2068
|
-
for entry in entries {
|
|
2069
|
-
let entry = match entry {
|
|
2070
|
-
Ok(e) => e,
|
|
2071
|
-
Err(_) => continue,
|
|
2072
|
-
};
|
|
2073
|
-
|
|
2074
|
-
let path = entry.path();
|
|
2075
|
-
let file_name = entry.file_name();
|
|
2076
|
-
let file_name_str = file_name.to_string_lossy();
|
|
2077
|
-
|
|
2078
|
-
// Skip specific directories/patterns (includes dotfiles via SKIP_FILES)
|
|
2079
|
-
if should_skip_path(&path, &file_name_str, &self.config.skip_files) {
|
|
2080
|
-
continue;
|
|
2081
|
-
}
|
|
2082
|
-
|
|
2083
|
-
// Skip components directory during initial scan (we'll validate them when referenced)
|
|
2084
|
-
if file_name_str == self.config.components {
|
|
2085
|
-
continue;
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
// Skip nested directories with same name as output dir (from previous bad compilations)
|
|
2089
|
-
if !output_dir_name.is_empty() && file_name_str == output_dir_name {
|
|
2090
|
-
continue;
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
if path.is_dir() {
|
|
2094
|
-
self.find_html_files_recursive(&path, files, output_dir_name)?;
|
|
2095
|
-
} else if path.extension().and_then(|e| e.to_str()) == Some("html") {
|
|
2096
|
-
files.push(path);
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
2099
|
-
|
|
2100
|
-
Ok(())
|
|
2101
|
-
}
|
|
2102
|
-
|
|
2103
|
-
fn get_output_path(&self, path: &Path, relative_path: &str) -> Result<PathBuf, CompileError> {
|
|
2104
|
-
let file_name = path.file_name().unwrap();
|
|
2105
|
-
|
|
2106
|
-
let output_path = if relative_path.is_empty() {
|
|
2107
|
-
self.config.output.join(file_name)
|
|
2108
|
-
} else {
|
|
2109
|
-
let output_subdir = self.config.output.join(relative_path);
|
|
2110
|
-
fs::create_dir_all(&output_subdir).map_err(|_| {
|
|
2111
|
-
CompileError::CreateDirError(output_subdir.display().to_string())
|
|
2112
|
-
})?;
|
|
2113
|
-
output_subdir.join(file_name)
|
|
2114
|
-
};
|
|
2115
|
-
|
|
2116
|
-
Ok(output_path)
|
|
2117
|
-
}
|
|
2118
|
-
|
|
2119
|
-
fn validate_html(&self, content: &str, path: &Path) -> Result<(), CompileError> {
|
|
2120
|
-
// Check 1: Unclosed attribute quotes
|
|
2121
|
-
self.validate_quote_balance(content, path)?;
|
|
2122
|
-
|
|
2123
|
-
// Check 2: Tag balance (unclosed elements break slot extraction)
|
|
2124
|
-
self.validate_tag_balance(content, path)
|
|
2125
|
-
}
|
|
2126
|
-
|
|
2127
|
-
/// Quotes only matter inside tags and attribute values may span lines;
|
|
2128
|
-
/// comments and raw content (script, style, pre) are skipped entirely.
|
|
2129
|
-
fn validate_quote_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
|
|
2130
|
-
const RAW_CONTENT: &[&str] = &["script", "style", "pre"];
|
|
2131
|
-
|
|
2132
|
-
let bytes = content.as_bytes();
|
|
2133
|
-
let mut i = 0;
|
|
2134
|
-
let mut line = 1;
|
|
2135
|
-
|
|
2136
|
-
while i < bytes.len() {
|
|
2137
|
-
if bytes[i] == b'\n' {
|
|
2138
|
-
line += 1;
|
|
2139
|
-
i += 1;
|
|
2140
|
-
} else if bytes[i..].starts_with(b"<!--") {
|
|
2141
|
-
match find_bytes(bytes, b"-->", i + 4) {
|
|
2142
|
-
Some(end) => {
|
|
2143
|
-
line += count_newlines(&bytes[i..end + 3]);
|
|
2144
|
-
i = end + 3;
|
|
2145
|
-
}
|
|
2146
|
-
None => break,
|
|
2147
|
-
}
|
|
2148
|
-
} else if bytes[i] == b'<'
|
|
2149
|
-
&& i + 1 < bytes.len()
|
|
2150
|
-
&& (bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'/' || bytes[i + 1] == b'!')
|
|
2151
|
-
{
|
|
2152
|
-
let is_closing = bytes[i + 1] == b'/';
|
|
2153
|
-
let name_start = i + if is_closing { 2 } else { 1 };
|
|
2154
|
-
let name_end = bytes[name_start..]
|
|
2155
|
-
.iter()
|
|
2156
|
-
.position(|b| !(b.is_ascii_alphanumeric() || *b == b'-'))
|
|
2157
|
-
.map_or(bytes.len(), |p| name_start + p);
|
|
2158
|
-
let name = content[name_start..name_end].to_ascii_lowercase();
|
|
2159
|
-
i = name_end;
|
|
2160
|
-
|
|
2161
|
-
let mut quote: Option<(u8, usize)> = None;
|
|
2162
|
-
let mut self_closing = false;
|
|
2163
|
-
while i < bytes.len() {
|
|
2164
|
-
let b = bytes[i];
|
|
2165
|
-
if b == b'\n' {
|
|
2166
|
-
line += 1;
|
|
2167
|
-
}
|
|
2168
|
-
match quote {
|
|
2169
|
-
Some((q, _)) if b == q => quote = None,
|
|
2170
|
-
Some(_) => {}
|
|
2171
|
-
None => match b {
|
|
2172
|
-
b'"' | b'\'' => quote = Some((b, line)),
|
|
2173
|
-
b'>' => {
|
|
2174
|
-
self_closing = bytes[i - 1] == b'/';
|
|
2175
|
-
break;
|
|
2176
|
-
}
|
|
2177
|
-
_ => {}
|
|
2178
|
-
},
|
|
2179
|
-
}
|
|
2180
|
-
i += 1;
|
|
2181
|
-
}
|
|
2182
|
-
|
|
2183
|
-
if let Some((_, quote_line)) = quote {
|
|
2184
|
-
return Err(CompileError::ValidationError {
|
|
2185
|
-
file: path.display().to_string(),
|
|
2186
|
-
line: quote_line,
|
|
2187
|
-
message: "Unclosed quote".to_string(),
|
|
2188
|
-
});
|
|
2189
|
-
}
|
|
2190
|
-
if i >= bytes.len() {
|
|
2191
|
-
break;
|
|
2192
|
-
}
|
|
2193
|
-
i += 1;
|
|
2194
|
-
|
|
2195
|
-
if !is_closing && !self_closing && RAW_CONTENT.contains(&name.as_str()) {
|
|
2196
|
-
let close = format!("</{}", name);
|
|
2197
|
-
match find_bytes_ci(bytes, close.as_bytes(), i) {
|
|
2198
|
-
Some(pos) => {
|
|
2199
|
-
line += count_newlines(&bytes[i..pos]);
|
|
2200
|
-
i = pos;
|
|
2201
|
-
}
|
|
2202
|
-
None => break, // unterminated raw block — tag balance reports it
|
|
2203
|
-
}
|
|
2204
|
-
}
|
|
2205
|
-
} else {
|
|
2206
|
-
i += 1;
|
|
2207
|
-
}
|
|
2208
|
-
}
|
|
2209
|
-
|
|
2210
|
-
Ok(())
|
|
2211
|
-
}
|
|
2212
|
-
|
|
2213
|
-
fn validate_tag_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
|
|
2214
|
-
const VOID_ELEMENTS: &[&str] = &[
|
|
2215
|
-
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
2216
|
-
"link", "meta", "param", "source", "track", "wbr",
|
|
2217
|
-
];
|
|
2218
|
-
// These are implicitly closed by the parser — don't require explicit close tags
|
|
2219
|
-
const IMPLICIT_CLOSE: &[&str] = &[
|
|
2220
|
-
"html", "head", "body", "p", "li", "dt", "dd", "option",
|
|
2221
|
-
"optgroup", "tr", "td", "th", "thead", "tbody", "tfoot", "colgroup",
|
|
2222
|
-
];
|
|
2223
|
-
|
|
2224
|
-
// Strip comments, raw content blocks, and @[...] bindings before tag scanning
|
|
2225
|
-
let stripped = Self::strip_for_tag_validation(content);
|
|
2226
|
-
|
|
2227
|
-
// Match any tag: opening, self-closing, or closing
|
|
2228
|
-
let tag_re = Regex::new(r"</?([a-zA-Z][a-zA-Z0-9-]*)(?:\s[^>]*)?>").unwrap();
|
|
2229
|
-
|
|
2230
|
-
// Count opens vs closes per tag name; track line of first open for error reporting
|
|
2231
|
-
let mut counts: HashMap<String, (i32, usize)> = HashMap::new();
|
|
2232
|
-
for cap in tag_re.captures_iter(&stripped) {
|
|
2233
|
-
let full = cap.get(0).unwrap().as_str();
|
|
2234
|
-
let name = cap[1].to_lowercase();
|
|
2235
|
-
if VOID_ELEMENTS.contains(&name.as_str()) || IMPLICIT_CLOSE.contains(&name.as_str()) {
|
|
2236
|
-
continue;
|
|
2237
|
-
}
|
|
2238
|
-
if full.starts_with("</") {
|
|
2239
|
-
counts.entry(name).and_modify(|(c, _)| *c -= 1);
|
|
2240
|
-
} else if full.ends_with("/>") {
|
|
2241
|
-
// self-closing — no balance change
|
|
2242
|
-
} else {
|
|
2243
|
-
let offset = cap.get(0).unwrap().start();
|
|
2244
|
-
let line = stripped[..offset].matches('\n').count() + 1;
|
|
2245
|
-
let entry = counts.entry(name).or_insert((0, line));
|
|
2246
|
-
entry.0 += 1;
|
|
2247
|
-
}
|
|
2248
|
-
}
|
|
2249
|
-
|
|
2250
|
-
// Report the first unclosed tag (sorted by line for deterministic output)
|
|
2251
|
-
let mut unclosed: Vec<(String, usize)> = counts.into_iter()
|
|
2252
|
-
.filter(|(_, (count, _))| *count > 0)
|
|
2253
|
-
.map(|(name, (_, line))| (name, line))
|
|
2254
|
-
.collect();
|
|
2255
|
-
unclosed.sort_by_key(|(_, line)| *line);
|
|
2256
|
-
|
|
2257
|
-
if let Some((name, line)) = unclosed.first() {
|
|
2258
|
-
return Err(CompileError::ValidationError {
|
|
2259
|
-
file: path.display().to_string(),
|
|
2260
|
-
line: *line,
|
|
2261
|
-
message: format!("Unclosed tag <{}>", name),
|
|
2262
|
-
});
|
|
2263
|
-
}
|
|
2264
|
-
|
|
2265
|
-
Ok(())
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
|
-
fn strip_for_tag_validation(content: &str) -> String {
|
|
2269
|
-
// Strip HTML comments <!-- ... --> (includes Vibe syntax: each, if, else, /if, /each)
|
|
2270
|
-
let comment_re = Regex::new(r"(?s)<!--.*?-->").unwrap();
|
|
2271
|
-
let s = comment_re.replace_all(content, "");
|
|
2272
|
-
// Strip @[...] reactive bindings (can contain < and > characters)
|
|
2273
|
-
let binding_re = Regex::new(r"@\[[^\]]*\]").unwrap();
|
|
2274
|
-
let s = binding_re.replace_all(&s, "");
|
|
2275
|
-
// Strip content inside <script>, <style>, <pre> to avoid parsing embedded code as HTML
|
|
2276
|
-
let raw_re = Regex::new(r"(?si)(<(?:script|style|pre)(?:\s[^>]*)?>).*?(</(?:script|style|pre)>)").unwrap();
|
|
2277
|
-
raw_re.replace_all(&s, "$1$2").into_owned()
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
|
-
/// Copy an entire directory recursively
|
|
2281
|
-
fn copy_directory(
|
|
2282
|
-
&mut self,
|
|
2283
|
-
dir: &Path,
|
|
2284
|
-
relative_path: &str,
|
|
2285
|
-
canonical_source: &Path,
|
|
2286
|
-
stats: &mut CompileStats,
|
|
2287
|
-
) -> Result<(), CompileError> {
|
|
2288
|
-
let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
|
|
2289
|
-
path: dir.display().to_string(),
|
|
2290
|
-
source: e,
|
|
2291
|
-
})?;
|
|
2292
|
-
|
|
2293
|
-
for entry in entries.flatten() {
|
|
2294
|
-
let path = entry.path();
|
|
2295
|
-
let file_name = path.file_name().unwrap().to_str().unwrap();
|
|
2296
|
-
|
|
2297
|
-
// Skip files/directories matching skip patterns
|
|
2298
|
-
if should_skip_path(&path, file_name, &self.config.skip_files) {
|
|
2299
|
-
continue;
|
|
2300
|
-
}
|
|
2301
|
-
|
|
2302
|
-
if path.is_dir() {
|
|
2303
|
-
let new_relative = format!("{}/{}", relative_path, file_name);
|
|
2304
|
-
self.copy_directory(&path, &new_relative, canonical_source, stats)?;
|
|
2305
|
-
} else {
|
|
2306
|
-
self.copy_file(&path, relative_path)?;
|
|
2307
|
-
stats.files_copied += 1;
|
|
2308
|
-
if let Some(ref mut logger) = self.logger {
|
|
2309
|
-
logger.log(&path, FileOperation::Copied, canonical_source);
|
|
2310
|
-
}
|
|
2311
|
-
}
|
|
2312
|
-
}
|
|
2313
|
-
|
|
2314
|
-
Ok(())
|
|
2315
|
-
}
|
|
2316
|
-
|
|
2317
|
-
/// Copy node_modules as-is to output
|
|
2318
|
-
fn copy_node_modules_as_is(&self) -> Result<(), CompileError> {
|
|
2319
|
-
let src = self.config.working_dir.join("node_modules");
|
|
2320
|
-
let dest = self.config.output.join("node_modules");
|
|
2321
|
-
|
|
2322
|
-
// Remove existing node_modules in output if it exists
|
|
2323
|
-
if dest.exists() {
|
|
2324
|
-
fs::remove_dir_all(&dest).map_err(|e| CompileError::WriteError {
|
|
2325
|
-
path: dest.display().to_string(),
|
|
2326
|
-
source: e,
|
|
2327
|
-
})?;
|
|
2328
|
-
}
|
|
2329
|
-
|
|
2330
|
-
// Copy recursively
|
|
2331
|
-
copy_dir_recursive(&src, &dest)?;
|
|
2332
|
-
|
|
2333
|
-
Ok(())
|
|
2334
|
-
}
|
|
2335
|
-
|
|
2336
|
-
/// Install production dependencies directly into output directory
|
|
2337
|
-
fn copy_production_node_modules(&self) -> Result<(), CompileError> {
|
|
2338
|
-
let pkg_manager = detect_package_manager(&self.config.working_dir)?;
|
|
2339
|
-
|
|
2340
|
-
if self.verbose {
|
|
2341
|
-
println!("\n Detected package manager: {}", pkg_manager);
|
|
2342
|
-
println!(" Installing production dependencies to output...");
|
|
2343
|
-
}
|
|
2344
|
-
|
|
2345
|
-
// 1. Copy package.json to output directory
|
|
2346
|
-
let package_json_src = self.config.working_dir.join("package.json");
|
|
2347
|
-
let package_json_dest = self.config.output.join("package.json");
|
|
2348
|
-
|
|
2349
|
-
fs::copy(&package_json_src, &package_json_dest).map_err(|e| CompileError::WriteError {
|
|
2350
|
-
path: package_json_dest.display().to_string(),
|
|
2351
|
-
source: e,
|
|
2352
|
-
})?;
|
|
2353
|
-
|
|
2354
|
-
// 2. Copy lockfile if it exists (needed for reproducible installs)
|
|
2355
|
-
let lockfile_name = match pkg_manager.as_str() {
|
|
2356
|
-
"bun" => "bun.lockb",
|
|
2357
|
-
"pnpm" => "pnpm-lock.yaml",
|
|
2358
|
-
"yarn" => "yarn.lock",
|
|
2359
|
-
"npm" => "package-lock.json",
|
|
2360
|
-
_ => "",
|
|
2361
|
-
};
|
|
2362
|
-
|
|
2363
|
-
if !lockfile_name.is_empty() {
|
|
2364
|
-
let lockfile_src = self.config.working_dir.join(lockfile_name);
|
|
2365
|
-
let lockfile_dest = self.config.output.join(lockfile_name);
|
|
2366
|
-
if lockfile_src.exists() {
|
|
2367
|
-
fs::copy(&lockfile_src, &lockfile_dest).ok();
|
|
2368
|
-
}
|
|
2369
|
-
}
|
|
2370
|
-
|
|
2371
|
-
// 3. Install production dependencies directly in output directory
|
|
2372
|
-
run_command(&pkg_manager, &["install", "--production"], &self.config.output)?;
|
|
2373
|
-
|
|
2374
|
-
// 4. Cleanup: remove package.json and lockfile from output
|
|
2375
|
-
fs::remove_file(&package_json_dest).ok();
|
|
2376
|
-
if !lockfile_name.is_empty() {
|
|
2377
|
-
let lockfile_dest = self.config.output.join(lockfile_name);
|
|
2378
|
-
fs::remove_file(&lockfile_dest).ok();
|
|
2379
|
-
}
|
|
2380
|
-
|
|
2381
|
-
if self.verbose {
|
|
2382
|
-
println!(" Installed production dependencies to output");
|
|
2383
|
-
}
|
|
2384
|
-
|
|
2385
|
-
Ok(())
|
|
2386
|
-
}
|
|
2387
|
-
}
|
|
2388
|
-
|
|
2389
|
-
/// Detect package manager from lockfiles
|
|
2390
|
-
fn detect_package_manager(dir: &Path) -> Result<String, CompileError> {
|
|
2391
|
-
if dir.join("bun.lockb").exists() {
|
|
2392
|
-
return Ok("bun".to_string());
|
|
2393
|
-
}
|
|
2394
|
-
if dir.join("pnpm-lock.yaml").exists() {
|
|
2395
|
-
return Ok("pnpm".to_string());
|
|
2396
|
-
}
|
|
2397
|
-
if dir.join("yarn.lock").exists() {
|
|
2398
|
-
return Ok("yarn".to_string());
|
|
2399
|
-
}
|
|
2400
|
-
if dir.join("package-lock.json").exists() {
|
|
2401
|
-
return Ok("npm".to_string());
|
|
2402
|
-
}
|
|
2403
|
-
|
|
2404
|
-
Err(CompileError::PackageManagerNotFound)
|
|
2405
|
-
}
|
|
2406
|
-
|
|
2407
|
-
/// Run a package manager command
|
|
2408
|
-
fn run_command(pkg_manager: &str, args: &[&str], cwd: &Path) -> Result<(), CompileError> {
|
|
2409
|
-
let output = Command::new(pkg_manager)
|
|
2410
|
-
.args(args)
|
|
2411
|
-
.current_dir(cwd)
|
|
2412
|
-
.output()
|
|
2413
|
-
.map_err(|e| CompileError::CommandError(format!("{} not found: {}", pkg_manager, e)))?;
|
|
2414
|
-
|
|
2415
|
-
if !output.status.success() {
|
|
2416
|
-
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
2417
|
-
return Err(CompileError::CommandError(format!(
|
|
2418
|
-
"{} {} failed: {}",
|
|
2419
|
-
pkg_manager,
|
|
2420
|
-
args.join(" "),
|
|
2421
|
-
stderr
|
|
2422
|
-
)));
|
|
2423
|
-
}
|
|
2424
|
-
|
|
2425
|
-
Ok(())
|
|
2426
|
-
}
|
|
2427
|
-
|
|
2428
|
-
/// Recursively copy a directory
|
|
2429
|
-
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
|
|
2430
|
-
if !dest.exists() {
|
|
2431
|
-
fs::create_dir_all(dest).map_err(|_| CompileError::CreateDirError(dest.display().to_string()))?;
|
|
2432
|
-
}
|
|
2433
|
-
|
|
2434
|
-
for entry in fs::read_dir(src).map_err(|e| CompileError::ReadError {
|
|
2435
|
-
path: src.display().to_string(),
|
|
2436
|
-
source: e,
|
|
2437
|
-
})? {
|
|
2438
|
-
let entry = entry.map_err(|e| CompileError::ReadError {
|
|
2439
|
-
path: src.display().to_string(),
|
|
2440
|
-
source: e,
|
|
2441
|
-
})?;
|
|
2442
|
-
let path = entry.path();
|
|
2443
|
-
let file_name = entry.file_name();
|
|
2444
|
-
let dest_path = dest.join(&file_name);
|
|
2445
|
-
|
|
2446
|
-
if path.is_dir() {
|
|
2447
|
-
// Cache Directory Tagging spec: a directory carrying a signed
|
|
2448
|
-
// CACHEDIR.TAG (cargo target/, many build caches) declares itself
|
|
2449
|
-
// regenerable and skippable for copy tools. A symlinked local
|
|
2450
|
-
// package would otherwise drag gigabytes of build artifacts into
|
|
2451
|
-
// the as-is node_modules copy.
|
|
2452
|
-
if is_cachedir_tagged(&path) {
|
|
2453
|
-
continue;
|
|
2454
|
-
}
|
|
2455
|
-
copy_dir_recursive(&path, &dest_path)?;
|
|
2456
|
-
} else {
|
|
2457
|
-
fs::copy(&path, &dest_path).map_err(|e| CompileError::WriteError {
|
|
2458
|
-
path: dest_path.display().to_string(),
|
|
2459
|
-
source: e,
|
|
2460
|
-
})?;
|
|
2461
|
-
}
|
|
2462
|
-
}
|
|
2463
|
-
|
|
2464
|
-
Ok(())
|
|
2465
|
-
}
|
|
2466
|
-
|
|
2467
|
-
fn is_cachedir_tagged(dir: &Path) -> bool {
|
|
2468
|
-
fs::read(dir.join("CACHEDIR.TAG"))
|
|
2469
|
-
.map(|bytes| bytes.starts_with(b"Signature: 8a477f597d28d172789f06886806bc55"))
|
|
2470
|
-
.unwrap_or(false)
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
/// Basic HTML minification
|
|
2474
|
-
fn minify_html(html: &str) -> String {
|
|
2475
|
-
let mut result = String::with_capacity(html.len());
|
|
2476
|
-
let mut in_raw = false;
|
|
2477
|
-
let mut last_was_space = false;
|
|
2478
|
-
|
|
2479
|
-
for line in html.lines() {
|
|
2480
|
-
let trimmed = line.trim();
|
|
2481
|
-
|
|
2482
|
-
// <pre>, <script> and <style> carry significant whitespace and must
|
|
2483
|
-
// survive minification verbatim: <pre> is literal text, while a JS
|
|
2484
|
-
// `//` line comment or ASI in <script> breaks the moment its trailing
|
|
2485
|
-
// newline is collapsed into a space (the comment swallows the rest of
|
|
2486
|
-
// the script). Keep these blocks line-for-line, exactly as <pre> always
|
|
2487
|
-
// did. The opening-tag line enters the block before we emit it; the
|
|
2488
|
-
// closing-tag line leaves it (and collapses, which only tightens the
|
|
2489
|
-
// bare `</pre>` / `</script>` / `</style>`).
|
|
2490
|
-
if trimmed.contains("<pre") || trimmed.contains("<script") || trimmed.contains("<style") {
|
|
2491
|
-
in_raw = true;
|
|
2492
|
-
}
|
|
2493
|
-
if trimmed.contains("</pre>") || trimmed.contains("</script>") || trimmed.contains("</style>") {
|
|
2494
|
-
in_raw = false;
|
|
2495
|
-
}
|
|
2496
|
-
|
|
2497
|
-
if in_raw {
|
|
2498
|
-
result.push_str(line);
|
|
2499
|
-
result.push('\n');
|
|
2500
|
-
last_was_space = false;
|
|
2501
|
-
} else {
|
|
2502
|
-
// The line break preceding this line is whitespace: collapse it to a
|
|
2503
|
-
// single space so attributes/text split across lines don't glue
|
|
2504
|
-
// together (e.g. a multi-line <meta name=... content=...> tag).
|
|
2505
|
-
// The >\s+< pass below re-tightens genuine tag boundaries.
|
|
2506
|
-
if !last_was_space && !result.is_empty() {
|
|
2507
|
-
result.push(' ');
|
|
2508
|
-
last_was_space = true;
|
|
2509
|
-
}
|
|
2510
|
-
for c in trimmed.chars() {
|
|
2511
|
-
if c.is_whitespace() {
|
|
2512
|
-
if !last_was_space {
|
|
2513
|
-
result.push(' ');
|
|
2514
|
-
last_was_space = true;
|
|
2515
|
-
}
|
|
2516
|
-
} else {
|
|
2517
|
-
result.push(c);
|
|
2518
|
-
last_was_space = false;
|
|
2519
|
-
}
|
|
2520
|
-
}
|
|
2521
|
-
}
|
|
2522
|
-
}
|
|
2523
|
-
|
|
2524
|
-
// Collapse whitespace between tags to a SINGLE space — never delete it.
|
|
2525
|
-
// Inter-element whitespace is significant for inline content: the browser
|
|
2526
|
-
// (and the non-compiled runtime) render `<em>a</em> <em>b</em>` with a space,
|
|
2527
|
-
// so dropping it (`><`) glued words onto preceding inline elements (the
|
|
2528
|
-
// status-chip "💠Concussion" parity bug). One space matches both the browser's
|
|
2529
|
-
// own collapsing and the non-minified/runtime output; where the space is
|
|
2530
|
-
// insignificant (between block/table/list/head elements) the parser discards
|
|
2531
|
-
// it anyway, exactly as it does for the non-minified newline.
|
|
2532
|
-
let result = regex::Regex::new(r">\s+<")
|
|
2533
|
-
.unwrap()
|
|
2534
|
-
.replace_all(&result, "> <")
|
|
2535
|
-
.to_string();
|
|
2536
|
-
|
|
2537
|
-
result.trim().to_string()
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
fn find_bytes(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
|
2541
|
-
haystack[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
|
|
2542
|
-
}
|
|
2543
|
-
|
|
2544
|
-
fn find_bytes_ci(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
|
2545
|
-
haystack[from..].windows(needle.len()).position(|w| w.eq_ignore_ascii_case(needle)).map(|p| p + from)
|
|
2546
|
-
}
|
|
2547
|
-
|
|
2548
|
-
fn count_newlines(bytes: &[u8]) -> usize {
|
|
2549
|
-
bytes.iter().filter(|&&b| b == b'\n').count()
|
|
2550
|
-
}
|
|
2551
|
-
|
|
2552
|
-
#[cfg(test)]
|
|
2553
|
-
mod tests {
|
|
2554
|
-
use super::*;
|
|
2555
|
-
|
|
2556
|
-
#[test]
|
|
2557
|
-
fn minify_preserves_script_newlines_so_line_comments_dont_swallow_code() {
|
|
2558
|
-
// A `//` line comment inside a component script relies on its trailing
|
|
2559
|
-
// newline. If minify collapses newlines into spaces, the comment eats
|
|
2560
|
-
// the rest of the script → SyntaxError at runtime (new Function).
|
|
2561
|
-
let html = "<page>\n\
|
|
2562
|
-
<script type=\"module\">\n\
|
|
2563
|
-
\x20 const a = 1; // explain a\n\
|
|
2564
|
-
\x20 // keep explaining\n\
|
|
2565
|
-
\x20 const b = 2;\n\
|
|
2566
|
-
</script>\n\
|
|
2567
|
-
</page>";
|
|
2568
|
-
|
|
2569
|
-
let out = minify_html(html);
|
|
2570
|
-
|
|
2571
|
-
// The code after the comments must still be reachable, i.e. on its own
|
|
2572
|
-
// line rather than glued behind the `//`.
|
|
2573
|
-
let script = &out[out.find("<script").unwrap()..out.find("</script>").unwrap()];
|
|
2574
|
-
assert!(
|
|
2575
|
-
script.contains('\n'),
|
|
2576
|
-
"script newlines were collapsed, // comment swallows following code: {script:?}"
|
|
2577
|
-
);
|
|
2578
|
-
assert!(out.contains("const b = 2"), "code after // comment lost: {out:?}");
|
|
2579
|
-
|
|
2580
|
-
// Surrounding HTML must still be minified (tag boundaries tightened).
|
|
2581
|
-
assert!(out.contains("<page><script"), "non-script HTML not minified: {out:?}");
|
|
2582
|
-
}
|
|
2583
|
-
|
|
2584
|
-
#[test]
|
|
2585
|
-
fn minify_preserves_style_newlines() {
|
|
2586
|
-
let html = "<page>\n\
|
|
2587
|
-
<style>\n\
|
|
2588
|
-
\x20 a { color: red; }\n\
|
|
2589
|
-
\x20 b { color: blue; }\n\
|
|
2590
|
-
</style>\n\
|
|
2591
|
-
</page>";
|
|
2592
|
-
|
|
2593
|
-
let out = minify_html(html);
|
|
2594
|
-
let style = &out[out.find("<style").unwrap()..out.find("</style>").unwrap()];
|
|
2595
|
-
assert!(style.contains('\n'), "style newlines collapsed: {style:?}");
|
|
2596
|
-
}
|
|
2597
|
-
|
|
2598
|
-
#[test]
|
|
2599
|
-
fn atomic_write_replaces_content_without_leaving_tmp_files() {
|
|
2600
|
-
let dir = std::env::temp_dir().join("vibe_atomic_write_test");
|
|
2601
|
-
let _ = fs::remove_dir_all(&dir);
|
|
2602
|
-
fs::create_dir_all(&dir).unwrap();
|
|
2603
|
-
let target = dir.join("page.html");
|
|
2604
|
-
|
|
2605
|
-
atomic_write(&target, "first").unwrap();
|
|
2606
|
-
assert_eq!(fs::read_to_string(&target).unwrap(), "first");
|
|
2607
|
-
|
|
2608
|
-
// Overwriting an existing file goes through the same tmp+rename path.
|
|
2609
|
-
atomic_write(&target, "second, longer content").unwrap();
|
|
2610
|
-
assert_eq!(fs::read_to_string(&target).unwrap(), "second, longer content");
|
|
2611
|
-
|
|
2612
|
-
let leftovers: Vec<String> = fs::read_dir(&dir)
|
|
2613
|
-
.unwrap()
|
|
2614
|
-
.filter_map(|e| e.ok())
|
|
2615
|
-
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
2616
|
-
.filter(|n| n != "page.html")
|
|
2617
|
-
.collect();
|
|
2618
|
-
assert!(leftovers.is_empty(), "temp artifacts left behind: {leftovers:?}");
|
|
2619
|
-
}
|
|
2620
|
-
|
|
2621
|
-
// A minimal on-disk project: source with one page carrying a distinctive
|
|
2622
|
-
// binding, empty components dir, output dir sibling. Returns (config, page
|
|
2623
|
-
// source path, compiled page output path, manifest path).
|
|
2624
|
-
// nodeModulesAsIs copies must skip cache-tagged directories (the Cache
|
|
2625
|
-
// Directory Tagging spec: cargo target/, many build caches). A symlinked
|
|
2626
|
-
// local package dragging its cargo target/ along turned a 15MB copy into
|
|
2627
|
-
// gigabytes.
|
|
2628
|
-
#[test]
|
|
2629
|
-
fn as_is_copy_skips_cachedir_tagged_directories() {
|
|
2630
|
-
let dir = std::env::temp_dir().join("vibe-cachedir-copy-test");
|
|
2631
|
-
let _ = fs::remove_dir_all(&dir);
|
|
2632
|
-
let src = dir.join("node_modules/pkg");
|
|
2633
|
-
fs::create_dir_all(src.join("runtime")).unwrap();
|
|
2634
|
-
fs::create_dir_all(src.join("build/target/release")).unwrap();
|
|
2635
|
-
fs::write(src.join("index.js"), "export default 1;").unwrap();
|
|
2636
|
-
fs::write(src.join("runtime/state.js"), "export const s = 1;").unwrap();
|
|
2637
|
-
fs::write(
|
|
2638
|
-
src.join("build/target/CACHEDIR.TAG"),
|
|
2639
|
-
"Signature: 8a477f597d28d172789f06886806bc55\n",
|
|
2640
|
-
)
|
|
2641
|
-
.unwrap();
|
|
2642
|
-
fs::write(src.join("build/target/release/artifact"), "big").unwrap();
|
|
2643
|
-
|
|
2644
|
-
let dest = dir.join("out/node_modules");
|
|
2645
|
-
copy_dir_recursive(&dir.join("node_modules"), &dest).unwrap();
|
|
2646
|
-
|
|
2647
|
-
assert!(dest.join("pkg/index.js").exists());
|
|
2648
|
-
assert!(dest.join("pkg/runtime/state.js").exists());
|
|
2649
|
-
assert!(dest.join("pkg/build").exists());
|
|
2650
|
-
assert!(!dest.join("pkg/build/target").exists(), "cache-tagged dir was copied");
|
|
2651
|
-
|
|
2652
|
-
let _ = fs::remove_dir_all(&dir);
|
|
2653
|
-
}
|
|
2654
|
-
|
|
2655
|
-
// Watch mode re-runs compile_spa on any pages-tree change: an edited page
|
|
2656
|
-
// re-transforms and the shell recomposes (title/head/route table), a
|
|
2657
|
-
// removed page's fragment is pruned from the output mirror and its route
|
|
2658
|
-
// leaves the table. Full-rerun semantics keep add/edit/remove one path.
|
|
2659
|
-
#[test]
|
|
2660
|
-
fn spa_recompile_syncs_fragments_shell_and_routes() {
|
|
2661
|
-
let dir = std::env::temp_dir().join("vibe-spa-watch-test");
|
|
2662
|
-
let _ = fs::remove_dir_all(&dir);
|
|
2663
|
-
let source = dir.join("src");
|
|
2664
|
-
fs::create_dir_all(source.join("pages")).unwrap();
|
|
2665
|
-
fs::create_dir_all(source.join("components")).unwrap();
|
|
2666
|
-
let page = |title: &str, body: &str| {
|
|
2667
|
-
format!(
|
|
2668
|
-
"<!DOCTYPE html>\n<html><head><title>{}</title></head>\n<body vibe-fouc>{}</body></html>\n",
|
|
2669
|
-
title, body
|
|
2670
|
-
)
|
|
2671
|
-
};
|
|
2672
|
-
fs::write(source.join("pages/index.html"), page("Home", "<h1>home</h1>")).unwrap();
|
|
2673
|
-
fs::write(source.join("pages/about.html"), page("About", "<h1>about</h1>")).unwrap();
|
|
2674
|
-
|
|
2675
|
-
let config = Config {
|
|
2676
|
-
source: source.clone(),
|
|
2677
|
-
output: dir.join("out"),
|
|
2678
|
-
_source_str: String::new(),
|
|
2679
|
-
_output_str: String::new(),
|
|
2680
|
-
components: "components".to_string(),
|
|
2681
|
-
pages: "pages".to_string(),
|
|
2682
|
-
_assets: String::new(),
|
|
2683
|
-
root: None,
|
|
2684
|
-
minify: false,
|
|
2685
|
-
elements_as_is: true,
|
|
2686
|
-
source_maps: false,
|
|
2687
|
-
reserved_elements: Vec::new(),
|
|
2688
|
-
skip_files: Vec::new(),
|
|
2689
|
-
node_modules_as_is: false,
|
|
2690
|
-
components_as_is: true,
|
|
2691
|
-
runtime_as_is: false,
|
|
2692
|
-
iterations_as_is: false,
|
|
2693
|
-
no_clean: false,
|
|
2694
|
-
fouc_as_is: false,
|
|
2695
|
-
spa: true,
|
|
2696
|
-
working_dir: dir.clone(),
|
|
2697
|
-
};
|
|
2698
|
-
|
|
2699
|
-
let mut compiler = Compiler::new(config.clone(), false);
|
|
2700
|
-
let mut parser = HtmlParser::new(config.components_path());
|
|
2701
|
-
parser.load_elements().unwrap();
|
|
2702
|
-
compiler.compile().unwrap();
|
|
2703
|
-
|
|
2704
|
-
let shell_path = config.output.join("index.html");
|
|
2705
|
-
let fragment = config.output.join("components/vibe-spa/about.html");
|
|
2706
|
-
let shell = fs::read_to_string(&shell_path).unwrap();
|
|
2707
|
-
assert!(shell.contains("\"route\": \"/about\""));
|
|
2708
|
-
assert!(shell.contains("<title>Home</title>"));
|
|
2709
|
-
assert!(fragment.exists());
|
|
2710
|
-
|
|
2711
|
-
// Edited page: fragment re-transforms, shell recomposes with the new
|
|
2712
|
-
// harvested title.
|
|
2713
|
-
fs::write(source.join("pages/about.html"), page("Regenerated", "<h1>about v2</h1>")).unwrap();
|
|
2714
|
-
compiler.compile_spa(&parser).unwrap();
|
|
2715
|
-
let shell = fs::read_to_string(&shell_path).unwrap();
|
|
2716
|
-
assert!(shell.contains("\"title\": \"Regenerated\""));
|
|
2717
|
-
assert!(fs::read_to_string(&fragment).unwrap().contains("about v2"));
|
|
2718
|
-
|
|
2719
|
-
// Removed page: route leaves the table, orphan fragment is pruned.
|
|
2720
|
-
fs::remove_file(source.join("pages/about.html")).unwrap();
|
|
2721
|
-
compiler.compile_spa(&parser).unwrap();
|
|
2722
|
-
let shell = fs::read_to_string(&shell_path).unwrap();
|
|
2723
|
-
assert!(!shell.contains("\"route\": \"/about\""));
|
|
2724
|
-
assert!(!fragment.exists());
|
|
2725
|
-
|
|
2726
|
-
let _ = fs::remove_dir_all(&dir);
|
|
2727
|
-
}
|
|
2728
|
-
|
|
2729
|
-
fn manifest_test_project(name: &str) -> (Config, PathBuf, PathBuf, PathBuf) {
|
|
2730
|
-
let dir = std::env::temp_dir().join(format!("vibe_{}_test", name));
|
|
2731
|
-
let _ = fs::remove_dir_all(&dir);
|
|
2732
|
-
let source = dir.join("src");
|
|
2733
|
-
let output = dir.join("out");
|
|
2734
|
-
fs::create_dir_all(source.join("components")).unwrap();
|
|
2735
|
-
fs::write(
|
|
2736
|
-
source.join("index.html"),
|
|
2737
|
-
"<!doctype html>\n<html><head><title>t</title></head>\n\
|
|
2738
|
-
<body vibe>\n<page-home><h1>@[uniqueMarker123]</h1></page-home>\n</body></html>\n",
|
|
2739
|
-
)
|
|
2740
|
-
.unwrap();
|
|
2741
|
-
|
|
2742
|
-
let config = Config {
|
|
2743
|
-
source: source.clone(),
|
|
2744
|
-
output: output.clone(),
|
|
2745
|
-
_source_str: String::new(),
|
|
2746
|
-
_output_str: String::new(),
|
|
2747
|
-
components: "components".to_string(),
|
|
2748
|
-
pages: "pages".to_string(),
|
|
2749
|
-
_assets: String::new(),
|
|
2750
|
-
root: None,
|
|
2751
|
-
minify: false,
|
|
2752
|
-
elements_as_is: false,
|
|
2753
|
-
source_maps: false,
|
|
2754
|
-
reserved_elements: Vec::new(),
|
|
2755
|
-
skip_files: Vec::new(),
|
|
2756
|
-
node_modules_as_is: false,
|
|
2757
|
-
components_as_is: false,
|
|
2758
|
-
runtime_as_is: false,
|
|
2759
|
-
iterations_as_is: false,
|
|
2760
|
-
no_clean: false,
|
|
2761
|
-
fouc_as_is: false,
|
|
2762
|
-
spa: false,
|
|
2763
|
-
working_dir: dir.clone(),
|
|
2764
|
-
};
|
|
2765
|
-
|
|
2766
|
-
let page_src = source.join("index.html");
|
|
2767
|
-
let page_out = output.join("index.html");
|
|
2768
|
-
let manifest = output.join("vibe-hyperspeed").join("index.html.manifest.js");
|
|
2769
|
-
(config, page_src, page_out, manifest)
|
|
2770
|
-
}
|
|
2771
|
-
|
|
2772
|
-
// The full build always mirrors components/ verbatim into the output —
|
|
2773
|
-
// runtime-fetched components (`<component src="@[page.src]">`, iter-prop
|
|
2774
|
-
// roots, components_as_is) are served from that mirror. Watch mode must
|
|
2775
|
-
// keep the same contract: an edited component reaches the mirror even when
|
|
2776
|
-
// NO page inlines it (zero dependents), and a deleted component leaves it.
|
|
2777
|
-
#[test]
|
|
2778
|
-
fn changed_component_is_remirrored_to_output() {
|
|
2779
|
-
let (config, _page_src, _page_out, _manifest) = manifest_test_project("component_mirror");
|
|
2780
|
-
let source_component = config.source.join("components").join("widget.html");
|
|
2781
|
-
let mirrored = config.output.join("components").join("widget.html");
|
|
2782
|
-
let nested_src = config.source.join("components").join("spa").join("pane.html");
|
|
2783
|
-
let nested_out = config.output.join("components").join("spa").join("pane.html");
|
|
2784
|
-
fs::write(&source_component, "<spa-widget>v1</spa-widget>\n").unwrap();
|
|
2785
|
-
|
|
2786
|
-
let mut compiler = Compiler::new(config, false);
|
|
2787
|
-
compiler.compile().expect("compile should succeed");
|
|
2788
|
-
assert_eq!(
|
|
2789
|
-
fs::read_to_string(&mirrored).unwrap(),
|
|
2790
|
-
"<spa-widget>v1</spa-widget>\n",
|
|
2791
|
-
"sanity: full build mirrors the component"
|
|
2792
|
-
);
|
|
2793
|
-
|
|
2794
|
-
// Edit the component — no page references it, so the dependency graph
|
|
2795
|
-
// maps it to zero pages; the mirror must still track the change.
|
|
2796
|
-
fs::write(&source_component, "<spa-widget>v2</spa-widget>\n").unwrap();
|
|
2797
|
-
let copied = compiler
|
|
2798
|
-
.mirror_component_files(&[source_component.clone()])
|
|
2799
|
-
.expect("mirroring should succeed");
|
|
2800
|
-
assert_eq!(copied, 1);
|
|
2801
|
-
assert_eq!(
|
|
2802
|
-
fs::read_to_string(&mirrored).unwrap(),
|
|
2803
|
-
"<spa-widget>v2</spa-widget>\n",
|
|
2804
|
-
"edited component did not reach the output mirror"
|
|
2805
|
-
);
|
|
2806
|
-
|
|
2807
|
-
// A component created mid-session (parent dirs may not exist yet).
|
|
2808
|
-
fs::create_dir_all(nested_src.parent().unwrap()).unwrap();
|
|
2809
|
-
fs::write(&nested_src, "<spa-pane>new</spa-pane>\n").unwrap();
|
|
2810
|
-
compiler
|
|
2811
|
-
.mirror_component_files(&[nested_src.clone()])
|
|
2812
|
-
.expect("mirroring a new nested component should succeed");
|
|
2813
|
-
assert_eq!(fs::read_to_string(&nested_out).unwrap(), "<spa-pane>new</spa-pane>\n");
|
|
2814
|
-
|
|
2815
|
-
// Deleting the source removes the mirrored copy.
|
|
2816
|
-
fs::remove_file(&source_component).unwrap();
|
|
2817
|
-
compiler
|
|
2818
|
-
.mirror_component_files(&[source_component])
|
|
2819
|
-
.expect("mirroring a deletion should succeed");
|
|
2820
|
-
assert!(!mirrored.exists(), "deleted component still present in the output mirror");
|
|
2821
|
-
}
|
|
2822
|
-
|
|
2823
|
-
// The watcher race: another writer truncates/rewrites a compiled page on
|
|
2824
|
-
// disk between our compile and our manifest pass. The manifest must be
|
|
2825
|
-
// built from the HTML this compiler just produced in memory — never from a
|
|
2826
|
-
// disk read-back — or a torn read yields a valid-but-empty manifest and the
|
|
2827
|
-
// page hydrates to a blank screen.
|
|
2828
|
-
#[test]
|
|
2829
|
-
fn manifest_survives_output_corruption_between_compile_and_manifests() {
|
|
2830
|
-
let (config, _page_src, page_out, manifest) =
|
|
2831
|
-
manifest_test_project("manifest_memory_full");
|
|
2832
|
-
|
|
2833
|
-
let mut compiler = Compiler::new(config, false);
|
|
2834
|
-
compiler.compile().expect("compile should succeed");
|
|
2835
|
-
assert!(
|
|
2836
|
-
fs::read_to_string(&page_out).unwrap().contains("uniqueMarker123"),
|
|
2837
|
-
"sanity: compiled page carries the binding"
|
|
2838
|
-
);
|
|
2839
|
-
|
|
2840
|
-
// Simulate the concurrent writer: the on-disk page is now a shell.
|
|
2841
|
-
fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
|
|
2842
|
-
|
|
2843
|
-
compiler.generate_manifests().expect("manifest generation should succeed");
|
|
2844
|
-
|
|
2845
|
-
let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
|
|
2846
|
-
assert!(
|
|
2847
|
-
manifest_js.contains("uniqueMarker123"),
|
|
2848
|
-
"manifest was built from the corrupted disk file instead of the in-memory compile output"
|
|
2849
|
-
);
|
|
2850
|
-
}
|
|
2851
|
-
|
|
2852
|
-
// Same property on the incremental watch path (generate_manifests_for_files),
|
|
2853
|
-
// which is where the two-watcher race actually corrupted manifests.
|
|
2854
|
-
#[test]
|
|
2855
|
-
fn incremental_manifest_survives_output_corruption() {
|
|
2856
|
-
let (config, page_src, page_out, manifest) =
|
|
2857
|
-
manifest_test_project("manifest_memory_incremental");
|
|
2858
|
-
|
|
2859
|
-
let mut compiler = Compiler::new(config.clone(), false);
|
|
2860
|
-
let mut parser = HtmlParser::new(config.components_path());
|
|
2861
|
-
parser.load_elements().unwrap();
|
|
2862
|
-
|
|
2863
|
-
fs::create_dir_all(&config.output).unwrap();
|
|
2864
|
-
compiler
|
|
2865
|
-
.compile_specific_html_files(&[page_src.clone()], &parser)
|
|
2866
|
-
.expect("incremental compile should succeed");
|
|
2867
|
-
|
|
2868
|
-
fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
|
|
2869
|
-
|
|
2870
|
-
compiler
|
|
2871
|
-
.generate_manifests_for_files(&[page_src])
|
|
2872
|
-
.expect("incremental manifest generation should succeed");
|
|
2873
|
-
|
|
2874
|
-
let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
|
|
2875
|
-
assert!(
|
|
2876
|
-
manifest_js.contains("uniqueMarker123"),
|
|
2877
|
-
"incremental manifest was built from the corrupted disk file instead of the in-memory compile output"
|
|
2878
|
-
);
|
|
2879
|
-
}
|
|
2880
|
-
}
|