From d324da518c830f1818fa0e7ddb70eece5992716b Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Thu, 10 Sep 2026 19:56:37 -0700 Subject: [PATCH 1/3] feat: add Press content mode and plugin-aware discovery Generate shell-free documentation without losing SSR and hydration. Keep native component names filename-based and resolve FAST packages through their manifests with ordinary HTML fallback. BREAKING CHANGE: default component discovery no longer derives names or template paths from CEM metadata or template/style exports. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + DESIGN.md | 136 ++++- crates/webui-cli/src/commands/build.rs | 48 +- crates/webui-discovery/README.md | 20 + crates/webui-discovery/src/catalog.rs | 168 ++++++ crates/webui-discovery/src/lib.rs | 1 + crates/webui-discovery/src/npm.rs | 310 ++++------- .../src/{plugin.rs => plugin/fast.rs} | 333 ++++++------ .../webui-discovery/src/plugin/fast/README.md | 35 ++ .../webui-discovery/src/plugin/fast/tests.rs | 46 ++ crates/webui-discovery/src/plugin/mod.rs | 101 ++++ crates/webui-discovery/tests/catalog.rs | 351 +++++++++++++ crates/webui-discovery/tests/scopes.rs | 205 ++++++++ crates/webui-press/README.md | 27 + crates/webui-press/src/build.rs | 48 +- crates/webui-press/src/bundler.rs | 29 +- crates/webui-press/src/content.rs | 4 +- crates/webui-press/src/lib.rs | 2 +- crates/webui-press/src/main.rs | 71 ++- crates/webui-press/src/serve.rs | 21 +- crates/webui-press/src/state.rs | 1 + crates/webui-press/src/types.rs | 31 ++ crates/webui-press/template/content.html | 39 ++ crates/webui-press/template/docs.css | 489 +----------------- crates/webui-press/template/index.html | 12 +- crates/webui-press/template/shell.css | 453 ++++++++++++++++ crates/webui-press/tests/native-fixture.ts | 121 +++++ crates/webui-press/tests/regions_build.rs | 73 ++- crates/webui-press/tests/show-mode.test.ts | 243 +++++++++ crates/webui-press/tests/theme-mode.test.ts | 179 +++++++ crates/webui/src/tests/fast.rs | 88 ++++ docs/ai.md | 25 + docs/guide/cli/index.md | 47 +- docs/guide/concepts/components/index.md | 101 ++-- docs/guide/webui-press.md | 47 +- 35 files changed, 2887 insertions(+), 1019 deletions(-) create mode 100644 crates/webui-discovery/src/catalog.rs rename crates/webui-discovery/src/{plugin.rs => plugin/fast.rs} (56%) create mode 100644 crates/webui-discovery/src/plugin/fast/README.md create mode 100644 crates/webui-discovery/src/plugin/fast/tests.rs create mode 100644 crates/webui-discovery/src/plugin/mod.rs create mode 100644 crates/webui-discovery/tests/catalog.rs create mode 100644 crates/webui-discovery/tests/scopes.rs create mode 100644 crates/webui-press/template/content.html create mode 100644 crates/webui-press/template/shell.css create mode 100644 crates/webui-press/tests/native-fixture.ts create mode 100644 crates/webui-press/tests/show-mode.test.ts create mode 100644 crates/webui-press/tests/theme-mode.test.ts diff --git a/.gitignore b/.gitignore index ad88726fa..cb623a22b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ docs/.vitepress/cache/ .turbo/ .memory/ test-results/ +.DS_Store # Staged native binaries (populated by cargo xtask publish-stage) dotnet/runtimes/ diff --git a/DESIGN.md b/DESIGN.md index 9977b3102..93d80c7d8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1541,29 +1541,36 @@ pub struct DiscoveredComponent { ``` #### npm Package Resolution -1. Walk up from the search directory to find `node_modules/` (Node.js-style resolution) -2. For scoped packages (`@scope`), enumerate all sub-directories -3. For each package, read `package.json`: - - `exports["./template-webui.html"]` → template HTML path - - `exports["./styles.css"]` → styles CSS path (optional) - - `customElements` → path to Custom Elements Manifest - - root JS entry (`exports["."]`, `main`, `module`, or `browser`) → authored component ownership -4. Parse the Custom Elements Manifest for `modules[].declarations[].tagName` -5. Return `DiscoveredComponent` structs with `is_client_owned` set from source metadata (callers handle registration) - -Conditional exports are resolved with deterministic priority: `default` → `import` → `require`. - -Script ownership is metadata-only: discovery never scans package JavaScript to find -`customElements.define()` calls. Packages without a root JS entry are treated as -compiler-owned template libraries. Packages with a root JS entry own their custom -elements and are never replaced by compiler-owned hosts. Package source is not -scanned by Rust. If the package is bundled with the application, the bundler -projection adapter analyzes its source and includes it in the application -manifest; external/separately built packages provide their own fragment. +1. Walk up from the search directory to find the requested package or scope in + `node_modules/` (Node.js-style resolution), then fall back to the process + working directory for synthesized app roots. An unrelated nearer + `node_modules/` does not hide packages installed in ancestors. A found but + invalid package fails rather than falling back to a different installation. +2. For bare scopes (`@scope`), enumerate sub-packages in the nearest matching + scope directory, in filename order. `supports_package` identifies unrelated + packages that may be skipped. Errors from declared component packages propagate + with the failing scope member's name; they are never silently dropped. + A trailing `/*` is a collection spelling: `@scope/*` resolves the scope and + `@scope/package/*` resolves that package. It is normalized before npm lookup, + not applied to local filesystem sources. +3. Read `package.json` and delegate the canonical package root to the selected + discovery plugin. +4. Default WebUI scans `components/` when present, otherwise the package root, + deriving component names only from hyphenated `.html` filenames. + Matching CSS and TS/JS siblings provide styling and authored ownership. + It does not interpret template/style exports or Custom Elements Manifest names. +5. FAST retains its separate `customElements` manifest and special template/style + resolution, including package-metadata-based script ownership. +6. Return `DiscoveredComponent` structs; callers handle registration. + +Discovery does not scan package JavaScript for `customElements.define()` calls. +For bundled authored components, the projection adapter analyzes their source +and includes it in the application manifest; external/separately built packages +provide their own fragment. #### Security -- **Path traversal**: Export paths are validated — absolute paths and `..` components are rejected -- **Symlink resolution**: Package symlinks are resolved via `fs::canonicalize()` to support pnpm, npm workspaces, and yarn link layouts. Path traversal safety is enforced on `package.json` export paths (not on the symlink target) +- **Path traversal**: FAST manifest and asset-export paths are validated; absolute paths and `..` components are rejected. Native discovery never follows template/CEM export paths. +- **Symlink resolution**: Package symlinks are resolved via `fs::canonicalize()` to support pnpm, npm workspaces, and yarn link layouts. Metadata path validation does not restrict the package symlink target. - **File size limits**: Manifests and templates are capped at 10 MB to prevent denial-of-service #### Discovery Cache @@ -1937,10 +1944,14 @@ package-relative manifest paths, determines client ownership from package metadata, and owns cache invalidation. The selected discovery plugin maps that validated root to normalized `DiscoveredComponent` values: +`plugin/mod.rs` owns the discovery contract and default filename-based behavior. +`plugin/fast.rs` contains FAST's special naming, manifest, and style rules. + ```rust pub trait DiscoveryPlugin { fn cache_namespace(&self) -> &'static str; fn discover_local(&self, root: &Path) -> Result>; + fn supports_package(&self, package: PackageContext<'_>) -> Result; fn package_cache_files( &self, package: PackageContext<'_>, @@ -1966,13 +1977,38 @@ invalidates the cache. Cache writes use process- and sequence-qualified temporary paths before atomic rename, preventing concurrent builds from clobbering one another. -`WebUIDiscoveryPlugin` preserves the native layout: hyphenated local -`.html` files and npm packages exporting -`./template-webui.html`, optionally `./styles.css`, with tag names supplied by -`customElements`. `FastDiscoveryPlugin` also admits local -`.template.html` files. For npm packages, it reads CEM module -declarations and first maps each declaration to a sibling -`.template.html`. If the declared module is virtual (the package +`supports_package` defaults to `true` for custom plugins. In scoped searches, +default discovery claims packages with named HTML templates; FAST claims packages +with a `customElements` field or ordinary named HTML sources. Explicit package requests still diagnose missing +component inputs rather than returning an empty success. + +`WebUIDiscoveryPlugin` uses hyphenated `.html` filenames for both local +and npm sources. Native npm packages use `components/` as their source root when +present, otherwise the package root. Traversal is filename-sorted and skips hidden +directories and nested `node_modules`; directory names do not determine tags. +Selecting `components/` excludes duplicate artifacts elsewhere in the package. +Template/style exports and CEM naming are not part of default discovery. +Ownership is component-local: a matching `.ts` or `.js` +sibling makes that component authored; package-level JS exports do not make +unrelated scriptless components authored. `.spec.ts`, documentation, and JSON +sidecars do not imply authored code. Scriptless catalog components retain normal +compiler-owned SSR and do not require a projection entry. Native discovery uses +a `webui-filenames` cache namespace so legacy metadata-derived names and ownership +are never reused. This namespace is internal, not a framework or plugin version. +The current template list and all CSS/TS/JS candidates participate in the cache +fingerprint, including missing optional files. +`FastDiscoveryPlugin` also admits local +`.template.html` files. For single-component npm packages, a +`./template.html` export selects the standard template relative to the canonical +package root; `./styles.css` may select its stylesheet independently of the JS +module location. Export values accept strings or deterministic +`default`/`import`/`require` conditions. The `./template-webui.html` export is not +used. A package-level template export must have exactly one CEM component. +Invalid or missing declared assets are errors, never fallbacks to another file. +Selected exports and inferred optional styles participate in cache invalidation. + +Without a package-level template export, FAST reads CEM module declarations and +maps each declaration to a sibling `.template.html`. If the declared module is virtual (the package does not contain that JavaScript path), discovery also checks component-root directories derived from the class name: kebab-case, compact lowercase, then the terminal class noun. Candidate priority is deterministic and every @@ -1981,6 +2017,15 @@ candidate participates in cache invalidation. Discovery associates subsequently resolves the final registry key from the authored ``. FAST 2 and FAST 3 share this discovery layout and retain separate parser and handler behavior. +If those module-local candidates do not exist, FAST also tries standard +module/class-named templates in ancestor directories bounded by the package root. +The CEM is the declared-component inventory; template exports are only location +hints. FAST adds ordinary named HTML files not covered by that inventory using +default CSS/script-sibling rules. Missing or empty CEM inventory enables this +fallback; malformed metadata and missing declared assets remain errors. +Declared tags win conflicts, and generated `.template.html`/`.template-webui.html` +files are excluded from the ordinary fallback. Cache inputs include the selected +ordinary files and their CSS/TS/JS siblings. `discover_source` remains the WebUI-native convenience API. `discover_source_with_plugin` selects another layout. Cache keys include the @@ -4875,8 +4920,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-cli/src/commands/build.rs b/crates/webui-cli/src/commands/build.rs index 993e48a7e..4a4e25a26 100644 --- a/crates/webui-cli/src/commands/build.rs +++ b/crates/webui-cli/src/commands/build.rs @@ -969,36 +969,15 @@ mod tests { // Create the npm package files fs::write( - pkg_dir.join("template-webui.html"), + pkg_dir.join("test-widget.html"), r#""#, ) .unwrap(); - fs::write(pkg_dir.join("styles.css"), ".btn { padding: 4px; }").unwrap(); - - let manifest = serde_json::json!({ - "schemaVersion": "1.0.0", - "modules": [{ - "kind": "javascript-module", - "declarations": [{ - "kind": "class", - "tagName": "test-widget" - }] - }] - }); - fs::write( - pkg_dir.join("custom-elements.json"), - serde_json::to_string(&manifest).unwrap(), - ) - .unwrap(); + fs::write(pkg_dir.join("test-widget.css"), ".btn { padding: 4px; }").unwrap(); let pkg_json = serde_json::json!({ "name": "test-widget", - "version": "1.0.0", - "customElements": "./custom-elements.json", - "exports": { - "./template-webui.html": "./template-webui.html", - "./styles.css": "./styles.css" - } + "version": "1.0.0" }); fs::write( pkg_dir.join("package.json"), @@ -1069,28 +1048,11 @@ mod tests { let pkg_dir = scope_dir.join(sub); fs::create_dir_all(&pkg_dir).unwrap(); - fs::write(pkg_dir.join("template-webui.html"), html).unwrap(); - - let manifest = serde_json::json!({ - "schemaVersion": "1.0.0", - "modules": [{ - "kind": "javascript-module", - "declarations": [{ "kind": "class", "tagName": tag }] - }] - }); - fs::write( - pkg_dir.join("custom-elements.json"), - serde_json::to_string(&manifest).unwrap(), - ) - .unwrap(); + fs::write(pkg_dir.join(format!("{tag}.html")), html).unwrap(); let pkg_json = serde_json::json!({ "name": format!("@myui/{sub}"), - "version": "1.0.0", - "customElements": "./custom-elements.json", - "exports": { - "./template-webui.html": "./template-webui.html" - } + "version": "1.0.0" }); fs::write( pkg_dir.join("package.json"), diff --git a/crates/webui-discovery/README.md b/crates/webui-discovery/README.md index 9ceec2d5a..9f6f8ea56 100644 --- a/crates/webui-discovery/README.md +++ b/crates/webui-discovery/README.md @@ -7,9 +7,29 @@ can implement `DiscoveryPlugin` and call `discover_source_with_plugin` to map a different validated package layout into the same `DiscoveredComponent` runtime contract. Built-in WebUI and FAST discovery plugins are provided. +Default WebUI discovery uses `.html`: the filename is the +custom element name. npm packages are scanned beneath `components/` when +present, otherwise beneath the package root. Nested directories are supported; +their names do not determine component names. Matching `.css` files provide +styles and matching `.ts`/`.js` siblings mark authored components. + +Template/style exports and Custom Elements Manifest names are not interpreted +by default discovery. Browser registrations are imported through package module +exports separately. `.spec.ts` files do not make a component scripted or require +a projection entry. + +Named packages are resolved from each ancestor's `node_modules`, so a nearer +directory containing unrelated dependencies does not hide an installed catalog. +Bare scopes use the same lookup, selecting the nearest matching scope directory. +Scope searches skip unrelated packages but report failures in packages that +declare components. +The collection spellings `@scope/*` and `@scope/package/*` are equivalent to +`@scope` and `@scope/package`, respectively. + ## Documentation See the [WebUI repository](https://github.com/microsoft/webui) for full usage guides and examples. +Plugin-specific behavior is documented in the [FAST discovery guide](src/plugin/fast/README.md). ## License diff --git a/crates/webui-discovery/src/catalog.rs b/crates/webui-discovery/src/catalog.rs new file mode 100644 index 000000000..1141c2eab --- /dev/null +++ b/crates/webui-discovery/src/catalog.rs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use walkdir::WalkDir; + +use crate::npm::{read_optional_file, read_required_file, PackageContext}; +use crate::{has_sibling_script, DiscoveredComponent}; + +pub(crate) fn root(package: PackageContext<'_>) -> Result { + let root = package.root.join("components"); + match fs::metadata(&root) { + Ok(metadata) if metadata.is_dir() => Ok(root), + Ok(_) => bail!("Component catalog must be a directory: {}", root.display()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(package.root.to_path_buf()) + } + Err(error) => Err(error).with_context(|| format!("Cannot inspect {}", root.display())), + } +} + +pub(crate) fn template_tag(path: &Path) -> Option<&str> { + path.file_stem() + .and_then(|stem| stem.to_str()) + .filter(|stem| stem.contains('-')) +} + +fn templates(root: &Path) -> impl Iterator> + '_ { + WalkDir::new(root) + .sort_by_file_name() + .into_iter() + .filter_entry(|entry| { + entry.depth() == 0 + || (entry.file_name() != "node_modules" + && !entry.file_name().to_string_lossy().starts_with('.')) + }) + .filter_map(move |entry| match entry { + Err(error) => { + Some(Err(error).with_context(|| format!("Cannot scan {}", root.display()))) + } + Ok(entry) => { + let path = entry.path(); + (path.extension().is_some_and(|ext| ext == "html") + && template_tag(path).is_some() + && path.is_file()) + .then(|| Ok(entry.into_path())) + } + }) +} + +pub(crate) fn cache_files(root: &Path) -> Result> { + cache_files_matching(root, |_| true) +} + +pub(crate) fn cache_files_matching( + root: &Path, + include: impl Fn(&Path) -> bool, +) -> Result> { + let mut files = Vec::new(); + for template in templates(root) { + let template = template?; + if !include(&template) { + continue; + } + for extension in ["css", "ts", "js"] { + files.push(template.with_extension(extension)); + } + files.push(template); + } + Ok(files) +} + +pub(crate) fn has_templates(root: &Path) -> Result { + templates(root) + .next() + .transpose() + .map(|path| path.is_some()) +} + +pub(crate) fn has_templates_matching(root: &Path, include: impl Fn(&Path) -> bool) -> Result { + for path in templates(root) { + if include(&path?) { + return Ok(true); + } + } + Ok(false) +} + +pub(crate) fn discover(source: &str, root: &Path) -> Result> { + discover_matching(source, root, |_| true) +} + +pub(crate) fn discover_matching( + source: &str, + root: &Path, + include: impl Fn(&Path) -> bool, +) -> Result> { + let mut components = Vec::new(); + for template in templates(root) { + let template = template?; + if !include(&template) { + continue; + } + let tag_name = template + .file_stem() + .and_then(|stem| stem.to_str()) + .context("Component template has no valid tag name")?; + components.push(DiscoveredComponent { + tag_name: tag_name.to_string(), + html_content: read_required_file(&template, "component template")?, + css_content: read_optional_file( + Some(&template.with_extension("css")), + "component styles", + )?, + is_client_owned: has_sibling_script(&template)?, + source: source.to_string(), + }); + } + Ok(components) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::{CacheKey, DiscoveryCache}; + + #[test] + fn package_wide_catalog_ownership_cache_is_not_reused() -> Result<()> { + let root = tempfile::tempdir()?; + let package = root.path().join("node_modules/fixture-catalog"); + let component = package.join("components/test-text"); + fs::create_dir_all(&component)?; + fs::write(component.join("test-text.html"), "Static")?; + fs::write( + package.join("package.json"), + r#"{"exports":{"./button.js":"./dist/button.js"}}"#, + )?; + let package = package.canonicalize()?; + let package_json = package.join("package.json"); + let files = cache_files(&package.join("components"))?; + let cache = DiscoveryCache::open()?; + for namespace in ["webui", "webui-v2"] { + cache.put( + &CacheKey { + namespace, + source: "fixture-catalog", + package_json: &package_json, + fingerprint: DiscoveryCache::fingerprint(&package_json, &files)?, + }, + &[DiscoveredComponent { + tag_name: "test-text".to_string(), + html_content: "Static".to_string(), + css_content: None, + is_client_owned: true, + source: "fixture-catalog".to_string(), + }], + )?; + } + + let result = crate::discover_source("fixture-catalog", root.path())?; + assert_eq!(result.components.len(), 1); + assert!(!result.components[0].is_client_owned); + Ok(()) + } +} diff --git a/crates/webui-discovery/src/lib.rs b/crates/webui-discovery/src/lib.rs index aa5d54bda..8887afc84 100644 --- a/crates/webui-discovery/src/lib.rs +++ b/crates/webui-discovery/src/lib.rs @@ -11,6 +11,7 @@ //! logic reusable by CLI, FFI, and other host integrations. mod cache; +mod catalog; mod npm; mod plugin; diff --git a/crates/webui-discovery/src/npm.rs b/crates/webui-discovery/src/npm.rs index f6dc28d3b..8da7f6b6a 100644 --- a/crates/webui-discovery/src/npm.rs +++ b/crates/webui-discovery/src/npm.rs @@ -4,8 +4,8 @@ //! npm package resolution for external component discovery. //! //! Resolves npm packages from `node_modules/` using Node.js-style upward -//! traversal. Reads `package.json` exports for template and styles, and -//! parses the Custom Elements Manifest for component tag names. +//! traversal. Plugins own component naming and layout; the manifest helpers +//! support metadata-based discovery such as FAST. use anyhow::{bail, Context, Result}; use std::fs; @@ -33,63 +33,19 @@ pub(crate) struct ComponentDeclaration { pub(crate) module_path: Option, } -pub(crate) struct WebUIAssets { - pub(crate) template_path: PathBuf, - pub(crate) styles_path: Option, - pub(crate) manifest_path: PathBuf, -} - /// Maximum file size for package.json and custom elements manifests (10 MB). const MAX_MANIFEST_SIZE: u64 = 10 * 1024 * 1024; -/// Conditional export keys in priority order for fallback resolution. -const EXPORT_PRIORITY: &[&str] = &["default", "import", "require"]; - /// Package fields that conventionally point to a browser/module entry. const SCRIPT_ENTRY_FIELDS: &[&str] = &["main", "module", "browser"]; -/// WebUI asset exports that do not imply authored browser code. -const WEBUI_ASSET_EXPORTS: &[&str] = &[ +/// Resource-only exports do not imply registration scripts for metadata-based plugins. +const SCRIPTLESS_ASSET_EXPORTS: &[&str] = &[ "./template-webui.html", "./styles.css", "./component-asset.js", ]; -/// Find `node_modules/` by walking up from `start` directory. -fn find_node_modules(start: &Path) -> Result { - let mut current = Some(start); - while let Some(dir) = current { - let candidate = dir.join("node_modules"); - if candidate.is_dir() { - return Ok(candidate); - } - current = dir.parent(); - } - bail!( - "Could not find node_modules/ directory \ - (searched upward from {})", - start.display() - ); -} - -/// Find `node_modules/` by walking up from `primary`, falling back to a -/// walk up from `fallback` when the primary search comes up empty. -/// -/// The fallback rescues callers whose primary search root lives outside -/// any project tree. For example, `webui-press` builds each docs page in a -/// synthesized scratch directory under the system temp folder, which has no -/// `node_modules` ancestor; the project's `node_modules` is instead reached -/// from the process working directory the command was invoked in. The error -/// from the primary search is preserved when both roots fail. -fn find_node_modules_with_fallback(primary: &Path, fallback: &Path) -> Result { - find_node_modules(primary).or_else(|primary_err| { - if fallback == primary { - return Err(primary_err); - } - find_node_modules(fallback).map_err(|_| primary_err) - }) -} - /// Check if a package name is a bare scope (e.g., `@reactive-ui` without a sub-package). fn is_bare_scope(name: &str) -> bool { name.starts_with('@') && !name.contains('/') @@ -136,21 +92,45 @@ pub fn resolve( plugin: &dyn DiscoveryPlugin, cache: &mut DiscoveryCache, ) -> Result> { + let name = name.strip_suffix("/*").unwrap_or(name); // Walk up from the build's app directory first, then fall back to the // process working directory. The fallback covers callers whose app // directory lives outside the project (e.g. a system-temp scratch dir), // where the project's `node_modules` is only reachable from the cwd the // command was invoked in. let fallback = std::env::current_dir().unwrap_or_else(|_| search_dir.to_path_buf()); - let node_modules = find_node_modules_with_fallback(search_dir, &fallback)?; - + let node_modules = find_package_node_modules(name, search_dir, &fallback)?; if is_bare_scope(name) { resolve_scoped(name, &node_modules, plugin, cache) } else { - resolve_single(name, &node_modules, plugin, cache) + resolve_single(name, &node_modules, plugin, cache, false) } } +fn find_package_node_modules(name: &str, primary: &Path, fallback: &Path) -> Result { + for start in [primary, fallback] { + for directory in start.ancestors() { + let node_modules = directory.join("node_modules"); + let candidate = node_modules.join(name); + match fs::symlink_metadata(&candidate) { + Ok(_) => return Ok(node_modules), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!("Failed to inspect package {}", candidate.display()) + }); + } + } + } + } + bail!( + "Package or scope '{name}' not found in node_modules (searched upward from {} and {}). \ + Install the required packages in the project before building.", + primary.display(), + fallback.display() + ); +} + /// Enumerate all sub-packages under a scoped directory (e.g., `@reactive-ui/*`). fn resolve_scoped( scope: &str, @@ -166,20 +146,21 @@ fn resolve_scoped( ); } - let mut all = Vec::new(); - for entry in fs::read_dir(&scope_dir) + let mut entries = fs::read_dir(&scope_dir) .with_context(|| format!("Failed to read scope directory: {}", scope_dir.display()))? - { - let entry = entry?; + .collect::>>()?; + entries.sort_by_cached_key(fs::DirEntry::file_name); + let mut all = Vec::new(); + for entry in entries { let path = entry.path(); if !path.is_dir() { continue; } let sub_name = format!("{}/{}", scope, entry.file_name().to_string_lossy()); - // Sub-packages without WebUI exports are expected — skip silently. - if let Ok(components) = resolve_single(&sub_name, node_modules, plugin, cache) { - all.extend(components); - } + all.extend( + resolve_single(&sub_name, node_modules, plugin, cache, true) + .with_context(|| format!("Failed to discover scope member '{sub_name}'"))?, + ); } Ok(all) @@ -191,6 +172,7 @@ fn resolve_single( node_modules: &Path, plugin: &dyn DiscoveryPlugin, cache: &mut DiscoveryCache, + scope_member: bool, ) -> Result> { let pkg_dir = node_modules.join(name); @@ -224,6 +206,9 @@ fn resolve_single( manifest: &pkg_json, is_client_owned, }; + if scope_member && !plugin.supports_package(package)? { + return Ok(Vec::new()); + } let cache_files = plugin.package_cache_files(package)?; let fingerprint = DiscoveryCache::fingerprint(&pkg_json_path, &cache_files)?; let cache_key = CacheKey { @@ -245,52 +230,6 @@ fn resolve_single( Ok(components) } -/// Resolve an export path from the `exports` field in `package.json`. -/// -/// Handles two common formats: -/// - Direct string: `"./template-webui.html": "./dist/template.html"` -/// - Conditional object: `"./template-webui.html": { "default": "./dist/template.html" }` -fn resolve_export(exports: &serde_json::Value, key: &str) -> Option { - match exports.get(key)? { - serde_json::Value::String(s) => Some(s.clone()), - serde_json::Value::Object(obj) => { - for key in EXPORT_PRIORITY { - if let Some(serde_json::Value::String(s)) = obj.get(*key) { - return Some(s.clone()); - } - } - None - } - _ => None, - } -} - -pub(crate) fn resolve_webui_assets(package: PackageContext<'_>) -> Result { - let package_json = package.root.join("package.json"); - let exports = package - .manifest - .get("exports") - .with_context(|| format!("No 'exports' field in {}", package_json.display()))?; - let template_rel = resolve_export(exports, "./template-webui.html").with_context(|| { - format!( - "No './template-webui.html' export in {}", - package_json.display() - ) - })?; - validate_relative_path(&template_rel, "exports[\"./template-webui.html\"]")?; - let styles_path = if let Some(relative) = resolve_export(exports, "./styles.css") { - validate_relative_path(&relative, "exports[\"./styles.css\"]")?; - Some(package.root.join(relative)) - } else { - None - }; - Ok(WebUIAssets { - template_path: package.root.join(template_rel), - styles_path, - manifest_path: custom_elements_manifest_path(package)?, - }) -} - pub(crate) fn package_component_declarations( package: PackageContext<'_>, ) -> Result> { @@ -298,6 +237,35 @@ pub(crate) fn package_component_declarations( parse_custom_elements_manifest(&path) } +pub(crate) fn package_export_path( + package: PackageContext<'_>, + key: &str, +) -> Result> { + let Some(value) = package + .manifest + .get("exports") + .and_then(|exports| exports.get(key)) + else { + return Ok(None); + }; + let target = value + .as_str() + .or_else(|| { + ["default", "import", "require"] + .into_iter() + .find_map(|condition| value.get(condition).and_then(serde_json::Value::as_str)) + }) + .with_context(|| { + format!( + "Package '{}' export '{key}' must be a relative file path string, \ + or provide one under default/import/require.", + package.name + ) + })?; + validate_relative_path(target, key)?; + Ok(Some(package.root.join(target))) +} + pub(crate) fn custom_elements_manifest_path(package: PackageContext<'_>) -> Result { let package_json = package.root.join("package.json"); let relative = package @@ -343,7 +311,7 @@ fn package_has_authored_script(pkg_json: &serde_json::Value) -> bool { return export_value_has_script(root); } map.iter() - .any(|(key, value)| !is_webui_asset_export(key) && export_value_has_script(value)) + .any(|(key, value)| !is_resource_export(key) && export_value_has_script(value)) } _ => false, } @@ -358,8 +326,8 @@ fn export_value_has_script(value: &serde_json::Value) -> bool { } } -fn is_webui_asset_export(key: &str) -> bool { - WEBUI_ASSET_EXPORTS.contains(&key) +fn is_resource_export(key: &str) -> bool { + SCRIPTLESS_ASSET_EXPORTS.contains(&key) } fn is_script_path(path: &str) -> bool { @@ -420,7 +388,7 @@ fn parse_custom_elements_manifest(path: &Path) -> Result &'static str; - - /// Discover components below a local source root. - /// - /// # Errors - /// - /// Returns an error when a claimed component source cannot be read. - fn discover_local(&self, root: &Path) -> Result>; - - /// Return every package file whose contents or existence affects discovery. - /// - /// Paths must be deterministic. Missing optional candidates should still be - /// included so creating one invalidates a prior cache entry. - /// - /// # Errors - /// - /// Returns an error when package metadata needed to identify dependencies - /// is invalid. - fn package_cache_files(&self, package: PackageContext<'_>) -> Result>; - - /// Discover components in a validated npm package. - /// - /// # Errors - /// - /// Returns an error when required package metadata or component sources are - /// missing or invalid. - fn discover_package(&self, package: PackageContext<'_>) -> Result>; -} - -/// Discovery for WebUI's native component package layout. -#[derive(Debug, Default, Clone, Copy)] -pub struct WebUIDiscoveryPlugin; - -impl WebUIDiscoveryPlugin { - /// Create WebUI native discovery. - #[must_use] - pub const fn new() -> Self { - Self - } -} - -impl DiscoveryPlugin for WebUIDiscoveryPlugin { - fn cache_namespace(&self) -> &'static str { - "webui" - } - - fn discover_local(&self, root: &Path) -> Result> { - discover_local_templates(root, webui_local_tag) - } - - fn package_cache_files(&self, package: PackageContext<'_>) -> Result> { - let assets = resolve_webui_assets(package)?; - let mut files = Vec::with_capacity(3); - files.push(assets.manifest_path); - files.push(assets.template_path); - if let Some(styles) = assets.styles_path { - files.push(styles); - } - Ok(files) - } - - fn discover_package(&self, package: PackageContext<'_>) -> Result> { - let assets = resolve_webui_assets(package)?; - let html_content = read_required_file(&assets.template_path, "component template")?; - let css_content = read_optional_file(assets.styles_path.as_deref(), "component styles")?; - let tag_names = package_component_declarations(package)? - .into_iter() - .map(|declaration| declaration.tag_name) - .collect::>(); - if tag_names.is_empty() { - bail!( - "No component tag names found in custom elements manifest: {}", - assets.manifest_path.display() - ); - } - - Ok(tag_names - .into_iter() - .map(|tag_name| DiscoveredComponent { - tag_name, - html_content: html_content.clone(), - css_content: css_content.clone(), - is_client_owned: package.is_client_owned, - source: package.name.to_string(), - }) - .collect()) - } -} - /// Discovery for FAST generated component layouts. #[derive(Debug, Default, Clone, Copy)] pub struct FastDiscoveryPlugin; @@ -129,10 +38,45 @@ impl DiscoveryPlugin for FastDiscoveryPlugin { discover_local_templates(root, fast_local_tag) } + fn supports_package(&self, package: PackageContext<'_>) -> Result { + if package.manifest.get("customElements").is_some() { + return Ok(true); + } + crate::catalog::has_templates_matching(&crate::catalog::root(package)?, ordinary_html) + } + fn package_cache_files(&self, package: PackageContext<'_>) -> Result> { - let declarations = package_component_declarations(package)?; - let mut files = Vec::with_capacity(1 + declarations.len() * FAST_CACHE_FILES_PER_COMPONENT); - files.push(crate::npm::custom_elements_manifest_path(package)?); + let declarations = declarations(package)?; + let names: HashSet<_> = declarations + .iter() + .map(|item| item.tag_name.as_str()) + .collect(); + let mut files = + crate::catalog::cache_files_matching(&crate::catalog::root(package)?, |path| { + fallback_html(path, &names) + })?; + if package.manifest.get("customElements").is_some() { + files.push(crate::npm::custom_elements_manifest_path(package)?); + } + if declarations.is_empty() { + return Ok(files); + } + let assets = exported_assets(package, declarations.len())?; + let capacity = if assets.template.is_some() { + 4 + } else { + 1 + declarations.len() * FAST_CACHE_FILES_PER_COMPONENT + }; + files.reserve(capacity); + if let Some(template) = assets.template { + if let Some(styles) = assets.styles { + files.push(styles); + } else { + files.extend(fast_style_candidates(&template)); + } + files.push(template); + return Ok(files); + } for declaration in declarations { let module_path = declaration.module_path.as_deref().with_context(|| { format!( @@ -149,38 +93,47 @@ impl DiscoveryPlugin for FastDiscoveryPlugin { let declaration_name = declaration.name.as_deref().unwrap_or(&declaration.tag_name); for candidate in fast_template_candidates(package.root, module_path, declaration_name) { files.push(candidate.clone()); - files.extend(fast_style_candidates(&candidate)); + if assets.styles.is_none() { + files.extend(fast_style_candidates(&candidate)); + } } } + files.extend(assets.styles); Ok(files) } fn discover_package(&self, package: PackageContext<'_>) -> Result> { - let declarations = package_component_declarations(package)?; + let declarations = declarations(package)?; + let mut components = { + let names: HashSet<_> = declarations + .iter() + .map(|item| item.tag_name.as_str()) + .collect(); + crate::catalog::discover_matching( + package.name, + &crate::catalog::root(package)?, + |path| fallback_html(path, &names), + )? + }; if declarations.is_empty() { - bail!( - "No component declarations found in package '{}'", - package.name - ); + if components.is_empty() { + bail!( + "No components found in package '{}'. Declare FAST components through \ + customElements or provide .html files.", + package.name + ); + } + return Ok(components); } - let mut components = Vec::with_capacity(declarations.len()); + let assets = exported_assets(package, declarations.len())?; + components.reserve(declarations.len()); let mut seen_templates = HashSet::with_capacity(declarations.len()); for declaration in declarations { - let module_path = declaration.module_path.as_deref().with_context(|| { - format!( - "FAST component <{}> in package '{}' has no CEM module path", - declaration.tag_name, package.name - ) - })?; - let declaration_name = declaration.name.as_deref().unwrap_or(&declaration.tag_name); - let template_path = resolve_fast_template(package.root, module_path, declaration_name) - .with_context(|| { - format!( - "Failed to locate FAST template for <{}> in package '{}'", - declaration.tag_name, package.name - ) - })?; + let template_path = match &assets.template { + Some(path) => path.clone(), + None => inferred_template(package, &declaration)?, + }; if !seen_templates.insert(template_path.clone()) { bail!( "FAST template {} maps to multiple component declarations", @@ -188,10 +141,13 @@ impl DiscoveryPlugin for FastDiscoveryPlugin { ); } let html_content = read_required_file(&template_path, "FAST component template")?; - let css_content = read_optional_file( - resolve_fast_styles(&template_path).as_deref(), - "FAST component styles", - )?; + let css_content = match &assets.styles { + Some(path) => Some(read_required_file(path, "FAST component styles")?), + None => read_optional_file( + resolve_fast_styles(&template_path).as_deref(), + "FAST component styles", + )?, + }; components.push(DiscoveredComponent { tag_name: declaration.tag_name, html_content, @@ -204,6 +160,68 @@ impl DiscoveryPlugin for FastDiscoveryPlugin { } } +fn declarations(package: PackageContext<'_>) -> Result> { + if package.manifest.get("customElements").is_some() { + package_component_declarations(package) + } else { + Ok(Vec::new()) + } +} + +fn ordinary_html(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + !name.ends_with(".template.html") && !name.ends_with(".template-webui.html") + }) +} + +fn fallback_html(path: &Path, names: &HashSet<&str>) -> bool { + ordinary_html(path) + && crate::catalog::template_tag(path).is_some_and(|name| !names.contains(name)) +} + +struct ExportedAssets { + template: Option, + styles: Option, +} + +fn exported_assets(package: PackageContext<'_>, declarations: usize) -> Result { + let template = package_export_path(package, "./template.html")?; + if template.is_some() && declarations != 1 { + bail!( + "Package '{}' exports one './template.html' but declares {declarations} components. \ + Use one component declaration or per-module .template.html files.", + package.name + ); + } + let styles = if declarations == 1 { + package_export_path(package, "./styles.css")? + } else { + None + }; + Ok(ExportedAssets { template, styles }) +} + +fn inferred_template( + package: PackageContext<'_>, + declaration: &ComponentDeclaration, +) -> Result { + let module_path = declaration.module_path.as_deref().with_context(|| { + format!( + "FAST component <{}> in package '{}' has no CEM module path", + declaration.tag_name, package.name + ) + })?; + let name = declaration.name.as_deref().unwrap_or(&declaration.tag_name); + resolve_fast_template(package.root, module_path, name).with_context(|| { + format!( + "Failed to locate FAST template for <{}> in package '{}'", + declaration.tag_name, package.name + ) + }) +} + fn discover_local_templates( root: &Path, tag_name_for_path: fn(&Path) -> Option<&str>, @@ -237,17 +255,14 @@ fn discover_local_templates( Ok(components) } -fn webui_local_tag(path: &Path) -> Option<&str> { - path.file_stem() - .and_then(|stem| stem.to_str()) - .filter(|stem| stem.contains('-')) -} - fn fast_local_tag(path: &Path) -> Option<&str> { let file_name = path.file_name()?.to_str()?; + if file_name.ends_with(".template-webui.html") { + return None; + } file_name .strip_suffix(".template.html") - .or_else(|| webui_local_tag(path)) + .or_else(|| crate::catalog::template_tag(path)) } fn resolve_local_styles(template_path: &Path) -> Option { @@ -318,6 +333,20 @@ fn fast_template_candidates( push_nested_template_candidate(&mut candidates, component_root, suffix); } } + if !candidates.iter().any(|candidate| candidate.is_file()) { + for directory in parent + .ancestors() + .skip(1) + .take_while(|path| path.starts_with(root)) + { + for stem in module_stem + .into_iter() + .chain(std::iter::once(declaration_stem.as_str())) + { + push_template_candidate(&mut candidates, directory, stem); + } + } + } candidates } @@ -375,44 +404,4 @@ fn to_kebab_case(name: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - use std::fs; - - #[test] - fn fast_local_template_uses_component_filename_prefix() { - let root = tempfile::TempDir::new().unwrap(); - fs::write( - root.path().join("todo-item.template.html"), - "", - ) - .unwrap(); - - let components = FastDiscoveryPlugin::new() - .discover_local(root.path()) - .unwrap(); - - assert_eq!(components.len(), 1); - assert_eq!(components[0].tag_name, "todo-item"); - } - - #[test] - fn virtual_root_module_candidates_stay_inside_package() { - let root = tempfile::TempDir::new().unwrap(); - let candidates = fast_template_candidates(root.path(), Path::new("index.js"), "MyButton"); - - assert!(candidates - .iter() - .all(|candidate| candidate.starts_with(root.path()))); - assert!(candidates.contains( - &root - .path() - .join("my-button") - .join("my-button.template.html") - )); - assert!( - fast_template_candidates(root.path(), Path::new("../escape/index.js"), "Escape") - .is_empty() - ); - } -} +mod tests; diff --git a/crates/webui-discovery/src/plugin/fast/README.md b/crates/webui-discovery/src/plugin/fast/README.md new file mode 100644 index 000000000..86b3bcb69 --- /dev/null +++ b/crates/webui-discovery/src/plugin/fast/README.md @@ -0,0 +1,35 @@ +# FAST Component Discovery + +FAST reads each package's `customElements` manifest as its component inventory +and loads standard `*.template.html` files for those declarations. + +## Package assets + +A single-component package can export `./template.html` and optionally +`./styles.css`. Paths are relative to the package root, including symlinked +packages. Direct strings and `default`/`import`/`require` conditional exports +are supported. `./template-webui.html` is not selected. + +The manifest supplies the inventory and names; these exports are only optional +asset-location hints. + +Without a package-level template export, FAST uses CEM module-relative +template/style lookup and virtual-module fallbacks, then checks parent +directories within the package when needed. This supports multi-component +packages with templates beside their JavaScript modules. + +An explicit missing or invalid asset is an error, not a reason to select a +different template. + +## Default fallback + +FAST also includes ordinary `.html` files not declared in the +manifest, using default filename, CSS, and script-ownership rules. This fallback +also works when the manifest is absent or contains no component declarations. + +Manifest declarations win name conflicts. Generated `.template.html` and +`.template-webui.html` assets are not accidentally registered as default names. +Malformed declared metadata still reports an error. + +See the [discovery crate README](../../../README.md) for shared package lookup +and scope behavior. diff --git a/crates/webui-discovery/src/plugin/fast/tests.rs b/crates/webui-discovery/src/plugin/fast/tests.rs new file mode 100644 index 000000000..01aec284c --- /dev/null +++ b/crates/webui-discovery/src/plugin/fast/tests.rs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use super::*; +use std::fs; + +#[test] +fn fast_local_template_uses_component_filename_prefix() { + let root = tempfile::TempDir::new().unwrap(); + fs::write( + root.path().join("todo-item.template.html"), + "", + ) + .unwrap(); + fs::write( + root.path().join("todo-item.template-webui.html"), + "", + ) + .unwrap(); + + let components = FastDiscoveryPlugin::new() + .discover_local(root.path()) + .unwrap(); + + assert_eq!(components.len(), 1); + assert_eq!(components[0].tag_name, "todo-item"); +} + +#[test] +fn virtual_root_module_candidates_stay_inside_package() { + let root = tempfile::TempDir::new().unwrap(); + let candidates = fast_template_candidates(root.path(), Path::new("index.js"), "MyButton"); + + assert!(candidates + .iter() + .all(|candidate| candidate.starts_with(root.path()))); + assert!(candidates.contains( + &root + .path() + .join("my-button") + .join("my-button.template.html") + )); + assert!( + fast_template_candidates(root.path(), Path::new("../escape/index.js"), "Escape").is_empty() + ); +} diff --git a/crates/webui-discovery/src/plugin/mod.rs b/crates/webui-discovery/src/plugin/mod.rs new file mode 100644 index 000000000..3ed7504c7 --- /dev/null +++ b/crates/webui-discovery/src/plugin/mod.rs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Discovery contracts and the default filename-based implementation. + +use crate::npm::PackageContext; +use crate::DiscoveredComponent; +use anyhow::{bail, Result}; +use std::path::{Path, PathBuf}; + +mod fast; +pub use fast::FastDiscoveryPlugin; + +/// Maps a resolved local or npm package layout to WebUI component registrations. +pub trait DiscoveryPlugin { + /// Stable cache namespace for this discovery layout. + fn cache_namespace(&self) -> &'static str; + + /// Discover components below a local source root. + /// + /// # Errors + /// + /// Returns an error when a claimed component source cannot be read. + fn discover_local(&self, root: &Path) -> Result>; + + /// Whether a package declares components using this discovery layout. + /// + /// Scope searches skip packages returning `false`, but propagate failures + /// from packages that declare components. Explicit package requests still + /// report invalid or missing component inputs. Custom plugins default to + /// accepting every package. + /// + /// # Errors + /// + /// Returns an error when component-source presence cannot be determined. + fn supports_package(&self, _package: PackageContext<'_>) -> Result { + Ok(true) + } + + /// Return every package file whose contents or existence affects discovery. + /// + /// Paths must be deterministic. Missing optional candidates should still be + /// included so creating one invalidates a prior cache entry. + /// + /// # Errors + /// + /// Returns an error when package metadata needed to identify dependencies + /// is invalid. + fn package_cache_files(&self, package: PackageContext<'_>) -> Result>; + + /// Discover components in a validated npm package. + /// + /// # Errors + /// + /// Returns an error when required package metadata or component sources are + /// missing or invalid. + fn discover_package(&self, package: PackageContext<'_>) -> Result>; +} + +/// Default discovery using component filenames and matching sibling files. +#[derive(Debug, Default, Clone, Copy)] +pub struct WebUIDiscoveryPlugin; + +impl WebUIDiscoveryPlugin { + /// Create default filename-based discovery. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl DiscoveryPlugin for WebUIDiscoveryPlugin { + fn cache_namespace(&self) -> &'static str { + "webui-filenames" + } + + fn discover_local(&self, root: &Path) -> Result> { + crate::catalog::discover(&root.to_string_lossy(), root) + } + + fn supports_package(&self, package: PackageContext<'_>) -> Result { + crate::catalog::has_templates(&crate::catalog::root(package)?) + } + + fn package_cache_files(&self, package: PackageContext<'_>) -> Result> { + crate::catalog::cache_files(&crate::catalog::root(package)?) + } + + fn discover_package(&self, package: PackageContext<'_>) -> Result> { + let root = crate::catalog::root(package)?; + let components = crate::catalog::discover(package.name, &root)?; + if components.is_empty() { + bail!( + "No component templates in {}. Add .html files; \ + the filename is the custom element name.", + root.display() + ); + } + Ok(components) + } +} diff --git a/crates/webui-discovery/tests/catalog.rs b/crates/webui-discovery/tests/catalog.rs new file mode 100644 index 000000000..33a3456cf --- /dev/null +++ b/crates/webui-discovery/tests/catalog.rs @@ -0,0 +1,351 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use std::fs; +use std::path::Path; + +use webui_discovery::{discover_source, discover_source_with_plugin, FastDiscoveryPlugin}; + +type TestResult = Result<(), Box>; + +fn write_component(root: &Path, tag: &str) -> std::io::Result<()> { + let dir = root.join("components").join(tag); + fs::create_dir_all(&dir)?; + fs::write(dir.join(format!("{tag}.html")), "") +} + +#[test] +fn native_catalog_supports_ancestor_resolution_and_cache_invalidation() -> TestResult { + let root = tempfile::tempdir()?; + let site = root.path().join("site"); + fs::create_dir_all(site.join("node_modules"))?; + let package = root.path().join("node_modules/@fixture/catalog"); + write_component(&package, "test-button")?; + fs::write( + package.join("components/test-button/test-button.ts"), + "export class TestButton {}", + )?; + fs::write( + package.join("package.json"), + r#"{"name":"@fixture/catalog","exports":{"./button.js":"./dist/button.js"}}"#, + )?; + let first = discover_source("@fixture/catalog", &site)?; + assert_eq!(first.components.len(), 1); + assert_eq!(first.components[0].tag_name, "test-button"); + assert!(first.components[0].is_client_owned); + assert_eq!(first.components[0].source, "@fixture/catalog"); + assert!(first.components[0].css_content.is_none()); + + fs::write( + package.join("components/test-button/test-button.css"), + "button { color: blue; }", + )?; + write_component(&package, "test-alert")?; + // Compiled duplicates and documentation must not become extra components. + fs::create_dir_all(package.join("dist/components/test-button"))?; + fs::write( + package.join("dist/components/test-button/test-button.html"), + "Duplicate", + )?; + fs::write( + package.join("components/test-button/index.html"), + "Documentation", + )?; + let second = discover_source("@fixture/catalog", &site)?; + assert_eq!(second.components.len(), 2); + assert_eq!(second.components[0].tag_name, "test-alert"); + assert_eq!( + second.components[1].css_content.as_deref(), + Some("button { color: blue; }") + ); + fs::remove_dir_all(package.join("components/test-alert"))?; + assert_eq!( + discover_source("@fixture/catalog", &site)?.components.len(), + 1 + ); + Ok(()) +} + +#[test] +fn mixed_catalog_ownership_tracks_component_scripts_not_package_exports() -> TestResult { + let root = tempfile::tempdir()?; + let package = root.path().join("node_modules/@fixture/mixed"); + write_component(&package, "test-button")?; + write_component(&package, "test-text")?; + fs::write( + package.join("package.json"), + r#"{"exports":{"./button.js":"./dist/components/test-button/test-button.js"}}"#, + )?; + let button = package.join("components/test-button/test-button.ts"); + let text = package.join("components/test-text/test-text.js"); + fs::write(&button, "export class TestButton {}")?; + fs::write( + package.join("components/test-text/test-text.spec.ts"), + "throw new Error('Test-only source');", + )?; + let discovered = discover_source("@fixture/mixed", root.path())?; + assert_eq!(discovered.components.len(), 2); + assert!(discovered.components[0].is_client_owned); + assert!(!discovered.components[1].is_client_owned); + + fs::write(&text, "export class TestText {}")?; + assert!(discover_source("@fixture/mixed", root.path())?.components[1].is_client_owned); + fs::remove_file(&text)?; + fs::remove_file(&button)?; + assert!(discover_source("@fixture/mixed", root.path())? + .components + .iter() + .all(|component| !component.is_client_owned)); + Ok(()) +} + +#[test] +fn invalid_nearest_package_does_not_fall_back_to_an_ancestor() -> TestResult { + let root = tempfile::tempdir()?; + let site = root.path().join("site"); + for directory in [root.path(), site.as_path()] { + let package = directory.join("node_modules/@fixture/catalog"); + write_component(&package, "test-button")?; + fs::write(package.join("package.json"), "{}")?; + } + fs::write( + site.join("node_modules/@fixture/catalog/package.json"), + "{ invalid }", + )?; + assert!(discover_source("@fixture/catalog", &site).is_err()); + Ok(()) +} + +#[test] +fn native_filenames_ignore_template_exports_and_custom_elements_metadata() -> TestResult { + let root = tempfile::tempdir()?; + let package = root.path().join("node_modules/@fixture/catalog"); + write_component(&package, "test-button")?; + fs::write( + package.join("package.json"), + r#"{ + "exports": { + "./template-webui.html": "../outside.html", + "./styles.css": "../outside.css" + }, + "customElements": "../missing-manifest.json", + "main": "./dist/unrelated.js" + }"#, + )?; + let result = discover_source("@fixture/catalog", root.path())?; + assert_eq!(result.components.len(), 1); + assert_eq!(result.components[0].tag_name, "test-button"); + assert!(!result.components[0].is_client_owned); + assert!(result.components[0].css_content.is_none()); + Ok(()) +} + +#[test] +fn native_packages_use_html_basenames_without_directory_name_requirements() -> TestResult { + let root = tempfile::tempdir()?; + let package = root.path().join("node_modules/@fixture/names"); + fs::create_dir_all(&package)?; + fs::write(package.join("package.json"), r#"{"name":"@fixture/names"}"#)?; + fs::write(package.join("test-flat.html"), "Flat")?; + fs::write(package.join("test-flat.css"), "span { color: blue; }")?; + fs::create_dir_all(package.join("node_modules/other"))?; + fs::create_dir_all(package.join(".hidden"))?; + fs::write( + package.join("node_modules/other/other-widget.html"), + "Dependency", + )?; + fs::write( + package.join(".hidden/hidden-widget.html"), + "Hidden", + )?; + let result = discover_source("@fixture/names", root.path())?; + assert_eq!(result.components.len(), 1); + assert_eq!(result.components[0].tag_name, "test-flat"); + assert_eq!( + result.components[0].css_content.as_deref(), + Some("span { color: blue; }") + ); + + let components = package.join("components"); + fs::create_dir_all(components.join("nested/deep"))?; + fs::write(components.join("test-root.html"), "Root")?; + fs::write( + components.join("nested/deep/test-nested.html"), + "Nested", + )?; + let result = discover_source("@fixture/names", root.path())?; + let tags: Vec<_> = result + .components + .iter() + .map(|component| component.tag_name.as_str()) + .collect(); + assert_eq!(tags, ["test-nested", "test-root"]); + Ok(()) +} + +#[test] +fn fast_keeps_manifest_names_and_special_template_style_paths() -> TestResult { + let root = tempfile::tempdir()?; + let package = root.path().join("node_modules/@fixture/fast"); + fs::create_dir_all(package.join("dist"))?; + fs::write( + package.join("package.json"), + r#"{ + "customElements": "./custom-elements.json", + "exports": {"./card.js": "./dist/card.js"} + }"#, + )?; + fs::write( + package.join("custom-elements.json"), + r#"{ + "modules": [{ + "path": "dist/card.js", + "declarations": [{"name": "Card", "tagName": "fast-card"}] + }] + }"#, + )?; + fs::write(package.join("dist/card.js"), "export {};")?; + fs::write( + package.join("dist/card.template.html"), + "", + )?; + fs::write( + package.join("dist/card.styles.css"), + ":host { color: blue; }", + )?; + let result = + discover_source_with_plugin("@fixture/fast", root.path(), &FastDiscoveryPlugin::new())?; + assert_eq!(result.components.len(), 1); + assert_eq!(result.components[0].tag_name, "fast-card"); + assert_eq!( + result.components[0].css_content.as_deref(), + Some(":host { color: blue; }") + ); + assert!(result.components[0].is_client_owned); + assert!(discover_source("@fixture/fast", root.path()).is_err()); + Ok(()) +} + +#[test] +fn fast_prefers_standard_exported_template_over_webui_variant() -> TestResult { + let root = tempfile::tempdir()?; + let package = root.path().join("node_modules/@fixture/button"); + fs::create_dir_all(package.join("dist/esm"))?; + fs::write( + package.join("package.json"), + r#"{ + "customElements": "./custom-elements.json", + "exports": { + ".": "./dist/esm/button.js", + "./template.html": {"default": "./dist/button.template.html"}, + "./template-webui.html": "./dist/button.template-webui.html", + "./styles.css": "./dist/button.styles.css" + } + }"#, + )?; + fs::write( + package.join("custom-elements.json"), + r#"{ + "modules": [{"path":"dist/esm/button.js", + "declarations":[{"name":"Button","tagName":"fast-button"}]}] + }"#, + )?; + fs::write(package.join("dist/esm/button.js"), "export {};")?; + let standard = ""; + fs::write(package.join("dist/button.template.html"), standard)?; + fs::write( + package.join("dist/button.template-webui.html"), + "", + )?; + fs::write( + package.join("dist/button.styles.css"), + ":host { color: blue; }", + )?; + let result = + discover_source_with_plugin("@fixture/button", root.path(), &FastDiscoveryPlugin::new())?; + assert_eq!(result.components.len(), 1); + assert_eq!(result.components[0].tag_name, "fast-button"); + assert_eq!(result.components[0].html_content, standard); + assert_eq!( + result.components[0].css_content.as_deref(), + Some(":host { color: blue; }") + ); + assert!(result.components[0].is_client_owned); + let manifest = fs::read_to_string(package.join("package.json"))?; + fs::write( + package.join("package.json"), + r#"{"customElements":"custom-elements.json","main":"./dist/esm/button.js"}"#, + )?; + let inferred = + discover_source_with_plugin("@fixture/button", root.path(), &FastDiscoveryPlugin::new())?; + assert_eq!(inferred.components[0].html_content, standard); + assert_eq!( + inferred.components[0].css_content.as_deref(), + Some(":host { color: blue; }") + ); + fs::write(package.join("package.json"), manifest)?; + fs::write( + package.join("dist/button.styles.css"), + ":host { color: red; }", + )?; + let changed = + discover_source_with_plugin("@fixture/button", root.path(), &FastDiscoveryPlugin::new())?; + assert_eq!( + changed.components[0].css_content.as_deref(), + Some(":host { color: red; }") + ); + + fs::remove_file(package.join("dist/button.styles.css"))?; + assert!(discover_source_with_plugin( + "@fixture/button", + root.path(), + &FastDiscoveryPlugin::new(), + ) + .is_err()); + fs::write( + package.join("dist/button.styles.css"), + ":host { color: red; }", + )?; + fs::remove_file(package.join("dist/button.template.html"))?; + assert!(discover_source_with_plugin( + "@fixture/button", + root.path(), + &FastDiscoveryPlugin::new() + ) + .is_err()); + Ok(()) +} + +#[test] +fn fast_rejects_invalid_standard_template_exports_without_falling_back() -> TestResult { + let root = tempfile::tempdir()?; + let package = root.path().join("node_modules/@fixture/invalid"); + fs::create_dir_all(package.join("dist"))?; + fs::write( + package.join("custom-elements.json"), + r#"{ + "modules":[{"path":"dist/button.js", + "declarations":[{"name":"Button","tagName":"fast-button"}]}] + }"#, + )?; + fs::write(package.join("dist/button.js"), "export {};")?; + fs::write( + package.join("dist/button.template.html"), + "", + )?; + for target in [r#""../escape.template.html""#, "true"] { + fs::write( + package.join("package.json"), + format!( + r#"{{"customElements":"custom-elements.json","exports":{{"./template.html":{target}}}}}"# + ), + )?; + assert!(discover_source_with_plugin( + "@fixture/invalid", + root.path(), + &FastDiscoveryPlugin::new(), + ) + .is_err()); + } + Ok(()) +} diff --git a/crates/webui-discovery/tests/scopes.rs b/crates/webui-discovery/tests/scopes.rs new file mode 100644 index 000000000..17be3c77f --- /dev/null +++ b/crates/webui-discovery/tests/scopes.rs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use std::fs; +use std::path::{Path, PathBuf}; + +use webui_discovery::{ + discover_source, discover_source_with_plugin, DiscoveredComponent, DiscoveryPlugin, + FastDiscoveryPlugin, PackageContext, +}; + +type TestResult = Result<(), Box>; + +fn package(root: &Path, name: &str, manifest: &str) -> std::io::Result { + let package = root.join("node_modules").join(name); + fs::create_dir_all(&package)?; + fs::write(package.join("package.json"), manifest)?; + Ok(package) +} + +#[test] +fn native_scope_skips_utilities_but_reports_invalid_component_files() -> TestResult { + let root = tempfile::tempdir()?; + let child = root.path().join("app"); + fs::create_dir_all(child.join("node_modules/unrelated"))?; + let component = package(root.path(), "@fixture/button", "{}")?; + package(root.path(), "@fixture/utils", r#"{"main":"./index.js"}"#)?; + fs::write( + component.join("test-button.html"), + "", + )?; + let result = discover_source("@fixture", &child)?; + assert_eq!(result.components.len(), 1); + assert_eq!(result.components[0].tag_name, "test-button"); + let wildcard = discover_source("@fixture/*", &child)?; + assert_eq!(wildcard.components.len(), 1); + assert_eq!(wildcard.components[0].tag_name, "test-button"); + + fs::write(component.join("test-button.html"), [0xff])?; + let error = discover_source("@fixture", &child) + .err() + .ok_or("scope must surface bad HTML")?; + assert!(format!("{error:#}").contains("test-button.html")); + Ok(()) +} + +#[test] +fn fast_scope_skips_non_components_but_reports_declared_missing_assets() -> TestResult { + let root = tempfile::tempdir()?; + let child = root.path().join("app"); + fs::create_dir_all(child.join("node_modules/unrelated"))?; + let component = package( + root.path(), + "@fixture/button", + r#"{ + "customElements":"custom-elements.json", + "exports":{"./template.html":"./button.template.html"} + }"#, + )?; + package(root.path(), "@fixture/tokens", r#"{"main":"./index.js"}"#)?; + fs::write( + component.join("custom-elements.json"), + r#"{ + "modules":[{"declarations":[{"name":"Button","tagName":"fast-button"}]}] + }"#, + )?; + fs::write( + component.join("button.template.html"), + "", + )?; + let plugin = FastDiscoveryPlugin::new(); + let result = discover_source_with_plugin("@fixture", &child, &plugin)?; + assert_eq!(result.components.len(), 1); + assert_eq!(result.components[0].tag_name, "fast-button"); + + fs::remove_file(component.join("button.template.html"))?; + let error = discover_source_with_plugin("@fixture", &child, &plugin) + .err() + .ok_or("scope must surface missing declared templates")?; + assert!(format!("{error:#}").contains("button.template.html")); + Ok(()) +} + +struct InlinePlugin; + +impl DiscoveryPlugin for InlinePlugin { + fn cache_namespace(&self) -> &'static str { + "fixture-inline" + } + fn discover_local(&self, _root: &Path) -> anyhow::Result> { + Ok(Vec::new()) + } + fn package_cache_files(&self, _package: PackageContext<'_>) -> anyhow::Result> { + Ok(Vec::new()) + } + fn discover_package( + &self, + package: PackageContext<'_>, + ) -> anyhow::Result> { + Ok(vec![DiscoveredComponent { + tag_name: "inline-component".to_string(), + html_content: "Inline".to_string(), + css_content: None, + is_client_owned: false, + source: package.name.to_string(), + }]) + } +} + +#[test] +fn custom_plugins_keep_default_scope_support_without_file_dependencies() -> TestResult { + let root = tempfile::tempdir()?; + package(root.path(), "@fixture/inline", "{}")?; + let result = discover_source_with_plugin("@fixture", root.path(), &InlinePlugin)?; + assert_eq!(result.components.len(), 1); + assert_eq!(result.components[0].tag_name, "inline-component"); + Ok(()) +} + +#[test] +fn fast_uses_default_html_when_manifest_metadata_is_absent_or_empty() -> TestResult { + let root = tempfile::tempdir()?; + let plain = package( + root.path(), + "@fixture/plain", + r#"{"main":"./unrelated.js"}"#, + )?; + fs::create_dir_all(plain.join("components"))?; + fs::write( + plain.join("components/plain-card.html"), + "Plain", + )?; + fs::write( + plain.join("components/plain-card.css"), + "span { color: blue; }", + )?; + let plugin = FastDiscoveryPlugin::new(); + let result = discover_source_with_plugin("@fixture/*", root.path(), &plugin)?; + assert_eq!(result.components.len(), 1); + assert_eq!(result.components[0].tag_name, "plain-card"); + assert_eq!(result.components[0].html_content, "Plain"); + assert!(!result.components[0].is_client_owned); + + fs::write( + plain.join("package.json"), + r#"{"customElements":"custom-elements.json"}"#, + )?; + fs::write(plain.join("custom-elements.json"), r#"{"modules":[]}"#)?; + fs::write( + plain.join("components/plain-card.ts"), + "export class PlainCard {}", + )?; + let result = discover_source_with_plugin("@fixture/plain/*", root.path(), &plugin)?; + assert_eq!(result.components.len(), 1); + assert!(result.components[0].is_client_owned); + Ok(()) +} + +#[test] +fn fast_manifest_components_take_precedence_and_plain_components_fill_gaps() -> TestResult { + let root = tempfile::tempdir()?; + let mixed = package( + root.path(), + "@fixture/mixed", + r#"{ + "customElements":"custom-elements.json", + "exports":{"./template.html":"./dist/button.template.html"} + }"#, + )?; + fs::create_dir_all(mixed.join("dist"))?; + fs::create_dir_all(mixed.join("components"))?; + fs::write( + mixed.join("custom-elements.json"), + r#"{ + "modules":[{"declarations":[{"name":"Button","tagName":"fast-button"}]}] + }"#, + )?; + fs::write( + mixed.join("dist/button.template.html"), + "", + )?; + fs::write( + mixed.join("components/plain-card.html"), + "Fallback", + )?; + fs::write(mixed.join("components/fast-button.html"), [0xff])?; + fs::write( + mixed.join("components/unused.template-webui.html"), + "Must not register", + )?; + let result = + discover_source_with_plugin("@fixture/mixed", root.path(), &FastDiscoveryPlugin::new())?; + assert_eq!(result.components.len(), 2); + let declared = result + .components + .iter() + .find(|component| component.tag_name == "fast-button") + .ok_or("manifest component missing")?; + assert!(declared.html_content.contains(" 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 534833852..79cd16e79 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 b2fdbba6a..f1553ffb6 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 b26315fb9..2fcfb74b1 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/lib.rs b/crates/webui-press/src/lib.rs index 063c4edf6..f61129ed7 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 a9dd554d8..311ed69c8 100644 --- a/crates/webui-press/src/main.rs +++ b/crates/webui-press/src/main.rs @@ -30,7 +30,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 +38,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 +59,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 +75,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 +92,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 { @@ -143,11 +160,29 @@ 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}"))?; + 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}"))?; EMBEDDED_TEMPLATE .extract(staging.join("template")) .map_err(|e| anyhow::anyhow!("Cannot extract embedded template: {e}"))?; @@ -200,8 +235,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 +249,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 +263,7 @@ fn run_serve_blocking( config_path: config_path_buf, host: host.to_string(), port, + show_override, })) } @@ -231,6 +271,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 cd8345cdb..994854436 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 bb5dad4ad..d29c3c1b2 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 781e16060..934efb68c 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 000000000..a5c6abc35 --- /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 e5f760e4d..01cd1ba91 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 f6a1d00a2..b8fbcdc70 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 000000000..dee4d9c5b --- /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 000000000..6d755694e --- /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 2f231c909..79212e655 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 000000000..59dae21d3 --- /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(/