Skip to content
Merged
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
24 changes: 22 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ sha2 = "0.11.0"
expand-tilde = "0.6.1"
mime_guess = "2.0.5"
html-escape = "0.2.13"
include_dir = "0.7.4"
async-stream = "0.3.6"
futures-util = "0.3.31"
tokio-stream = "0.1.18"
Expand Down
1 change: 1 addition & 0 deletions crates/webui-press/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ html-escape = { workspace = true }
microsoft-webui-dev-server = { path = "../webui-dev-server", version = "0.0.17" }
actix-web = { workspace = true }
tokio = { workspace = true }
include_dir = { workspace = true }
22 changes: 22 additions & 0 deletions crates/webui-press/src/bundler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ use crate::error::{Error, Result};
use crate::types::BundlerConfig;

static BUNDLE_REBUILD_NONCE: AtomicU64 = AtomicU64::new(0);
const WEBUI_TSCONFIG_RAW: &str =
r#"{"compilerOptions":{"experimentalDecorators":true,"useDefineForClassFields":false}}"#;

/// Resolve a configured component source for the per-page builds.
///
Expand Down Expand Up @@ -1054,6 +1056,7 @@ fn esbuild_args(
args.push("--chunk-names=assets/[name]-[hash]".to_string());
args.push("--loader:.html=text".to_string());
args.push("--loader:.css=text".to_string());
args.push(format!("--tsconfig-raw={WEBUI_TSCONFIG_RAW}"));
args.push("--log-level=warning".to_string());
if !opts.dev_mode {
args.push("--minify".to_string());
Expand Down Expand Up @@ -1560,6 +1563,25 @@ mod tests {
assert!(args.contains(&"--external:cdn-only-package".to_string()));
}

#[test]
fn esbuild_args_force_webui_decorator_semantics() {
let site_dir = Path::new("/site");
let config_dir = Path::new("/site/.webui-press");
let opts = BundleOptions {
site_dir,
node_modules: None,
root_bundle: None,
page_bundles: &[],
bundler_config: None,
dev_mode: false,
config_dir,
content_dir: Path::new("/site"),
};
let args = esbuild_args(&opts, &[], Path::new("/tmp/webui-press-bundle"));

assert!(args.contains(&format!("--tsconfig-raw={WEBUI_TSCONFIG_RAW}")));
}

#[test]
fn config_component_source_preserves_npm_packages() {
let cwd = Path::new("project");
Expand Down
158 changes: 136 additions & 22 deletions crates/webui-press/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,15 @@ use std::process;
use anyhow::Result;
use clap::{Parser, Subcommand};
use console::style;
use include_dir::{include_dir, Dir, DirEntry};

use crate::types::DocsConfig;

static EMBEDDED_TEMPLATE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/template");
static EMBEDDED_COMPONENTS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/components");
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0100_0000_01b3;

#[derive(Parser)]
#[command(name = "webui-press", about = "WebUI documentation site builder")]
struct Cli {
Expand All @@ -44,7 +50,7 @@ enum Commands {
#[arg(short, long, default_value = ".webui-press/config.json")]
config: String,

/// Path to the template directory (overrides built-in)
/// Path to the template directory (overrides bundled assets)
#[arg(short, long)]
template: Option<String>,
},
Expand All @@ -55,7 +61,7 @@ enum Commands {
#[arg(short, long, default_value = ".webui-press/config.json")]
config: String,

/// Path to the template directory (overrides built-in)
/// Path to the template directory (overrides bundled assets)
#[arg(short, long)]
template: Option<String>,

Expand All @@ -71,7 +77,6 @@ enum Commands {

fn main() {
let cli = Cli::parse();

let result = match cli.command {
Commands::Build { config, template } => run_build(&config, template.as_deref()),
Commands::Serve {
Expand All @@ -88,7 +93,7 @@ fn main() {
}
}

/// Resolve config + template directory + parsed config from CLI args.
/// Load the config and materialize the embedded template assets.
/// Shared by `build` and `serve`.
fn load_config(
config_path: &str,
Expand All @@ -98,36 +103,101 @@ fn load_config(
.map_err(|e| anyhow::anyhow!("Cannot read config {}: {}", style(config_path).bold(), e))?;

let docs_config: DocsConfig = serde_json::from_str(&config_str)
.map_err(|e| anyhow::anyhow!("Invalid config JSON: {}", e))?;
.map_err(|e| anyhow::anyhow!("Invalid config JSON: {e}"))?;

let config_dir = Path::new(config_path)
.parent()
.unwrap_or(Path::new("."))
.to_path_buf();

let template = match template_dir {
Some(t) => Path::new(t).to_path_buf(),
None => {
let exe = std::env::current_exe().unwrap_or_default();
let exe_dir = exe.parent().unwrap_or(Path::new(".")).to_path_buf();

exe_dir
.ancestors()
.find_map(|dir| {
let t = dir.join("crates/webui-press/template");
if t.join("index.html").exists() {
Some(t)
} else {
None
}
})
.unwrap_or_else(|| exe_dir.join("template"))
}
Some(template_dir) => Path::new(template_dir).to_path_buf(),
None => extract_embedded_assets()?,
};

Ok((docs_config, config_dir, template))
}

/// Materialize the embedded template + components into a per-version,
/// content-addressed cache directory and return the `template` subdirectory.
///
/// The cache is content-addressed (keyed by an FNV-1a hash of the embedded
/// bytes), so a `.complete` directory for a given hash is always valid and is
/// reused as-is. A fresh extraction is written into a sibling staging directory
/// and published with a single atomic `rename`, so an interrupted run (Ctrl-C,
/// crash) never leaves a half-written cache: the next run sees no `.complete`
/// sentinel and re-extracts.
fn extract_embedded_assets() -> Result<PathBuf> {
let dir_name = format!(
"webui-press-{}-{:016x}",
env!("CARGO_PKG_VERSION"),
embedded_assets_hash()
);
let tmp = std::env::temp_dir();
let root = tmp.join(&dir_name);
let template_dir = root.join("template");

if is_complete_cache(&root) {
return Ok(template_dir);
}

// A `root` that isn't complete is a stale or interrupted extraction. Clear
// it and any leftover staging dir, extract into staging, then publish.
let staging = tmp.join(format!("{dir_name}.staging"));
let _ = fs::remove_dir_all(&staging);
let _ = fs::remove_dir_all(&root);
EMBEDDED_TEMPLATE
.extract(staging.join("template"))
.map_err(|e| anyhow::anyhow!("Cannot extract embedded template: {e}"))?;
EMBEDDED_COMPONENTS
.extract(staging.join("components"))
.map_err(|e| anyhow::anyhow!("Cannot extract embedded components: {e}"))?;
fs::write(staging.join(".complete"), [])
.map_err(|e| anyhow::anyhow!("Cannot finalize embedded template assets: {e}"))?;

// Atomic publish: the fully staged tree appears at `root` in one step.
fs::rename(&staging, &root)
.map_err(|e| anyhow::anyhow!("Cannot publish embedded template assets: {e}"))?;
Ok(template_dir)
}

/// A cache directory is usable only when fully extracted: the `.complete`
/// sentinel, the template entry point, and the sibling `components/` directory
/// (which `build_docs` discovers via `template_dir.parent()/components`) must
/// all be present. Validating `components/` here turns an externally
/// corrupted cache into a clean re-extraction instead of a confusing
/// missing-component build failure later.
fn is_complete_cache(root: &Path) -> bool {
root.join(".complete").is_file()
&& root.join("template").join("index.html").is_file()
&& root.join("components").is_dir()
}

fn embedded_assets_hash() -> u64 {
let mut hash = FNV_OFFSET;
hash = hash_dir(hash, &EMBEDDED_TEMPLATE);
hash_dir(hash, &EMBEDDED_COMPONENTS)
}

fn hash_dir(mut hash: u64, dir: &Dir<'_>) -> u64 {
for entry in dir.entries() {
hash = hash_bytes(hash, entry.path().to_string_lossy().as_bytes());
match entry {
DirEntry::Dir(dir) => hash = hash_dir(hash, dir),
DirEntry::File(file) => hash = hash_bytes(hash, file.contents()),
}
}
hash
}

fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 {
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}

fn run_build(config_path: &str, template_dir: Option<&str>) -> Result<()> {
let (docs_config, config_dir, template) = load_config(config_path, template_dir)?;
let _stats = build::build_docs(&docs_config, &config_dir, &template)?;
Expand All @@ -154,3 +224,47 @@ fn run_serve_blocking(
port,
}))
}

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

#[test]
fn embedded_assets_extract_template_and_components() -> Result<()> {
let template = extract_embedded_assets()?;
let root = template
.parent()
.ok_or_else(|| anyhow::anyhow!("template has no parent"))?;

assert!(template.join("index.html").is_file());
assert!(root.join("components/code-block/code-block.html").is_file());

// The published cache must satisfy the completeness contract, and a
// second call must reuse the same content-addressed directory.
assert!(is_complete_cache(root));
assert_eq!(extract_embedded_assets()?, template);
Ok(())
}

#[test]
fn incomplete_cache_is_not_treated_as_complete() -> Result<()> {
let base = std::env::temp_dir().join(format!(
"webui-press-test-incomplete-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&base);
let outcome: Result<()> = (|| {
// `.complete` + template present, but no sibling components/ dir.
fs::create_dir_all(base.join("template"))?;
fs::write(base.join("template").join("index.html"), b"<html></html>")?;
fs::write(base.join(".complete"), [])?;
assert!(!is_complete_cache(&base));

fs::create_dir_all(base.join("components"))?;
assert!(is_complete_cache(&base));
Ok(())
})();
let _ = fs::remove_dir_all(&base);
outcome
}
}
2 changes: 1 addition & 1 deletion crates/webui-press/template/docs-search/docs-search.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<div class="box">
<div class="input-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input w-ref={searchInput} type="text" placeholder="Search documentation..." autocomplete="off" @input="{onInput()}" />
<input id="docs-search-input" name="q" w-ref={searchInput} type="text" placeholder="Search documentation..." autocomplete="off" @input="{onInput()}" />
</div>
<div class="results">
<if condition="emptyMessage">
Expand Down
Loading