Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions cells/cell-html/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1481,3 +1481,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
HtmlProcessorDispatcher::new(processor)
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn injects_vite_css_for_built_script_paths() {
let html = r#"
<html>
<head></head>
<body><script src="/assets/main-AbCdEf.js"></script></body>
</html>
"#;
let mut map = HashMap::new();
map.insert(
"/assets/main-AbCdEf.js".to_string(),
vec!["/assets/main-XyZ.css".to_string()],
);

let tendril = StrTendril::from(html);
let mut doc = hotmeal::parse(&tendril);
inject_vite_css_in_doc(&mut doc, &map);
let output = doc.to_html();

assert!(output.contains(r#"href="/assets/main-XyZ.css""#));
}
}
3 changes: 3 additions & 0 deletions cells/cell-sass-proto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ pub enum SassResult {
#[derive(Debug, Clone, Facet)]
pub struct SassInput {
pub files: HashMap<String, String>,
/// Additional filesystem load paths (for `@use` / `@import` resolution).
#[facet(default)]
pub load_paths: Vec<String>,
}

/// SASS compilation service implemented by the cell.
Expand Down
36 changes: 19 additions & 17 deletions cells/cell-sass/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,20 @@ impl SassCompiler for SassCompilerImpl {
let files = input.files;

// Find main.scss
let main_content = match files.get("main.scss") {
Some(content) => content,
None => {
return SassResult::Error {
message: "main.scss not found in files".to_string(),
};
}
};
if !files.contains_key("main.scss") {
return SassResult::Error {
message: "main.scss not found in files".to_string(),
};
}

// Create an in-memory filesystem for grass
let fs = InMemorySassFs::new(&files);

// Compile with grass using in-memory fs
let options = grass::Options::default().fs(&fs);
// Compile with grass using in-memory fs plus optional disk-backed load paths
let load_paths: Vec<PathBuf> = input.load_paths.iter().map(PathBuf::from).collect();
let options = grass::Options::default().fs(&fs).load_paths(&load_paths);

match grass::from_string(main_content.clone(), &options) {
match grass::from_path("main.scss", &options) {
Ok(css) => SassResult::Success { css },
Err(e) => SassResult::Error {
message: format!("SASS compilation failed: {}", e),
Expand Down Expand Up @@ -66,19 +64,23 @@ impl grass::Fs for InMemorySassFs {
fn is_dir(&self, path: &Path) -> bool {
// Check if any file is under this directory
self.files.keys().any(|f| f.starts_with(path))
|| std::fs::metadata(path).map(|m| m.is_dir()).unwrap_or(false)
}

fn is_file(&self, path: &Path) -> bool {
self.files.contains_key(path)
|| std::fs::metadata(path)
.map(|m| m.is_file())
.unwrap_or(false)
}

fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
self.files.get(path).cloned().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("File not found: {path:?}"),
)
})
if let Some(content) = self.files.get(path) {
return Ok(content.clone());
}

std::fs::read(path)
.map_err(|e| std::io::Error::new(e.kind(), format!("File not found: {path:?}: {e}")))
}
}

Expand Down
32 changes: 7 additions & 25 deletions crates/dodeca/src/build_steps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ pub enum BuildStepResult {
Error(String),
}


/// Executor for build steps with caching.
pub struct BuildStepExecutor {
/// Build step definitions from config
Expand All @@ -46,10 +45,7 @@ pub struct BuildStepExecutor {

impl BuildStepExecutor {
/// Create a new executor with build step definitions.
pub fn new(
steps: Option<HashMap<String, BuildStepDef>>,
project_root: Utf8PathBuf,
) -> Self {
pub fn new(steps: Option<HashMap<String, BuildStepDef>>, project_root: Utf8PathBuf) -> Self {
let steps = steps.unwrap_or_default();
tracing::debug!(num_steps = steps.len(), steps = ?steps.keys().collect::<Vec<_>>(), "BuildStepExecutor initialized");
Self {
Expand Down Expand Up @@ -123,10 +119,8 @@ impl BuildStepExecutor {
step_def: &BuildStepDef,
params: &HashMap<String, String>,
) -> Result<CacheKey, String> {
let mut sorted_params: Vec<(String, String)> = params
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let mut sorted_params: Vec<(String, String)> =
params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
sorted_params.sort_by(|a, b| a.0.cmp(&b.0));

// Hash file-typed parameters
Expand Down Expand Up @@ -179,10 +173,7 @@ impl BuildStepExecutor {
params: &HashMap<String, String>,
) -> BuildStepResult {
if cmd_args.is_empty() {
return BuildStepResult::Error(format!(
"Build step '{}' has empty command",
step_name
));
return BuildStepResult::Error(format!("Build step '{}' has empty command", step_name));
}

// Interpolate parameters into command arguments
Expand Down Expand Up @@ -212,10 +203,7 @@ impl BuildStepExecutor {
{
Ok(output) => output,
Err(e) => {
return BuildStepResult::Error(format!(
"Failed to execute '{}': {}",
program, e
));
return BuildStepResult::Error(format!("Failed to execute '{}': {}", program, e));
}
};

Expand Down Expand Up @@ -263,10 +251,7 @@ impl BuildStepExecutor {
let full_path = self.project_root.join(file_path);
match tokio::fs::read(&full_path).await {
Ok(contents) => BuildStepResult::Success(contents),
Err(e) => BuildStepResult::Error(format!(
"Failed to read file '{}': {}",
full_path, e
)),
Err(e) => BuildStepResult::Error(format!("Failed to read file '{}': {}", full_path, e)),
}
}
}
Expand Down Expand Up @@ -307,10 +292,7 @@ mod tests {
params.insert("file".to_string(), "test.txt".to_string());
params.insert("width".to_string(), "100".to_string());

assert_eq!(
interpolate_params("{file}", &params),
"test.txt"
);
assert_eq!(interpolate_params("{file}", &params), "test.txt");
assert_eq!(
interpolate_params("convert {file} -resize {width}x", &params),
"convert test.txt -resize 100x"
Expand Down
6 changes: 5 additions & 1 deletion crates/dodeca/src/cells.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1347,12 +1347,16 @@ pub async fn encode_jxl_cell(
}

// SASS/CSS cell wrappers
pub async fn compile_sass_cell(input: &HashMap<String, String>) -> Result<SassResult, eyre::Error> {
pub async fn compile_sass_cell(
input: &HashMap<String, String>,
load_paths: &[String],
) -> Result<SassResult, eyre::Error> {
let client = sass_cell()
.await
.ok_or_else(|| eyre::eyre!("SASS cell not available"))?;
let sass_input = SassInput {
files: input.clone(),
load_paths: load_paths.to_vec(),
};
client
.compile_sass(sass_input)
Expand Down
15 changes: 10 additions & 5 deletions crates/dodeca/src/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,28 +400,33 @@ impl tracing::field::Visit for MessageVisitor {
if field.name() == "message" {
self.message = Some(value.to_string());
} else {
self.fields.push((field.name().to_string(), value.to_string()));
self.fields
.push((field.name().to_string(), value.to_string()));
}
}

fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = Some(format!("{value:?}"));
} else {
self.fields.push((field.name().to_string(), format!("{value:?}")));
self.fields
.push((field.name().to_string(), format!("{value:?}")));
}
}

fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
self.fields.push((field.name().to_string(), value.to_string()));
self.fields
.push((field.name().to_string(), value.to_string()));
}

fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.fields.push((field.name().to_string(), value.to_string()));
self.fields
.push((field.name().to_string(), value.to_string()));
}

fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.fields.push((field.name().to_string(), value.to_string()));
self.fields
.push((field.name().to_string(), value.to_string()));
}
}

Expand Down
12 changes: 10 additions & 2 deletions crates/dodeca/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -858,7 +858,11 @@ impl BuildContext {
let static_files: Vec<Utf8PathBuf> = WalkBuilder::new(&static_dir)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
.filter(|e| {
e.file_type()
.map(|ft| ft.is_file() || ft.is_symlink())
.unwrap_or(false)
})
.filter_map(|e| Utf8PathBuf::from_path_buf(e.into_path()).ok())
.collect();

Expand All @@ -880,7 +884,11 @@ impl BuildContext {
let dist_files: Vec<Utf8PathBuf> = WalkBuilder::new(&dist_dir)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
.filter(|e| {
e.file_type()
.map(|ft| ft.is_file() || ft.is_symlink())
.unwrap_or(false)
})
.filter_map(|e| Utf8PathBuf::from_path_buf(e.into_path()).ok())
.collect();

Expand Down
30 changes: 28 additions & 2 deletions crates/dodeca/src/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,22 @@ pub async fn compile_sass<DB: Db>(db: &DB) -> PicanteResult<Option<CompiledCss>>

tracing::info!(num_files = sass_map.len(), "Compiling SASS");

// Allow `@use` / `@import` from node_modules when available.
let mut load_paths = Vec::new();
if let Some(cfg) = crate::config::global_config() {
let content_parent = cfg.content_dir.parent().unwrap_or(&cfg.content_dir);
load_paths.push(content_parent.join("node_modules").to_string());

if let Some(vite_dir) = cfg.paths().vite {
let vite_node_modules = vite_dir.join("node_modules").to_string();
if !load_paths.contains(&vite_node_modules) {
load_paths.push(vite_node_modules);
}
}
}

// Compile via cell
match crate::cells::compile_sass_cell(&sass_map).await {
match crate::cells::compile_sass_cell(&sass_map, &load_paths).await {
Ok(cell_sass_proto::SassResult::Success { css }) => {
tracing::info!(output_bytes = css.len(), "SASS compilation complete");
Ok(Some(CompiledCss(css)))
Expand Down Expand Up @@ -894,12 +908,24 @@ pub async fn vite_css_for_entries<DB: Db>(db: &DB) -> PicanteResult<HashMap<Stri
}

let source_path = format!("/{src}");
let built_path = format!("/{}", entry.file);
let cache_busted_built_path = if let Some(static_file) = static_file_map.get(&entry.file) {
let output = static_file_output(db, *static_file).await?;
Some(format!("/{}", output.cache_busted_path))
} else {
None
};

tracing::debug!(
source = %source_path,
css_count = css_urls.len(),
"Vite entry CSS dependencies"
);
result.insert(source_path, css_urls);
result.insert(source_path, css_urls.clone());
result.insert(built_path, css_urls.clone());
if let Some(cache_busted) = cache_busted_built_path {
result.insert(cache_busted, css_urls);
}
}

Ok(result)
Expand Down
3 changes: 1 addition & 2 deletions crates/gingembre/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -623,9 +623,8 @@ impl Parser {
// Found a tag open — check if the keyword is "endcall"
let after_open = &source[pos + 2..];
let trimmed = after_open.trim_start();
if trimmed.starts_with("endcall") {
if let Some(rest) = trimmed.strip_prefix("endcall") {
// Verify it's the full keyword (not "endcallx")
let rest = &trimmed["endcall".len()..];
if rest.is_empty()
|| rest.starts_with(char::is_whitespace)
|| rest.starts_with("%}")
Expand Down
13 changes: 10 additions & 3 deletions crates/integration-tests/src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1077,15 +1077,19 @@ impl Response {
pub fn img_src(&self, pattern: &str) -> Option<String> {
let tendril = StrTendril::from(self.body.as_str());
let doc = hotmeal::parse(&tendril);
find_attr_in_node(&doc, doc.root, "img", "src", &|value| matches_glob(pattern, value))
find_attr_in_node(&doc, doc.root, "img", "src", &|value| {
matches_glob(pattern, value)
})
}

/// Find a <link> tag's href attribute matching a glob pattern
/// Returns the matched href value (without host) or None
pub fn css_link(&self, pattern: &str) -> Option<String> {
let tendril = StrTendril::from(self.body.as_str());
let doc = hotmeal::parse(&tendril);
find_attr_in_node(&doc, doc.root, "link", "href", &|value| matches_glob(pattern, value))
find_attr_in_node(&doc, doc.root, "link", "href", &|value| {
matches_glob(pattern, value)
})
}

/// Extract a value using a regex with one capture group
Expand Down Expand Up @@ -1489,6 +1493,9 @@ mod unit_tests {
assert!(matches_glob("*style*css", "/css/style.123.css"));
assert!(matches_glob("*/style.*.css", "/css/style.123.css"));
assert!(matches_glob("*/style.*.css", "/assets/css/style.123.css"));
assert!(!matches_glob("*/style.*.css", "/assets/css/style.123.css.map"));
assert!(!matches_glob(
"*/style.*.css",
"/assets/css/style.123.css.map"
));
}
}
7 changes: 7 additions & 0 deletions crates/integration-tests/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,13 @@ fn collect_tests() -> Vec<Test> {
func: static_assets::image_files_processed,
ignored: false,
},
#[cfg(unix)]
Test {
name: "symlinked_static_files_are_served",
module: "static_assets",
func: static_assets::symlinked_static_files_are_served,
ignored: false,
},
// livereload tests
Test {
name: "test_new_section_detected",
Expand Down
Loading
Loading