diff --git a/Cargo.lock b/Cargo.lock index 95b077ee..9d516ed4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1955,6 +1955,7 @@ dependencies = [ "serde_json", "serde_yaml", "syntect", + "tempfile", "thiserror", "tokio", ] diff --git a/DESIGN.md b/DESIGN.md index fa95dbbb..53aa4ceb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4945,8 +4945,43 @@ The adapter handles all outputs in one `onEnd` pass. ### webui-press integration +`DocsConfig.show` is a typed `ShowMode` (`all` or `content`), defaulting to +`all`. Both native `build` and `serve` accept `--show`; an explicit CLI value +overrides configuration on the initial build and every serve config reload. +Page and 404 build errors retain the core error's complete source chain, +including parser diagnostic codes, locations, snippets, and help when present. + +Content mode selects the bundled content document before region expansion, +component/script reachability, compilation, and SSR. It retains document +metadata, base URL, configured head tags, themes, authored page modules, state, +and semantic `main`/`article` wrappers. Markdown (including home Markdown), +custom-page HTML, examples, and API panels are content, regardless of their +element names. No template regions, navigation, sidebar/TOC, mobile context, +previous/next links, hero/features, footer, or shell scripts are generated. +Configured regions remain validated against the selected full template, but +their state and scripts are inactive. This also applies to the 404 document. + +The bundled `docs.css` contains shared tokens and content typography; +`shell.css` contains full-site layout constraints. All mode concatenates both +into one served stylesheet, preserving the existing layout without an extra +request. Content mode uses only the bundled content styles, normal document +scrolling, and no shell width/height constraints. A custom full template and +its styles/entry script do not replace the content-mode scaffold; configured +head tags, CSS/theme, components, and custom pages continue to apply. + +Full-site manual light/dark selection overrides the OS preference for both +native theme tokens and `color-scheme`. Manual overrides are inactive under +forced colors so site-authored forced-colors token rules retain precedence. +Content mode has no shell theme control, does not read or snapshot a persisted +theme into `data-theme`, and uses only the light default and system dark media +query. It follows live OS preference changes without JavaScript and leaves the +stored full-site preference untouched. + `webui-press` invokes esbuild's JavaScript API once through `@microsoft/webui/projection.js`, then validates the generated manifest once. +Filesystem alias targets are resolved against an absolute config directory +before passing them to esbuild. Relative and absolute CLI config paths retain +the same config-relative alias semantics, independent of the invocation cwd. The resulting `PreparedProjectionManifests` is reused by every page and the 404 build; page builds never re-open or re-hash bundle files. The prepared handle is an `Arc`-backed immutable snapshot containing both component surfaces and diff --git a/crates/webui-press/Cargo.toml b/crates/webui-press/Cargo.toml index 98a91481..75a11f73 100644 --- a/crates/webui-press/Cargo.toml +++ b/crates/webui-press/Cargo.toml @@ -50,3 +50,6 @@ microsoft-webui-dev-server = { path = "../webui-dev-server", version = "0.0.28" actix-web = { workspace = true } tokio = { workspace = true } include_dir = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/webui-press/README.md b/crates/webui-press/README.md index b68710a2..64ddaf05 100644 --- a/crates/webui-press/README.md +++ b/crates/webui-press/README.md @@ -125,13 +125,40 @@ Every `.md` file under `contentDir` becomes a page automatically. The sidebar/na ``` webui-press build [OPTIONS] +webui-press serve [OPTIONS] Options: -c, --config Path to config.json [default: .webui-press/config.json] -t, --template Override the bundled template directory + --show all or content [default: config show, otherwise all] -h, --help Print help ``` +Use `webui-press build --show=content` or `webui-press serve --show=content` +for a shell-free gallery or documentation view. Omit the flag for the complete +site, or set `"show": "content"` in config to make content mode the default. +An explicit flag overrides config, including after live config reloads. + +Content mode keeps a complete HTML document, metadata/base URL, themes, SSR, +hydration, Markdown, examples, API panels, custom-page content, and page scripts. +It omits Press template regions, hero/features, header/navigation, +sidebars/TOC, mobile navigation, previous/next links, and footer before +compilation. Authored header/side-pane examples are not filtered. Home Markdown +is rendered in this mode; doc, page, full, custom, and 404 pages all use normal +document scrolling without reserved shell columns or viewport-fill behavior. + +Content mode uses the bundled `main`/`article` scaffold even with `--template`. +Its colors follow the OS preference, including live changes, without reading +or changing the full site's saved theme selection. Full mode retains its +persisted theme control; manual light/dark selection applies to native theme +tokens and browser controls even when the OS preference differs. Forced-colors +styles retain precedence over manual theme overrides. + +The full template's regions are still validated but are inactive, as are its +CSS and entry script. Configure `head`, `css`, `theme`, `components`, and page +scripts for assets that should apply in both modes. In the bundled full template, +shared `docs.css` and layout-only `shell.css` are combined into one output file. + The build pipeline: ``` diff --git a/crates/webui-press/src/build.rs b/crates/webui-press/src/build.rs index 53483385..79cd16e7 100644 --- a/crates/webui-press/src/build.rs +++ b/crates/webui-press/src/build.rs @@ -28,7 +28,7 @@ use crate::error::{Error, Result}; use crate::markdown::Highlighter; use crate::regions::RegionSet; use crate::state::{load_render_states, merge_page_state}; -use crate::types::{BuildStats, DocsConfig, PageDescriptor}; +use crate::types::{BuildStats, DocsConfig, PageDescriptor, ShowMode}; webui_handler::define_string_response_writer!(StringWriter, buf); @@ -41,6 +41,27 @@ fn region_layout(page: &PageDescriptor) -> &str { } } +fn template_css(template_dir: &Path, show: ShowMode) -> Result { + if show == ShowMode::Content { + return Ok(include_str!("../template/docs.css").to_string()); + } + let mut css = String::new(); + for name in ["docs.css", "shell.css"] { + let path = template_dir.join(name); + match fs::read_to_string(&path) { + Ok(source) => css.push_str(&source), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(Error::Io(format!( + "Cannot read {}: {error}", + path.display() + ))); + } + } + } + Ok(css) +} + /// Persistent state held by the dev server across rebuilds. The dev /// server always performs a full rebuild on every watcher tick — the /// previous incremental machinery proved too complex for the marginal @@ -298,8 +319,8 @@ pub fn build_docs_with_cache( // the link tags change. We don't write the CSS files yet — that // happens after content processing succeeds, so a content failure // can't corrupt the previous valid output. - let base_css_src = template_dir.join("docs.css"); - let has_base_css = base_css_src.exists(); + let base_css = template_css(template_dir, config.show)?; + let has_base_css = !base_css.is_empty(); let base_css_link = if has_base_css { format!("") } else { @@ -361,6 +382,17 @@ pub fn build_docs_with_cache( let template_html = fs::read_to_string(template_dir.join("index.html")) .map_err(|e| Error::Build(format!("Failed to read template: {e}")))?; let regions = RegionSet::load(&config.regions, config_dir, template_html)?; + // Validate configured region names even when the selected presentation + // makes every shell region inactive. Authored page content is never filtered. + let regions = if config.show == ShowMode::Content { + RegionSet::load( + &Default::default(), + config_dir, + include_str!("../template/content.html").to_string(), + )? + } else { + regions + }; let component_script_index = discover_component_scripts(&component_sources)?; // Step 3: Wipe the previous output and recreate the site root. @@ -379,8 +411,8 @@ pub fn build_docs_with_cache( // because we clean before processing — but that order means a // failure leaves no output at all, never half-output). if has_base_css { - fs::copy(&base_css_src, site_dir.join("docs.css")) - .map_err(|e| Error::Io(format!("Cannot copy docs.css: {e}")))?; + fs::write(site_dir.join("docs.css"), &base_css) + .map_err(|e| Error::Io(format!("Cannot write docs.css: {e}")))?; } if has_theme_css { fs::write(site_dir.join("theme.css"), &custom_css) @@ -453,7 +485,7 @@ pub fn build_docs_with_cache( ); let template_script_path = template_dir.join("index.ts"); - let template_script = if template_script_path.exists() { + let template_script = if config.show == ShowMode::All && template_script_path.exists() { Some( template_script_path .canonicalize() @@ -663,7 +695,7 @@ pub fn build_docs_with_cache( projection_manifests: vec![projection_source.clone()], ..BuildOptions::default() }) - .map_err(|e| Error::Build(format!("{}: {e}", page.path)))?; + .map_err(|e| Error::Build(format!("{}: {}", page.path, e.chain_message())))?; let preloads = generated_preloads.get().ok_or_else(|| { Error::Build("Generated preload metadata was not published".to_string()) })?; @@ -807,7 +839,7 @@ pub fn build_docs_with_cache( projection_manifests: vec![projection_source], ..BuildOptions::default() }) - .map_err(|e| Error::Build(format!("404 build failed: {e}")))?; + .map_err(|e| Error::Build(format!("404 build failed: {}", e.chain_message())))?; let preloads = generated_preloads .get() .ok_or_else(|| Error::Build("Generated preload metadata was not published".to_string()))?; diff --git a/crates/webui-press/src/bundler.rs b/crates/webui-press/src/bundler.rs index b2fdbba6..f1553ffb 100644 --- a/crates/webui-press/src/bundler.rs +++ b/crates/webui-press/src/bundler.rs @@ -1202,7 +1202,8 @@ fn normalized_alias_target(config_dir: &Path, target: &str) -> String { target.replace('\\', "/") } -fn build_aliases(opts: &BundleOptions<'_>) -> BTreeMap { +fn build_aliases(opts: &BundleOptions<'_>) -> Result> { + let config_dir = absolute_path(opts.config_dir)?; let mut aliases: BTreeMap = BTreeMap::new(); if let Some(node_modules) = opts.node_modules { if let Some(path) = default_framework_alias(node_modules) { @@ -1212,11 +1213,11 @@ fn build_aliases(opts: &BundleOptions<'_>) -> BTreeMap { if let Some(cfg) = opts.bundler_config { for (from, to) in &cfg.alias { - aliases.insert(from.clone(), normalized_alias_target(opts.config_dir, to)); + aliases.insert(from.clone(), normalized_alias_target(&config_dir, to)); } } - aliases + Ok(aliases) } #[cfg(test)] @@ -1238,8 +1239,8 @@ fn esbuild_args( opts: &BundleOptions<'_>, entry_files: &[(String, PathBuf)], bundle_tmp: &Path, -) -> Vec { - let aliases = build_aliases(opts); +) -> Result> { + let aliases = build_aliases(opts)?; let target = opts .bundler_config .and_then(|cfg| cfg.target.as_deref()) @@ -1275,7 +1276,7 @@ fn esbuild_args( for (_, path) in entry_files { args.push(path_for_js(path)); } - args + Ok(args) } fn esbuild_build_config( @@ -1288,7 +1289,7 @@ fn esbuild_build_config( let working_dir = absolute_path(opts.config_dir)?; let site_dir = absolute_path(opts.site_dir)?; let manifest_path = absolute_path(manifest_path)?; - let aliases = build_aliases(opts); + let aliases = build_aliases(opts)?; let target = opts .bundler_config .and_then(|cfg| cfg.target.as_deref()) @@ -2051,7 +2052,7 @@ mod tests { } #[test] - fn esbuild_args_force_webui_decorator_semantics() { + fn esbuild_args_force_webui_decorator_semantics() -> TestResult { let site_dir = Path::new("/site"); let config_dir = Path::new("/site/.webui-press"); let opts = BundleOptions { @@ -2065,13 +2066,14 @@ mod tests { config_dir, content_dir: Path::new("/site"), }; - let args = esbuild_args(&opts, &[], Path::new("/tmp/webui-press-bundle")); + let args = esbuild_args(&opts, &[], Path::new("/tmp/webui-press-bundle"))?; assert!(args.contains(&format!("--tsconfig-raw={WEBUI_TSCONFIG_RAW}"))); + Ok(()) } #[test] - fn esbuild_args_folds_webui_dev_flag_for_production_only() { + fn esbuild_args_folds_webui_dev_flag_for_production_only() -> TestResult { fn opts<'a>( site_dir: &'a Path, config_dir: &'a Path, @@ -2097,12 +2099,12 @@ mod tests { // Production build: the flag is folded to `false` so the framework's // dev-only diagnostics (and the module gating them) tree-shake out. - let prod = esbuild_args(&opts(site_dir, config_dir, false, None), &[], tmp); + let prod = esbuild_args(&opts(site_dir, config_dir, false, None), &[], tmp)?; assert!(prod.contains(&define)); // Development build (`webui-press serve`): the flag is left undefined so // the `typeof` guard defaults it to on and diagnostics run. - let dev = esbuild_args(&opts(site_dir, config_dir, true, None), &[], tmp); + let dev = esbuild_args(&opts(site_dir, config_dir, true, None), &[], tmp)?; assert!(!dev.iter().any(|arg| arg.contains("__WEBUI_DEV__"))); // A user-supplied define wins: esbuild honors the last `--define` for a @@ -2110,7 +2112,7 @@ mod tests { let mut cfg = BundlerConfig::default(); cfg.define .insert("__WEBUI_DEV__".to_string(), "true".to_string()); - let overridden = esbuild_args(&opts(site_dir, config_dir, false, Some(&cfg)), &[], tmp); + let overridden = esbuild_args(&opts(site_dir, config_dir, false, Some(&cfg)), &[], tmp)?; let ours = overridden.iter().position(|arg| arg == &define); let theirs = overridden .iter() @@ -2121,6 +2123,7 @@ mod tests { ours < theirs, "framework default must precede the user override so esbuild's last-wins keeps the user's value", ); + Ok(()) } #[test] diff --git a/crates/webui-press/src/content.rs b/crates/webui-press/src/content.rs index b26315fb..2fcfb74b 100644 --- a/crates/webui-press/src/content.rs +++ b/crates/webui-press/src/content.rs @@ -12,7 +12,7 @@ use serde_json::{Map, Value}; use crate::error::{Error, Result}; use crate::markdown::{render_markdown, Highlighter}; use crate::state::{load_render_states, merge_page_state, LoadedStates}; -use crate::types::{DocsConfig, NavLink, PageDescriptor, SidebarItem, SidebarSection}; +use crate::types::{DocsConfig, NavLink, PageDescriptor, ShowMode, SidebarItem, SidebarSection}; /// Normalize a config link (e.g. `/guide/intro/` or `/guide/intro`) to a /// canonical URL path that includes the site's `base_path` prefix and @@ -518,7 +518,7 @@ pub(crate) fn process_content_with_states( fm.layout.unwrap_or_else(|| "doc".to_string()) }; - if !is_home { + if !is_home || config.show == ShowMode::Content { // Canonical page URL: every page is written as // `/index.html`, so it is served with a trailing // slash. In-page anchors need this exact path so they diff --git a/crates/webui-press/src/extraction_tests.rs b/crates/webui-press/src/extraction_tests.rs new file mode 100644 index 00000000..cc7f0a09 --- /dev/null +++ b/crates/webui-press/src/extraction_tests.rs @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use std::process::{Child, Command}; +use std::time::{Duration, Instant}; + +use super::*; + +const TEST_ROOT_ENV: &str = "WEBUI_PRESS_EXTRACTION_TEST_ROOT"; +const WORKER_ENV: &str = "WEBUI_PRESS_EXTRACTION_WORKER"; +const TIMEOUT: Duration = Duration::from_secs(30); + +// Only the dedicated subprocess test enables these barriers. Production builds +// omit both the barriers and their call sites. +pub(super) fn checkpoint(phase: &str) -> Result<()> { + let Some(root) = std::env::var_os(TEST_ROOT_ENV) else { + return Ok(()); + }; + let worker = std::env::var(WORKER_ENV)?; + let root = PathBuf::from(root); + fs::write(root.join(format!("{worker}-{phase}")), [])?; + wait_for( + || { + Ok(root + .join(format!("{worker}-{phase}-continue")) + .try_exists()?) + }, + &format!("release of {worker} at {phase}"), + ) +} + +#[test] +fn extraction_worker() -> Result<()> { + // The test harness also discovers this entry point in normal, non-worker runs. + let Some(root) = std::env::var_os(TEST_ROOT_ENV) else { + return Ok(()); + }; + let root = PathBuf::from(root); + let worker = std::env::var(WORKER_ENV)?; + let template = extract_embedded_assets_in(&root.join("cache"))?; + fs::write( + root.join(format!("{worker}-result.json")), + serde_json::to_vec(&template)?, + )?; + Ok(()) +} + +#[test] +fn concurrent_cold_extractions_share_complete_cache() -> Result<()> { + let fixture = tempfile::tempdir()?; + let cache = fixture.path().join("cache"); + fs::create_dir(&cache)?; + let name = format!( + "webui-press-{}-{:016x}", + env!("CARGO_PKG_VERSION"), + embedded_assets_hash() + ); + let root = cache.join(&name); + let staging = cache.join(format!("{name}.staging")); + + let mut first = ExtractionProcess::spawn(fixture.path(), "first")?; + first.wait_at("cold")?; + assert!(!root.exists()); + first.resume("cold")?; + first.wait_at("staged")?; + assert!(staging.is_dir()); + assert!(!is_complete_cache(&root)); + assert!(!staging.join(".complete").exists()); + + // Keep the first writer inside its incomplete staging tree while a separate + // process reaches the cold path. Probe the actual OS lock, not elapsed time. + let mut second = ExtractionProcess::spawn(fixture.path(), "second")?; + second.wait_at("cold")?; + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .open(cache.join(format!("{name}.lock")))?; + match lock.try_lock() { + Err(fs::TryLockError::WouldBlock) => {} + Err(error) => return Err(error.into()), + Ok(()) => anyhow::bail!("cold extraction must hold the cache lock while staging"), + } + + second.resume("cold")?; + // If the post-lock completeness check regresses, let the second writer exit + // so its unexpected staging checkpoint fails below instead of timing out. + second.resume("staged")?; + first.resume("staged")?; + let first_template = first.finish()?; + let second_template = second.finish()?; + + assert_eq!(first_template, root.join("template")); + assert_eq!(second_template, first_template); + assert!(is_complete_cache(&root)); + assert!(!staging.exists()); + assert!(!fixture.path().join("second-staged").exists()); + assert_embedded_files(&EMBEDDED_TEMPLATE, &first_template)?; + assert_embedded_files(&EMBEDDED_COMPONENTS, &root.join("components"))?; + Ok(()) +} + +fn assert_embedded_files(embedded: &Dir<'_>, output: &Path) -> Result<()> { + let mut pending = vec![embedded]; + while let Some(dir) = pending.pop() { + for entry in dir.entries() { + match entry { + DirEntry::Dir(child) => { + assert!(output.join(child.path()).is_dir()); + pending.push(child); + } + DirEntry::File(file) => { + assert_eq!( + fs::read(output.join(file.path()))?, + file.contents(), + "incomplete embedded asset: {}", + file.path().display() + ); + } + } + } + } + Ok(()) +} + +struct ExtractionProcess { + child: Child, + root: PathBuf, + name: &'static str, +} + +impl ExtractionProcess { + fn spawn(root: &Path, name: &'static str) -> Result { + let child = Command::new(std::env::current_exe()?) + .args([ + "--exact", + "extraction_tests::extraction_worker", + "--nocapture", + ]) + .env(TEST_ROOT_ENV, root) + .env(WORKER_ENV, name) + .spawn()?; + Ok(Self { + child, + root: root.to_path_buf(), + name, + }) + } + + fn wait_at(&mut self, phase: &str) -> Result<()> { + let marker = self.root.join(format!("{}-{phase}", self.name)); + wait_for( + || { + if marker.try_exists()? { + return Ok(true); + } + anyhow::ensure!( + self.child.try_wait()?.is_none(), + "{} exited before reaching {phase}", + self.name + ); + Ok(false) + }, + &format!("{} to reach {phase}", self.name), + ) + } + + fn resume(&self, phase: &str) -> Result<()> { + fs::write( + self.root.join(format!("{}-{phase}-continue", self.name)), + [], + )?; + Ok(()) + } + + fn finish(mut self) -> Result { + wait_for( + || match self.child.try_wait()? { + Some(status) => { + anyhow::ensure!(status.success(), "{} failed: {status}", self.name); + Ok(true) + } + None => Ok(false), + }, + &format!("{} to finish extracting", self.name), + )?; + Ok(serde_json::from_slice(&fs::read( + self.root.join(format!("{}-result.json", self.name)), + )?)?) + } +} + +impl Drop for ExtractionProcess { + fn drop(&mut self) { + // Always reap subprocesses, including when an assertion or barrier fails. + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn wait_for(mut ready: impl FnMut() -> Result, description: &str) -> Result<()> { + let deadline = Instant::now() + TIMEOUT; + while !ready()? { + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for {description}" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Ok(()) +} diff --git a/crates/webui-press/src/lib.rs b/crates/webui-press/src/lib.rs index 063c4edf..f61129ed 100644 --- a/crates/webui-press/src/lib.rs +++ b/crates/webui-press/src/lib.rs @@ -18,4 +18,4 @@ pub mod types; pub use build::build_docs; pub use serve::run_serve; -pub use types::DocsConfig; +pub use types::{DocsConfig, ShowMode}; diff --git a/crates/webui-press/src/main.rs b/crates/webui-press/src/main.rs index a9dd554d..c8dc7ddb 100644 --- a/crates/webui-press/src/main.rs +++ b/crates/webui-press/src/main.rs @@ -15,6 +15,8 @@ mod build; mod bundler; mod content; mod error; +#[cfg(test)] +mod extraction_tests; mod markdown; mod regions; mod serve; @@ -30,7 +32,7 @@ use clap::{Parser, Subcommand}; use console::style; use include_dir::{include_dir, Dir, DirEntry}; -use crate::types::DocsConfig; +use crate::types::{DocsConfig, ShowMode}; static EMBEDDED_TEMPLATE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/template"); static EMBEDDED_COMPONENTS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/components"); @@ -38,7 +40,11 @@ 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")] +#[command( + name = "webui-press", + version, + about = "WebUI documentation site builder" +)] struct Cli { #[command(subcommand)] command: Commands, @@ -55,6 +61,10 @@ enum Commands { /// Path to the template directory (overrides bundled assets) #[arg(short, long)] template: Option, + + /// Generate the complete site or only page content (default: all) + #[arg(long, value_enum)] + show: Option, }, /// Build, watch sources, and serve with live reload (dev only) @@ -67,6 +77,10 @@ enum Commands { #[arg(short, long)] template: Option, + /// Generate the complete site or only page content (default: all) + #[arg(long, value_enum)] + show: Option, + /// Port to bind #[arg(short, long, default_value_t = 3333)] port: u16, @@ -80,13 +94,18 @@ enum Commands { fn main() { let cli = Cli::parse(); let result = match cli.command { - Commands::Build { config, template } => run_build(&config, template.as_deref()), + Commands::Build { + config, + template, + show, + } => run_build(&config, template.as_deref(), show), Commands::Serve { config, template, + show, port, host, - } => run_serve_blocking(&config, template.as_deref(), &host, port), + } => run_serve_blocking(&config, template.as_deref(), &host, port, show), }; if let Err(e) = result { @@ -130,12 +149,15 @@ fn load_config( /// crash) never leaves a half-written cache: the next run sees no `.complete` /// sentinel and re-extracts. fn extract_embedded_assets() -> Result { + extract_embedded_assets_in(&std::env::temp_dir()) +} + +fn extract_embedded_assets_in(tmp: &Path) -> Result { 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"); @@ -143,11 +165,33 @@ fn extract_embedded_assets() -> Result { return Ok(template_dir); } + // Concurrent cold builds must not remove one another's staging directory. + let cache_lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(tmp.join(format!("{dir_name}.lock"))) + .map_err(|e| anyhow::anyhow!("Cannot open embedded asset cache lock: {e}"))?; + #[cfg(test)] + extraction_tests::checkpoint("cold")?; + cache_lock + .lock() + .map_err(|e| anyhow::anyhow!("Cannot lock embedded asset cache: {e}"))?; + 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); + fs::create_dir_all(staging.join("template")) + .and_then(|()| fs::create_dir_all(staging.join("components"))) + .map_err(|e| anyhow::anyhow!("Cannot create embedded asset directories: {e}"))?; + #[cfg(test)] + extraction_tests::checkpoint("staged")?; EMBEDDED_TEMPLATE .extract(staging.join("template")) .map_err(|e| anyhow::anyhow!("Cannot extract embedded template: {e}"))?; @@ -200,8 +244,11 @@ fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 { hash } -fn run_build(config_path: &str, template_dir: Option<&str>) -> Result<()> { - let (docs_config, config_dir, template) = load_config(config_path, template_dir)?; +fn run_build(config_path: &str, template_dir: Option<&str>, show: Option) -> Result<()> { + let (mut docs_config, config_dir, template) = load_config(config_path, template_dir)?; + if let Some(show) = show { + docs_config.show = show; + } let _stats = build::build_docs(&docs_config, &config_dir, &template)?; Ok(()) } @@ -211,6 +258,7 @@ fn run_serve_blocking( template_dir: Option<&str>, host: &str, port: u16, + show_override: Option, ) -> Result<()> { let (docs_config, config_dir, template) = load_config(config_path, template_dir)?; let config_path_buf = Path::new(config_path).to_path_buf(); @@ -224,6 +272,7 @@ fn run_serve_blocking( config_path: config_path_buf, host: host.to_string(), port, + show_override, })) } @@ -231,6 +280,25 @@ fn run_serve_blocking( mod tests { use super::*; + #[test] + fn show_mode_is_typed_for_both_commands() -> Result<()> { + for command in ["build", "serve"] { + for (flag, expected) in [ + ("--show=all", ShowMode::All), + ("--show=content", ShowMode::Content), + ] { + let cli = Cli::try_parse_from(["webui-press", command, flag])?; + let (Commands::Build { show, .. } | Commands::Serve { show, .. }) = cli.command; + assert_eq!(show, Some(expected)); + } + let cli = Cli::try_parse_from(["webui-press", command])?; + let (Commands::Build { show, .. } | Commands::Serve { show, .. }) = cli.command; + assert_eq!(show, None); + assert!(Cli::try_parse_from(["webui-press", command, "--show=invalid"]).is_err()); + } + Ok(()) + } + #[test] fn embedded_assets_extract_template_and_components() -> Result<()> { let template = extract_embedded_assets()?; diff --git a/crates/webui-press/src/serve.rs b/crates/webui-press/src/serve.rs index cd8345cd..99485443 100644 --- a/crates/webui-press/src/serve.rs +++ b/crates/webui-press/src/serve.rs @@ -37,7 +37,7 @@ use webui_dev_server::{ }; use crate::build::{build_docs_with_cache, BuildCache}; -use crate::types::DocsConfig; +use crate::types::{DocsConfig, ShowMode}; /// Filesystem-event debounce window. Editors often save in multiple bursts; /// a single rebuild per burst feels right. @@ -55,6 +55,8 @@ pub struct ServeConfig { pub config_path: PathBuf, pub host: String, pub port: u16, + /// Explicit CLI display-mode override, reapplied after each config reload. + pub show_override: Option, } /// Run the dev server until interrupted. @@ -66,6 +68,7 @@ pub async fn run_serve(opts: ServeConfig) -> Result<()> { config_path, host, port, + show_override, } = opts; let base_path = normalize_base_path(&config.base_path); // Match `build_docs` semantics: `out_dir`, `content_dir`, and @@ -95,7 +98,7 @@ pub async fn run_serve(opts: ServeConfig) -> Result<()> { // amortizing across rebuilds — every other build step runs from // scratch). let initial_cache: BuildCache = { - let cfg = clone_config_via_reparse(&config_path)?; + let cfg = clone_config_via_reparse(&config_path, show_override)?; let cd = config_dir.clone(); let td = template_dir.clone(); tokio::task::spawn_blocking(move || -> Result { @@ -130,7 +133,7 @@ pub async fn run_serve(opts: ServeConfig) -> Result<()> { let template_dir = template_dir.clone(); let cache = cache.clone(); spawn_rebuild_worker(livereload.clone(), move || { - let cfg = clone_config_via_reparse(&config_path) + let cfg = clone_config_via_reparse(&config_path, show_override) .map_err(|e| format!("config reload failed: {e}"))?; let mut guard = cache .lock() @@ -284,10 +287,18 @@ fn projection_manifest_paths(config_dir: &Path, manifests: &[String]) -> Vec Result { +fn clone_config_via_reparse( + config_path: &Path, + show_override: Option, +) -> Result { let s = std::fs::read_to_string(config_path) .with_context(|| format!("Cannot read {}", config_path.display()))?; - serde_json::from_str(&s).with_context(|| format!("Invalid JSON in {}", config_path.display())) + let mut config: DocsConfig = serde_json::from_str(&s) + .with_context(|| format!("Invalid JSON in {}", config_path.display()))?; + if let Some(show) = show_override { + config.show = show; + } + Ok(config) } #[cfg(test)] diff --git a/crates/webui-press/src/state.rs b/crates/webui-press/src/state.rs index bb5dad4a..d29c3c1b 100644 --- a/crates/webui-press/src/state.rs +++ b/crates/webui-press/src/state.rs @@ -226,6 +226,7 @@ mod tests { fn empty_config() -> DocsConfig { DocsConfig { + show: Default::default(), site: SiteConfig { title: "Docs".to_string(), description: String::new(), diff --git a/crates/webui-press/src/types.rs b/crates/webui-press/src/types.rs index 781e1606..934efb68 100644 --- a/crates/webui-press/src/types.rs +++ b/crates/webui-press/src/types.rs @@ -3,11 +3,25 @@ use serde::Deserialize; +/// Which parts of the documentation site to generate. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, clap::ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum ShowMode { + /// Generate the complete site, including its navigation and other shell regions. + #[default] + All, + /// Generate only authored page content in a complete, shell-free document. + Content, +} + /// Documentation site configuration (read from config.json). #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocsConfig { pub site: SiteConfig, + /// Display mode, overridden by an explicit CLI `--show` value. + #[serde(default)] + pub show: ShowMode, pub base_path: String, pub content_dir: String, #[serde(default = "default_out_dir")] @@ -292,6 +306,23 @@ mod tests { use super::*; use std::collections::BTreeMap; + #[test] + fn show_mode_defaults_to_all_and_rejects_unknown_values() -> Result<(), serde_json::Error> { + let json = r#"{ + "site": {"title": "Docs"}, "basePath": "/", "contentDir": ".", + "nav": [], "sidebar": [] + }"#; + let config: DocsConfig = serde_json::from_str(json)?; + assert_eq!(config.show, ShowMode::All); + assert_eq!( + serde_json::from_str::("\"content\"")?, + ShowMode::Content + ); + assert_eq!(serde_json::from_str::("\"all\"")?, ShowMode::All); + assert!(serde_json::from_str::("\"invalid\"").is_err()); + Ok(()) + } + // --- HeadTag::to_html ------------------------------------------------ fn tag(name: &str, attrs: &[(&str, &str)], content: Option<&str>) -> HeadTag { diff --git a/crates/webui-press/template/content.html b/crates/webui-press/template/content.html new file mode 100644 index 00000000..a5c6abc3 --- /dev/null +++ b/crates/webui-press/template/content.html @@ -0,0 +1,39 @@ + + + + + + + + + + {{page.title}} | {{site.title}} + + + + + + + + {{{headTags}}} + + + +
+
+ {{{page.content}}} +
+
+ + + diff --git a/crates/webui-press/template/docs.css b/crates/webui-press/template/docs.css index e5f760e4..01cd1ba9 100644 --- a/crates/webui-press/template/docs.css +++ b/crates/webui-press/template/docs.css @@ -178,23 +178,12 @@ } } -html { - /* Keep browser chrome stable: docs pages scroll inside `.main-content` - while the sidebar keeps an independent scrollbar. */ - height: 100%; - overflow: hidden; -} - * { box-sizing: border-box; } body { - display: flex; - flex-direction: column; - height: 100%; margin: 0; - overflow: hidden; font-family: var(--docs-font-sans); color: var(--docs-color-text); background: var(--docs-color-bg); @@ -202,136 +191,6 @@ body { -webkit-font-smoothing: antialiased; } -.skip-link { - position: fixed; - top: var(--docs-space-s); - left: var(--docs-space-s); - z-index: 400; - padding: 10px var(--docs-space-m); - border-radius: var(--docs-radius-m); - background: var(--docs-btn-brand-bg); - color: var(--docs-btn-brand-text); - font-weight: var(--docs-font-weight-semibold); - text-decoration: none; - transform: translateY(calc(-100% - var(--docs-space-l))); - transition: transform 0.15s ease; -} - -.skip-link:focus { - transform: translateY(0); -} - -.sidebar, -.main-content { - scrollbar-color: var(--docs-scrollbar-thumb) var(--docs-scrollbar-track); -} - -.main-content:focus { - outline: none; -} - -.sidebar::-webkit-scrollbar, -.main-content::-webkit-scrollbar { - width: 12px; -} - -.sidebar::-webkit-scrollbar-track, -.main-content::-webkit-scrollbar-track { - background: var(--docs-scrollbar-track); -} - -.sidebar::-webkit-scrollbar-thumb, -.main-content::-webkit-scrollbar-thumb { - background: var(--docs-scrollbar-thumb); - border: 3px solid var(--docs-scrollbar-track); - border-radius: 999px; -} - -.sidebar::-webkit-scrollbar-thumb:hover, -.main-content::-webkit-scrollbar-thumb:hover { - background: var(--docs-scrollbar-thumb-hover); -} - -/* ── Navigation ────────────────────────────────────── */ - -.nav-bar { - position: sticky; - top: 0; - z-index: 100; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 var(--docs-space-l); - height: var(--docs-nav-height); - flex: 0 0 var(--docs-nav-height); - border-bottom: 1px solid var(--docs-color-border); - background: var(--docs-nav-bg); - backdrop-filter: var(--docs-nav-backdrop); - -webkit-backdrop-filter: var(--docs-nav-backdrop); -} - -.logo { - display: flex; - align-items: center; - gap: var(--docs-space-s); - text-decoration: none; - color: var(--docs-color-text); - font-weight: var(--docs-font-weight-semibold); - font-size: var(--docs-font-size-base); -} - -.logo img { - height: 28px; - width: 28px; -} - -/* ── Layout ────────────────────────────────────────── */ - -.layout { - display: flex; - flex: 1; - width: 100%; - max-width: var(--docs-max-width); - margin: 0 auto; - min-height: 0; -} - -/* ── Sidebar ───────────────────────────────────────── */ - -.sidebar { - width: var(--docs-sidebar-width); - padding: var(--docs-space-xl) var(--docs-space-l); - border-right: 1px solid var(--docs-color-border); - flex-shrink: 0; - position: sticky; - top: var(--docs-nav-height); - height: 100%; - overflow-y: auto; -} - -/* ── Main content ──────────────────────────────────── */ - -.main-content { - flex: 1; - min-width: 0; - min-height: 0; - height: 100%; - overflow-y: auto; - padding: var(--docs-space-xl) var(--docs-space-2xl) var(--docs-space-3xl); -} - -.doc-content, -.page-nav, -.main-content > .site-footer { - width: 100%; - max-width: var(--docs-content-max-width); - margin-inline: auto; -} - -.mobile-page-context { - display: none; -} - /* ── Article typography ─────────────────────────────── */ .doc-content h1 { @@ -510,255 +369,6 @@ body { background: var(--docs-color-bg-alt); } -/* ── Page navigation ───────────────────────────────── */ - -.page-nav { - display: flex; - justify-content: space-between; - margin-top: var(--docs-space-2xl); - padding-top: var(--docs-space-l); - padding-bottom: var(--docs-space-l); - border-top: 1px solid var(--docs-color-border); -} - -.page-nav a { - display: inline-flex; - align-items: center; - padding: var(--docs-space-s) var(--docs-space-m); - border-radius: var(--docs-radius-l); - text-decoration: none; - color: var(--docs-color-brand); - font-weight: var(--docs-font-weight-medium); - font-size: var(--docs-font-size-m); - border: 1px solid var(--docs-color-border); - transition: border-color 0.2s, background 0.2s; -} - -.page-nav a:hover { - border-color: var(--docs-color-brand); - background: var(--docs-color-brand-bg); -} - -/* ── Homepage ───────────────────────────────────────── */ - -.home-container { - max-width: 1152px; - margin: 0 auto; -} - -.home-hero { - text-align: center; - padding: var(--docs-space-l) 0 0; -} - -.home-hero h1 { - font-size: var(--docs-font-size-3xl); - font-weight: var(--docs-font-weight-extrabold); - letter-spacing: -0.03em; - color: var(--docs-color-text); - margin-bottom: var(--docs-space-m); -} - -.home-hero .hero-text { - font-size: var(--docs-font-size-2xl); - font-weight: var(--docs-font-weight-bold); - color: var(--docs-color-text); - max-width: 760px; - margin: 0 auto var(--docs-space-l); - line-height: 1.25; - letter-spacing: -0.01em; -} - -.home-hero .tagline { - font-size: var(--docs-font-size-lg); - color: var(--docs-color-text-2); - max-width: 600px; - margin: 0 auto var(--docs-space-xl); - line-height: 1.6; -} - -.home-actions { - display: flex; - gap: var(--docs-space-m); - justify-content: center; - flex-wrap: wrap; -} - -.home-actions a { - display: inline-flex; - align-items: center; - padding: 10px var(--docs-space-l); - border-radius: var(--docs-radius-l); - text-decoration: none; - font-weight: var(--docs-font-weight-semibold); - font-size: var(--docs-font-size-m); - transition: all 0.2s; -} - -.home-actions .btn-alt { - padding-inline: var(--docs-space-s); - border-color: transparent; - color: var(--docs-color-text-2); - font-weight: var(--docs-font-weight-medium); -} - -.home-actions .btn-alt:hover { - background: var(--docs-color-brand-bg); -} - -.btn-brand { - background: var(--docs-btn-brand-bg); - color: var(--docs-btn-brand-text); -} - -.btn-brand:hover { - background: var(--docs-btn-brand-hover-bg); -} - -.btn-alt { - border: 1px solid var(--docs-btn-alt-border); - color: var(--docs-btn-alt-text); - background: var(--docs-btn-alt-bg); -} - -.btn-alt:hover { - border-color: var(--docs-color-brand); - color: var(--docs-color-brand); -} - -.hero-manifesto { - text-align: center; - font-style: italic; - font-size: var(--docs-font-size-m); - color: var(--docs-color-text-3); - max-width: 720px; - margin: var(--docs-space-xl) auto 0; - line-height: 1.6; -} - -.features-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - column-gap: var(--docs-space-2xl); - padding: var(--docs-space-xl) 0 var(--docs-space-3xl); -} - -.feature-card { - padding: var(--docs-space-l) 0 var(--docs-space-xl); - border-top: 1px solid var(--docs-card-border); - transition: border-color 0.2s; -} - -.feature-card:hover { - border-color: var(--docs-color-brand); -} - -.feature-card-header { - display: flex; - align-items: center; - gap: var(--docs-space-m); - margin-bottom: var(--docs-space-s); -} - -.feature-icon { - display: inline-flex; - width: 28px; - height: 28px; - align-items: center; - justify-content: center; - color: var(--docs-color-brand); - font-size: var(--docs-font-size-xl); - font-weight: var(--docs-font-weight-semibold); - line-height: 1; -} - -.feature-card h2 { - font-size: var(--docs-font-size-base); - font-weight: var(--docs-font-weight-semibold); - margin: 0; -} - -.feature-card p { - font-size: var(--docs-font-size-m); - color: var(--docs-color-text-2); - line-height: 1.6; -} - -/* ── Responsive ─────────────────────────────────────── */ - -@media (max-width: 768px) { - .sidebar { - display: none; - } - - .main-content { - padding: var(--docs-space-l) var(--docs-space-m) var(--docs-space-2xl); - } - - .mobile-page-context { - display: flex; - align-items: center; - gap: var(--docs-space-s); - width: 100%; - max-width: var(--docs-content-max-width); - margin: 0 auto var(--docs-space-l); - color: var(--docs-color-text-2); - font-size: var(--docs-font-size-s); - } - - .mobile-page-context strong { - overflow: hidden; - color: var(--docs-color-text); - text-overflow: ellipsis; - white-space: nowrap; - } - - .home-hero h1 { - font-size: var(--docs-font-size-2xl); - } - - .home-actions { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--docs-space-s); - } - - .home-actions a { - min-height: 44px; - justify-content: center; - } - - .home-actions .btn-brand { - grid-column: 1 / -1; - } - - .home-actions .btn-alt { - padding-inline: var(--docs-space-xs); - } - - .features-grid { - grid-template-columns: 1fr; - } -} - -@media (max-width: 520px) { - .nav-bar { - padding-inline: var(--docs-space-m); - } - - .logo span { - display: none; - } - -} - -@media (min-width: 769px) and (max-width: 1024px) { - .features-grid { - grid-template-columns: repeat(2, 1fr); - column-gap: var(--docs-space-xl); - } -} - /* Admonitions */ .tip { border: 1px solid var(--docs-color-border); @@ -772,91 +382,6 @@ body { margin-bottom: 0; } -/* ── Footer ────────────────────────────────────────── */ - -.site-footer { - border-top: 1px solid var(--docs-color-border); - padding: var(--docs-space-l); - text-align: center; - font-size: var(--docs-font-size-s); - color: var(--docs-color-text-3); -} - -.site-footer a { - color: var(--docs-color-text-2); - text-decoration: none; -} - -.site-footer a:hover { - color: var(--docs-color-brand); -} - -/* ── Full layout ───────────────────────────────────── - Default layout is "doc" (sidebar + main content + footer). - "full" hides the chrome and lets the page content occupy the - entire viewport below the nav bar. Tailored for single-component - apps (e.g. the playground) — every doc-content child is stretched - to fill the viewport, page scrolling is disabled. - "page" is the same minus the viewport-fill: wide markdown without - sidebar/footer/page-nav, normal scroll behavior. - Shadow components can react to the same attribute via - `:host-context([data-layout="full"])`. */ - -body[data-layout="full"] { - overflow: hidden; -} - -body[data-layout="full"] .layout { - max-width: none; - min-height: 0; - height: auto; -} - -body[data-layout="full"] .sidebar, -body[data-layout="full"] .page-nav, -body[data-layout="full"] .site-footer, -body[data-layout="page"] .sidebar, -body[data-layout="page"] .page-nav { - display: none; -} - -body[data-layout="full"] .main-content { - padding: 0; - max-width: none; - height: 100%; - overflow: hidden; - display: flex; - flex-direction: column; -} - -body[data-layout="full"] .doc-content { - flex: 1; - width: 100%; - max-width: none; - min-height: 0; - display: flex; - flex-direction: column; -} - -body[data-layout="full"] .doc-content > * { - flex: 1; - min-height: 0; -} - -body[data-layout="page"] .layout { - max-width: none; -} - -body[data-layout="page"] .main-content { - max-width: none; -} - -body[data-layout="page"] .doc-content, -body[data-layout="page"] .page-nav, -body[data-layout="page"] .main-content > .site-footer { - max-width: none; -} - /* ── Heading anchors ───────────────────────────────── */ .header-anchor { @@ -895,4 +420,18 @@ h4 code, .doc-content h3 code, .doc-content h4 code { font-size: inherit; +} + +.press-content { + min-width: 0; + padding: var(--docs-space-m); +} + +.press-content > .doc-content { + display: flow-root; + overflow-wrap: anywhere; +} + +.press-content > .doc-content > :first-child { + margin-block-start: 0; } \ No newline at end of file diff --git a/crates/webui-press/template/index.html b/crates/webui-press/template/index.html index f6a1d00a..b8fbcdc7 100644 --- a/crates/webui-press/template/index.html +++ b/crates/webui-press/template/index.html @@ -36,8 +36,16 @@ } } - :root[data-theme="dark"] { - /*{{{tokens.dark}}}*/ + @media not (forced-colors: active) { + :root[data-theme="light"] { + color-scheme: light; + /*{{{tokens.light}}}*/ + } + + :root[data-theme="dark"] { + color-scheme: dark; + /*{{{tokens.dark}}}*/ + } } {{{headTags}}} diff --git a/crates/webui-press/template/shell.css b/crates/webui-press/template/shell.css new file mode 100644 index 00000000..dee4d9c5 --- /dev/null +++ b/crates/webui-press/template/shell.css @@ -0,0 +1,453 @@ +/* Copyright (c) Microsoft Corporation. */ +/* Licensed under the MIT license. */ + +/* Only the complete site uses independent main/sidebar scrolling. */ +html { + height: 100%; + overflow: hidden; +} + +body { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.skip-link { + position: fixed; + top: var(--docs-space-s); + left: var(--docs-space-s); + z-index: 400; + padding: 10px var(--docs-space-m); + border-radius: var(--docs-radius-m); + background: var(--docs-btn-brand-bg); + color: var(--docs-btn-brand-text); + font-weight: var(--docs-font-weight-semibold); + text-decoration: none; + transform: translateY(calc(-100% - var(--docs-space-l))); + transition: transform 0.15s ease; +} + +.skip-link:focus { + transform: translateY(0); +} + +.sidebar, +.main-content { + scrollbar-color: var(--docs-scrollbar-thumb) var(--docs-scrollbar-track); +} + +.main-content:focus { + outline: none; +} + +.sidebar::-webkit-scrollbar, +.main-content::-webkit-scrollbar { + width: 12px; +} + +.sidebar::-webkit-scrollbar-track, +.main-content::-webkit-scrollbar-track { + background: var(--docs-scrollbar-track); +} + +.sidebar::-webkit-scrollbar-thumb, +.main-content::-webkit-scrollbar-thumb { + background: var(--docs-scrollbar-thumb); + border: 3px solid var(--docs-scrollbar-track); + border-radius: 999px; +} + +.sidebar::-webkit-scrollbar-thumb:hover, +.main-content::-webkit-scrollbar-thumb:hover { + background: var(--docs-scrollbar-thumb-hover); +} + +.nav-bar { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--docs-space-l); + height: var(--docs-nav-height); + flex: 0 0 var(--docs-nav-height); + border-bottom: 1px solid var(--docs-color-border); + background: var(--docs-nav-bg); + backdrop-filter: var(--docs-nav-backdrop); + -webkit-backdrop-filter: var(--docs-nav-backdrop); +} + +.logo { + display: flex; + align-items: center; + gap: var(--docs-space-s); + text-decoration: none; + color: var(--docs-color-text); + font-weight: var(--docs-font-weight-semibold); + font-size: var(--docs-font-size-base); +} + +.logo img { + height: 28px; + width: 28px; +} + +.layout { + display: flex; + flex: 1; + width: 100%; + max-width: var(--docs-max-width); + margin: 0 auto; + min-height: 0; +} + +.sidebar { + width: var(--docs-sidebar-width); + padding: var(--docs-space-xl) var(--docs-space-l); + border-right: 1px solid var(--docs-color-border); + flex-shrink: 0; + position: sticky; + top: var(--docs-nav-height); + height: 100%; + overflow-y: auto; +} + +.main-content { + flex: 1; + min-width: 0; + min-height: 0; + height: 100%; + overflow-y: auto; + padding: var(--docs-space-xl) var(--docs-space-2xl) var(--docs-space-3xl); +} + +.doc-content, +.page-nav, +.main-content > .site-footer { + width: 100%; + max-width: var(--docs-content-max-width); + margin-inline: auto; +} + +.mobile-page-context { + display: none; +} + +.page-nav { + display: flex; + justify-content: space-between; + margin-top: var(--docs-space-2xl); + padding-top: var(--docs-space-l); + padding-bottom: var(--docs-space-l); + border-top: 1px solid var(--docs-color-border); +} + +.page-nav a { + display: inline-flex; + align-items: center; + padding: var(--docs-space-s) var(--docs-space-m); + border-radius: var(--docs-radius-l); + text-decoration: none; + color: var(--docs-color-brand); + font-weight: var(--docs-font-weight-medium); + font-size: var(--docs-font-size-m); + border: 1px solid var(--docs-color-border); + transition: border-color 0.2s, background 0.2s; +} + +.page-nav a:hover { + border-color: var(--docs-color-brand); + background: var(--docs-color-brand-bg); +} + +.home-container { + max-width: 1152px; + margin: 0 auto; +} + +.home-hero { + text-align: center; + padding: var(--docs-space-l) 0 0; +} + +.home-hero h1 { + font-size: var(--docs-font-size-3xl); + font-weight: var(--docs-font-weight-extrabold); + letter-spacing: -0.03em; + color: var(--docs-color-text); + margin-bottom: var(--docs-space-m); +} + +.home-hero .hero-text { + font-size: var(--docs-font-size-2xl); + font-weight: var(--docs-font-weight-bold); + color: var(--docs-color-text); + max-width: 760px; + margin: 0 auto var(--docs-space-l); + line-height: 1.25; + letter-spacing: -0.01em; +} + +.home-hero .tagline { + font-size: var(--docs-font-size-lg); + color: var(--docs-color-text-2); + max-width: 600px; + margin: 0 auto var(--docs-space-xl); + line-height: 1.6; +} + +.home-actions { + display: flex; + gap: var(--docs-space-m); + justify-content: center; + flex-wrap: wrap; +} + +.home-actions a { + display: inline-flex; + align-items: center; + padding: 10px var(--docs-space-l); + border-radius: var(--docs-radius-l); + text-decoration: none; + font-weight: var(--docs-font-weight-semibold); + font-size: var(--docs-font-size-m); + transition: all 0.2s; +} + +.home-actions .btn-alt { + padding-inline: var(--docs-space-s); + border-color: transparent; + color: var(--docs-color-text-2); + font-weight: var(--docs-font-weight-medium); +} + +.home-actions .btn-alt:hover { + background: var(--docs-color-brand-bg); +} + +.btn-brand { + background: var(--docs-btn-brand-bg); + color: var(--docs-btn-brand-text); +} + +.btn-brand:hover { + background: var(--docs-btn-brand-hover-bg); +} + +.btn-alt { + border: 1px solid var(--docs-btn-alt-border); + color: var(--docs-btn-alt-text); + background: var(--docs-btn-alt-bg); +} + +.btn-alt:hover { + border-color: var(--docs-color-brand); + color: var(--docs-color-brand); +} + +.hero-manifesto { + text-align: center; + font-style: italic; + font-size: var(--docs-font-size-m); + color: var(--docs-color-text-3); + max-width: 720px; + margin: var(--docs-space-xl) auto 0; + line-height: 1.6; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: var(--docs-space-2xl); + padding: var(--docs-space-xl) 0 var(--docs-space-3xl); +} + +.feature-card { + padding: var(--docs-space-l) 0 var(--docs-space-xl); + border-top: 1px solid var(--docs-card-border); + transition: border-color 0.2s; +} + +.feature-card:hover { + border-color: var(--docs-color-brand); +} + +.feature-card-header { + display: flex; + align-items: center; + gap: var(--docs-space-m); + margin-bottom: var(--docs-space-s); +} + +.feature-icon { + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + color: var(--docs-color-brand); + font-size: var(--docs-font-size-xl); + font-weight: var(--docs-font-weight-semibold); + line-height: 1; +} + +.feature-card h2 { + font-size: var(--docs-font-size-base); + font-weight: var(--docs-font-weight-semibold); + margin: 0; +} + +.feature-card p { + font-size: var(--docs-font-size-m); + color: var(--docs-color-text-2); + line-height: 1.6; +} + +@media (max-width: 768px) { + .sidebar { + display: none; + } + + .main-content { + padding: var(--docs-space-l) var(--docs-space-m) var(--docs-space-2xl); + } + + .mobile-page-context { + display: flex; + align-items: center; + gap: var(--docs-space-s); + width: 100%; + max-width: var(--docs-content-max-width); + margin: 0 auto var(--docs-space-l); + color: var(--docs-color-text-2); + font-size: var(--docs-font-size-s); + } + + .mobile-page-context strong { + overflow: hidden; + color: var(--docs-color-text); + text-overflow: ellipsis; + white-space: nowrap; + } + + .home-hero h1 { + font-size: var(--docs-font-size-2xl); + } + + .home-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--docs-space-s); + } + + .home-actions a { + min-height: 44px; + justify-content: center; + } + + .home-actions .btn-brand { + grid-column: 1 / -1; + } + + .home-actions .btn-alt { + padding-inline: var(--docs-space-xs); + } + + .features-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 520px) { + .nav-bar { + padding-inline: var(--docs-space-m); + } + + .logo span { + display: none; + } +} + +@media (min-width: 769px) and (max-width: 1024px) { + .features-grid { + grid-template-columns: repeat(2, 1fr); + column-gap: var(--docs-space-xl); + } +} + +.site-footer { + border-top: 1px solid var(--docs-color-border); + padding: var(--docs-space-l); + text-align: center; + font-size: var(--docs-font-size-s); + color: var(--docs-color-text-3); +} + +.site-footer a { + color: var(--docs-color-text-2); + text-decoration: none; +} + +.site-footer a:hover { + color: var(--docs-color-brand); +} + +/* Full layout fills the viewport below navigation; page layout is wide + markdown with ordinary main-content scrolling. */ +body[data-layout="full"] { + overflow: hidden; +} + +body[data-layout="full"] .layout { + max-width: none; + min-height: 0; + height: auto; +} + +body[data-layout="full"] .sidebar, +body[data-layout="full"] .page-nav, +body[data-layout="full"] .site-footer, +body[data-layout="page"] .sidebar, +body[data-layout="page"] .page-nav { + display: none; +} + +body[data-layout="full"] .main-content { + padding: 0; + max-width: none; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} + +body[data-layout="full"] .doc-content { + flex: 1; + width: 100%; + max-width: none; + min-height: 0; + display: flex; + flex-direction: column; +} + +body[data-layout="full"] .doc-content > * { + flex: 1; + min-height: 0; +} + +body[data-layout="page"] .layout { + max-width: none; +} + +body[data-layout="page"] .main-content { + max-width: none; +} + +body[data-layout="page"] .doc-content, +body[data-layout="page"] .page-nav, +body[data-layout="page"] .main-content > .site-footer { + max-width: none; +} diff --git a/crates/webui-press/tests/native-fixture.ts b/crates/webui-press/tests/native-fixture.ts new file mode 100644 index 00000000..6d755694 --- /dev/null +++ b/crates/webui-press/tests/native-fixture.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { buildSync } from 'esbuild'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const workspace = path.resolve(import.meta.dirname, '../../../..'); +export const binary = process.env.WEBUI_PRESS_BINARY ?? + path.join(workspace, 'target/debug', process.platform === 'win32' ? 'webui-press.exe' : 'webui-press'); + +export function write(file: string, content: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +export function fixture() { + const parent = path.join(workspace, 'crates/webui-press/dist-test/sites'); + fs.mkdirSync(parent, { recursive: true }); + const root = fs.mkdtempSync(path.join(parent, 'show-mode-')); + const site = path.join(root, 'site'); + const pkg = path.join(root, 'node_modules/@fixture/catalog'); + const configFile = path.join(site, '.webui-press/config.json'); + const template = path.join(pkg, 'components/test-catalog-button/test-catalog-button'); + write(`${template}.html`, + ''); + write(`${template}.css`, ':host { display: block; } button { font: inherit; }'); + write(`${template}.json`, '{"description":"Synthetic catalog metadata"}'); + write(`${template}.spec.ts`, 'throw new Error("Specs must not be bundled");'); + write(`${template}.ts`, ` + import { WebUIElement, attr, observable } from '@microsoft/webui-framework'; + export class CatalogButton extends WebUIElement { + @attr label = ''; + @observable count = 0; + increment() { this.count += 1; } + } + CatalogButton.define('test-catalog-button'); + `); + buildSync({ + entryPoints: [`${template}.ts`], + outfile: path.join(pkg, 'dist/components/test-catalog-button/test-catalog-button.js'), + format: 'esm', + tsconfigRaw: { compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false } }, + }); + write(path.join(pkg, 'package.json'), JSON.stringify({ + name: '@fixture/catalog', version: '1.0.0', type: 'module', + customElements: '../must-not-read.json', + exports: { + './button.js': './dist/components/test-catalog-button/test-catalog-button.js', + './template-webui.html': '../must-not-read.html', + }, + })); + const text = path.join(pkg, 'components/test-catalog-text/test-catalog-text'); + write(`${text}.html`, '{{catalogMessage}} '); + write(`${text}.css`, ':host { display: block; }'); + write(`${text}.json`, '{"description":"Scriptless catalog component"}'); + write(`${text}.md`, '# Scriptless catalog component'); + write(`${text}.spec.ts`, 'throw new Error("Specs are not component scripts");'); + for (const name of ['webui', 'webui-framework']) { + const link = path.join(site, 'node_modules/@microsoft', name); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(path.join(workspace, 'packages', name), link, 'junction'); + } + const examples = ` + + +Slotted example +Static slot + +## API + +| Property | Default | +| --- | --- | +| count | 2 | + +\`\`\`html + +\`\`\` + + +`; + for (const layout of ['doc', 'page', 'full', 'home']) { + write(path.join(site, 'content', `${layout}.md`), + `---\nlayout: ${layout}\ntitle: ${layout} example\ndescription: Example metadata\n---\n\n# ${layout} example\n${examples}`); + } + write(path.join(site, 'content/local/edge-hub-header/edge-hub-header.html'), + '
Authored header example
'); + write(path.join(site, 'content/local/edge-side-pane/edge-side-pane.html'), + ''); + write(path.join(site, 'theme.css'), ':root { --docs-color-brand: #0067b8; }'); + write(path.join(site, 'public/logo.svg'), + fs.readFileSync(path.join(workspace, 'docs/.webui-press/public/logo.svg'), 'utf8')); + write(path.join(site, 'shell.ts'), 'throw new Error("Shell region must be inactive");'); + write(path.join(site, '.webui-press/components/fixture-preview/preview.ts'), + 'document.documentElement.dataset.galleryAlias = "ready";'); + const config = { + site: { title: 'Content gallery' }, basePath: '/fixture/', + contentDir: './content', outDir: './dist', publicDir: './public', + components: ['@fixture/catalog', './content/local'], + // Projection analyzes original decorators, before JavaScript transformation. + bundler: { alias: { + '@fixture/catalog/button.js': `${template}.ts`, + '#gallery': './components/fixture-preview', + } }, + css: './theme.css', state: { count: 2, catalogMessage: 'Static catalog content' }, nav: [], sidebar: [], + head: [{ tag: 'meta', attrs: { name: 'fixture-head', content: 'preserved' } }], + footer: { html: 'Press footer' }, + customPages: { '/custom': { html: '

Custom example

' + examples, layout: 'full' } }, + regions: { 'site.announcement': { html: '

Shell announcement

', scriptFile: '../shell.ts' } }, + }; + write(configFile, JSON.stringify(config)); + return { root, site, configFile, config }; +} + +export function build(site: string, ...args: string[]): void { + execFileSync(binary, ['build', ...args], { cwd: site, stdio: 'pipe', timeout: 60_000 }); +} diff --git a/crates/webui-press/tests/regions_build.rs b/crates/webui-press/tests/regions_build.rs index 2f231c90..79212e65 100644 --- a/crates/webui-press/tests/regions_build.rs +++ b/crates/webui-press/tests/regions_build.rs @@ -6,7 +6,7 @@ use std::path::Path; use std::process::Command; use serde_json::{Map, Value}; -use webui_docs::{build_docs, DocsConfig}; +use webui_docs::{build_docs, DocsConfig, ShowMode}; type TestResult = Result>; @@ -74,7 +74,7 @@ fn builds_layout_scoped_regions_for_pages_and_404() -> TestResult { "globalThis.__docRegionScript = true;", )?; - let config: DocsConfig = serde_json::from_value(object([ + let mut config: DocsConfig = serde_json::from_value(object([ ("site", object([("title", string("Regions"))])), ("basePath", string("/fixture/")), ("contentDir", path_value(&content_dir)), @@ -127,6 +127,36 @@ fn builds_layout_scoped_regions_for_pages_and_404() -> TestResult { assert!(!doc_script.contains("__homeRegionScript")); assert!(not_found_script.contains("__docRegionScript")); + config.show = ShowMode::Content; + build_docs(&config, &root, &template_dir)?; + for page in ["index.html", "guide/index.html", "404.html"] { + let raw = fs::read_to_string(out_dir.join(page))?; + let html = html_escape::decode_html_entities(&raw); + assert!(html.contains("
Home "), + "home markdown must be rendered" + ); + let css = fs::read_to_string(out_dir.join("docs.css"))?; + assert!(!css.contains("overflow: hidden")); + assert!(!css.contains(".main-content")); + + config.regions.insert( + "unknown".to_string(), + serde_json::from_str(r#"{"html": "

Unknown

"}"#)?, + ); + let error = build_docs(&config, &root, &template_dir) + .err() + .ok_or("unknown region must still fail in content mode")?; + assert!(error.to_string().contains("does not declare it")); + fs::remove_dir_all(root).ok(); Ok(()) } @@ -147,6 +177,45 @@ fn ensure_projection_package(workspace: &Path) -> TestResult { Ok(()) } +#[test] +fn not_found_build_preserves_underlying_parser_error() -> TestResult { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!("not-found-diagnostic-{}", std::process::id())); + let template = root.join("template"); + let content = root.join("content"); + fs::create_dir_all(&template)?; + fs::create_dir_all(&content)?; + fs::write( + template.join("index.html"), + concat!( + "", + "", + "", + "
{{{page.content}}}
" + ), + )?; + fs::write(content.join("index.md"), "---\nlayout: home\n---\n# Home")?; + let config: DocsConfig = serde_json::from_value(object([ + ("site", object([("title", string("Diagnostics"))])), + ("basePath", string("/")), + ("contentDir", path_value(&content)), + ("outDir", path_value(&root.join("dist"))), + ("publicDir", path_value(&root.join("public"))), + ("nav", Value::Array(Vec::new())), + ("sidebar", Value::Array(Vec::new())), + ]))?; + let error = build_docs(&config, &root, &template) + .err() + .ok_or("invalid 404 template must fail")?; + let message = error.to_string(); + assert!(message.contains("404 build failed: Failed to parse index.html")); + assert!(message.contains(":value complex binding is only allowed on custom elements")); + assert!(message.contains("Use value=")); + fs::remove_dir_all(root)?; + Ok(()) +} + fn write_component(root: &Path, name: &str, html: &str) -> TestResult { let dir = root.join(name); fs::create_dir_all(&dir)?; diff --git a/crates/webui-press/tests/show-mode.test.ts b/crates/webui-press/tests/show-mode.test.ts new file mode 100644 index 00000000..59dae21d --- /dev/null +++ b/crates/webui-press/tests/show-mode.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { chromium, expect } from '@playwright/test'; +import type { Browser } from '@playwright/test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import test from 'node:test'; +import { binary, build, fixture, write } from './native-fixture.js'; + +const shell = '.nav-bar, docs-site-navigation, docs-sidebar-navigation, docs-search, ' + + 'docs-theme-toggle, .sidebar, .mobile-page-context, .page-nav, .site-footer, .home-hero'; + +function pageSnapshot(html: string): { document: string; bootstrap: object; modules: string[] } { + // Compare bootstrap objects semantically, not by published 0.0.28's + // unordered serialization/registration. CSS closures and document markup + // stay ordered; bundled module identities are compared separately. + const marker = '', start); + assert.ok(end > start); + const data: unknown = JSON.parse(html.slice(start, end)); + assert.ok(data && typeof data === 'object' && 'css' in data && Array.isArray(data.css)); + data.css.sort(); + return { + document: html.slice(0, start), + bootstrap: data, + modules: Array.from(html.matchAll(/