Skip to content

Commit 0d7d5da

Browse files
Embed webui-press template assets and fix client bundling (#375)
1 parent 75b3403 commit 0d7d5da

6 files changed

Lines changed: 183 additions & 25 deletions

File tree

Cargo.lock

Lines changed: 22 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ sha2 = "0.11.0"
4646
expand-tilde = "0.6.1"
4747
mime_guess = "2.0.5"
4848
html-escape = "0.2.13"
49+
include_dir = "0.7.4"
4950
async-stream = "0.3.6"
5051
futures-util = "0.3.31"
5152
tokio-stream = "0.1.18"

crates/webui-press/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ html-escape = { workspace = true }
4949
microsoft-webui-dev-server = { path = "../webui-dev-server", version = "0.0.17" }
5050
actix-web = { workspace = true }
5151
tokio = { workspace = true }
52+
include_dir = { workspace = true }

crates/webui-press/src/bundler.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use crate::error::{Error, Result};
1212
use crate::types::BundlerConfig;
1313

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

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

1566+
#[test]
1567+
fn esbuild_args_force_webui_decorator_semantics() {
1568+
let site_dir = Path::new("/site");
1569+
let config_dir = Path::new("/site/.webui-press");
1570+
let opts = BundleOptions {
1571+
site_dir,
1572+
node_modules: None,
1573+
root_bundle: None,
1574+
page_bundles: &[],
1575+
bundler_config: None,
1576+
dev_mode: false,
1577+
config_dir,
1578+
content_dir: Path::new("/site"),
1579+
};
1580+
let args = esbuild_args(&opts, &[], Path::new("/tmp/webui-press-bundle"));
1581+
1582+
assert!(args.contains(&format!("--tsconfig-raw={WEBUI_TSCONFIG_RAW}")));
1583+
}
1584+
15631585
#[test]
15641586
fn config_component_source_preserves_npm_packages() {
15651587
let cwd = Path::new("project");

crates/webui-press/src/main.rs

Lines changed: 136 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,15 @@ use std::process;
2626
use anyhow::Result;
2727
use clap::{Parser, Subcommand};
2828
use console::style;
29+
use include_dir::{include_dir, Dir, DirEntry};
2930

3031
use crate::types::DocsConfig;
3132

33+
static EMBEDDED_TEMPLATE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/template");
34+
static EMBEDDED_COMPONENTS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/components");
35+
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
36+
const FNV_PRIME: u64 = 0x0100_0000_01b3;
37+
3238
#[derive(Parser)]
3339
#[command(name = "webui-press", about = "WebUI documentation site builder")]
3440
struct Cli {
@@ -44,7 +50,7 @@ enum Commands {
4450
#[arg(short, long, default_value = ".webui-press/config.json")]
4551
config: String,
4652

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

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

@@ -71,7 +77,6 @@ enum Commands {
7177

7278
fn main() {
7379
let cli = Cli::parse();
74-
7580
let result = match cli.command {
7681
Commands::Build { config, template } => run_build(&config, template.as_deref()),
7782
Commands::Serve {
@@ -88,7 +93,7 @@ fn main() {
8893
}
8994
}
9095

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

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

103108
let config_dir = Path::new(config_path)
104109
.parent()
105110
.unwrap_or(Path::new("."))
106111
.to_path_buf();
107112

108113
let template = match template_dir {
109-
Some(t) => Path::new(t).to_path_buf(),
110-
None => {
111-
let exe = std::env::current_exe().unwrap_or_default();
112-
let exe_dir = exe.parent().unwrap_or(Path::new(".")).to_path_buf();
113-
114-
exe_dir
115-
.ancestors()
116-
.find_map(|dir| {
117-
let t = dir.join("crates/webui-press/template");
118-
if t.join("index.html").exists() {
119-
Some(t)
120-
} else {
121-
None
122-
}
123-
})
124-
.unwrap_or_else(|| exe_dir.join("template"))
125-
}
114+
Some(template_dir) => Path::new(template_dir).to_path_buf(),
115+
None => extract_embedded_assets()?,
126116
};
127117

128118
Ok((docs_config, config_dir, template))
129119
}
130120

121+
/// Materialize the embedded template + components into a per-version,
122+
/// content-addressed cache directory and return the `template` subdirectory.
123+
///
124+
/// The cache is content-addressed (keyed by an FNV-1a hash of the embedded
125+
/// bytes), so a `.complete` directory for a given hash is always valid and is
126+
/// reused as-is. A fresh extraction is written into a sibling staging directory
127+
/// and published with a single atomic `rename`, so an interrupted run (Ctrl-C,
128+
/// crash) never leaves a half-written cache: the next run sees no `.complete`
129+
/// sentinel and re-extracts.
130+
fn extract_embedded_assets() -> Result<PathBuf> {
131+
let dir_name = format!(
132+
"webui-press-{}-{:016x}",
133+
env!("CARGO_PKG_VERSION"),
134+
embedded_assets_hash()
135+
);
136+
let tmp = std::env::temp_dir();
137+
let root = tmp.join(&dir_name);
138+
let template_dir = root.join("template");
139+
140+
if is_complete_cache(&root) {
141+
return Ok(template_dir);
142+
}
143+
144+
// A `root` that isn't complete is a stale or interrupted extraction. Clear
145+
// it and any leftover staging dir, extract into staging, then publish.
146+
let staging = tmp.join(format!("{dir_name}.staging"));
147+
let _ = fs::remove_dir_all(&staging);
148+
let _ = fs::remove_dir_all(&root);
149+
EMBEDDED_TEMPLATE
150+
.extract(staging.join("template"))
151+
.map_err(|e| anyhow::anyhow!("Cannot extract embedded template: {e}"))?;
152+
EMBEDDED_COMPONENTS
153+
.extract(staging.join("components"))
154+
.map_err(|e| anyhow::anyhow!("Cannot extract embedded components: {e}"))?;
155+
fs::write(staging.join(".complete"), [])
156+
.map_err(|e| anyhow::anyhow!("Cannot finalize embedded template assets: {e}"))?;
157+
158+
// Atomic publish: the fully staged tree appears at `root` in one step.
159+
fs::rename(&staging, &root)
160+
.map_err(|e| anyhow::anyhow!("Cannot publish embedded template assets: {e}"))?;
161+
Ok(template_dir)
162+
}
163+
164+
/// A cache directory is usable only when fully extracted: the `.complete`
165+
/// sentinel, the template entry point, and the sibling `components/` directory
166+
/// (which `build_docs` discovers via `template_dir.parent()/components`) must
167+
/// all be present. Validating `components/` here turns an externally
168+
/// corrupted cache into a clean re-extraction instead of a confusing
169+
/// missing-component build failure later.
170+
fn is_complete_cache(root: &Path) -> bool {
171+
root.join(".complete").is_file()
172+
&& root.join("template").join("index.html").is_file()
173+
&& root.join("components").is_dir()
174+
}
175+
176+
fn embedded_assets_hash() -> u64 {
177+
let mut hash = FNV_OFFSET;
178+
hash = hash_dir(hash, &EMBEDDED_TEMPLATE);
179+
hash_dir(hash, &EMBEDDED_COMPONENTS)
180+
}
181+
182+
fn hash_dir(mut hash: u64, dir: &Dir<'_>) -> u64 {
183+
for entry in dir.entries() {
184+
hash = hash_bytes(hash, entry.path().to_string_lossy().as_bytes());
185+
match entry {
186+
DirEntry::Dir(dir) => hash = hash_dir(hash, dir),
187+
DirEntry::File(file) => hash = hash_bytes(hash, file.contents()),
188+
}
189+
}
190+
hash
191+
}
192+
193+
fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 {
194+
for byte in bytes {
195+
hash ^= u64::from(*byte);
196+
hash = hash.wrapping_mul(FNV_PRIME);
197+
}
198+
hash
199+
}
200+
131201
fn run_build(config_path: &str, template_dir: Option<&str>) -> Result<()> {
132202
let (docs_config, config_dir, template) = load_config(config_path, template_dir)?;
133203
let _stats = build::build_docs(&docs_config, &config_dir, &template)?;
@@ -154,3 +224,47 @@ fn run_serve_blocking(
154224
port,
155225
}))
156226
}
227+
228+
#[cfg(test)]
229+
mod tests {
230+
use super::*;
231+
232+
#[test]
233+
fn embedded_assets_extract_template_and_components() -> Result<()> {
234+
let template = extract_embedded_assets()?;
235+
let root = template
236+
.parent()
237+
.ok_or_else(|| anyhow::anyhow!("template has no parent"))?;
238+
239+
assert!(template.join("index.html").is_file());
240+
assert!(root.join("components/code-block/code-block.html").is_file());
241+
242+
// The published cache must satisfy the completeness contract, and a
243+
// second call must reuse the same content-addressed directory.
244+
assert!(is_complete_cache(root));
245+
assert_eq!(extract_embedded_assets()?, template);
246+
Ok(())
247+
}
248+
249+
#[test]
250+
fn incomplete_cache_is_not_treated_as_complete() -> Result<()> {
251+
let base = std::env::temp_dir().join(format!(
252+
"webui-press-test-incomplete-{}",
253+
std::process::id()
254+
));
255+
let _ = fs::remove_dir_all(&base);
256+
let outcome: Result<()> = (|| {
257+
// `.complete` + template present, but no sibling components/ dir.
258+
fs::create_dir_all(base.join("template"))?;
259+
fs::write(base.join("template").join("index.html"), b"<html></html>")?;
260+
fs::write(base.join(".complete"), [])?;
261+
assert!(!is_complete_cache(&base));
262+
263+
fs::create_dir_all(base.join("components"))?;
264+
assert!(is_complete_cache(&base));
265+
Ok(())
266+
})();
267+
let _ = fs::remove_dir_all(&base);
268+
outcome
269+
}
270+
}

crates/webui-press/template/docs-search/docs-search.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<div class="box">
77
<div class="input-wrap">
88
<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>
9-
<input w-ref={searchInput} type="text" placeholder="Search documentation..." autocomplete="off" @input="{onInput()}" />
9+
<input id="docs-search-input" name="q" w-ref={searchInput} type="text" placeholder="Search documentation..." autocomplete="off" @input="{onInput()}" />
1010
</div>
1111
<div class="results">
1212
<if condition="emptyMessage">

0 commit comments

Comments
 (0)