microsoft-webui-press 3.0.3

High-performance Static Site Generator powered by WebUI Framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

//! `webui-press` CLI — standalone documentation site builder.
//!
//! ```bash
//! webui-press build                          # defaults: .webui-press/config.json
//! webui-press build --config my-config.json  # custom config
//! webui-press build --template ./my-template # custom template
//! webui-press serve                          # build + watch + live-reload dev server
//! webui-press serve --port 4000              # custom port
//! ```

mod build;
mod bundler;
mod content;
mod error;
#[cfg(test)]
mod extraction_tests;
mod markdown;
mod regions;
mod scratch;
mod serve;
mod state;
mod types;

use std::fs;
use std::num::NonZeroU64;
use std::path::{Path, PathBuf};
use std::process;

use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use console::style;
use include_dir::{include_dir, Dir, DirEntry};
use webui_dev_server::shutdown::{self, Control, Mode};

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");
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0100_0000_01b3;

#[derive(Parser)]
#[command(
    name = "webui-press",
    version,
    about = "WebUI documentation site builder"
)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Build the documentation site
    Build {
        /// Path to config.json
        #[arg(short, long, default_value = ".webui-press/config.json")]
        config: String,

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

        /// Generate the complete site or only page content (default: all)
        #[arg(long, value_enum)]
        show: Option<ShowMode>,
    },

    /// Build, watch sources, and serve with live reload (dev only)
    Serve(ServeArgs),
}

#[derive(Args)]
struct ServeArgs {
    /// Path to config.json
    #[arg(short, long, default_value = ".webui-press/config.json")]
    config: String,

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

    /// Generate the complete site or only page content (default: all)
    #[arg(long, value_enum)]
    show: Option<ShowMode>,

    /// Port to bind
    #[arg(short, long, default_value_t = 3333)]
    port: u16,

    /// Host to bind
    #[arg(long, default_value = "127.0.0.1")]
    host: String,

    /// Opt in to supervised shutdown with this grace period in seconds.
    /// A second stop request forces termination sooner.
    #[arg(long, value_name = "SECONDS")]
    shutdown_timeout: Option<NonZeroU64>,
}

fn main() {
    let cli = Cli::parse();
    let result = match cli.command {
        Commands::Build {
            config,
            template,
            show,
        } => run_build(&config, template.as_deref(), show).map(|()| 0),
        Commands::Serve(args) => run_serve_command(&args),
    };

    match result {
        Ok(0) => {}
        Ok(code) => process::exit(code),
        Err(e) => {
            eprintln!("{} {e}", style("✘").red().bold());
            process::exit(1);
        }
    }
}

/// Load the config and materialize the embedded template assets.
/// Shared by `build` and `serve`.
fn load_config(
    config_path: &str,
    template_dir: Option<&str>,
) -> Result<(DocsConfig, PathBuf, PathBuf)> {
    let config_str = fs::read_to_string(config_path)
        .map_err(|e| anyhow::anyhow!("Cannot read config {}: {}", style(config_path).bold(), e))?;

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

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

    let template = match template_dir {
        Some(template_dir) => Path::new(template_dir).to_path_buf(),
        None => extract_embedded_assets(Path::new(&docs_config.out_dir), &config_dir)?,
    };

    Ok((docs_config, config_dir, template))
}

/// Materialize the embedded template + components into a per-version,
/// content-addressed cache directory and return the `template` subdirectory.
///
/// The cache is content-addressed (keyed by an FNV-1a hash of the embedded
/// bytes), so a `.complete` directory for a given hash is always valid and is
/// reused as-is. A fresh extraction is written into a sibling staging directory
/// and published with a single atomic `rename`, so an interrupted run (Ctrl-C,
/// crash) never leaves a half-written cache: the next run sees no `.complete`
/// sentinel and re-extracts.
///
/// The cache is placed on the volume that holds the build output (see
/// [`scratch::scratch_base`]): the extracted tree contains TypeScript sources
/// that become bundler inputs, and a bundle cannot span two volumes because
/// projection manifest keys are relative to a single build root.
fn extract_embedded_assets(build_dir: &Path, project_dir: &Path) -> Result<PathBuf> {
    let base = scratch::scratch_base(build_dir, project_dir).map_err(|e| {
        anyhow::anyhow!(
            "Cannot prepare the Press cache directory for {}: {e}",
            build_dir.display()
        )
    })?;
    extract_embedded_assets_in(&base)
}

fn extract_embedded_assets_in(tmp: &Path) -> Result<PathBuf> {
    let dir_name = format!(
        "webui-press-{}-{:016x}",
        env!("CARGO_PKG_VERSION"),
        embedded_assets_hash()
    );
    let root = tmp.join(&dir_name);
    let template_dir = root.join("template");

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

    // Concurrent cold builds must not remove one another's staging directory.
    let cache_lock = fs::OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(tmp.join(format!("{dir_name}.lock")))
        .map_err(|e| anyhow::anyhow!("Cannot open embedded asset cache lock: {e}"))?;
    #[cfg(test)]
    extraction_tests::checkpoint("cold")?;
    cache_lock
        .lock()
        .map_err(|e| anyhow::anyhow!("Cannot lock embedded asset cache: {e}"))?;
    if is_complete_cache(&root) {
        return Ok(template_dir);
    }

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

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

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

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

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

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

fn run_build(config_path: &str, template_dir: Option<&str>, show: Option<ShowMode>) -> 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(())
}

fn run_serve_command(args: &ServeArgs) -> Result<i32> {
    // Gate before load_config can extract embedded assets or acquire cache locks.
    match shutdown::prepare(args.shutdown_timeout)? {
        Mode::Direct => run_serve_blocking(args, None).map(|()| 0),
        Mode::Child(control) => {
            run_serve_blocking(args, Some(control)).map(|()| shutdown::JOINED_EXIT_CODE)
        }
        Mode::Supervisor(code) => Ok(code),
    }
}

fn run_serve_blocking(args: &ServeArgs, control: Option<Control>) -> Result<()> {
    let (docs_config, config_dir, template) = load_config(&args.config, args.template.as_deref())?;
    let config_path_buf = Path::new(&args.config).to_path_buf();

    let rt = tokio::runtime::Runtime::new()
        .map_err(|e| anyhow::anyhow!("Cannot start tokio runtime: {e}"))?;
    rt.block_on(serve::run_serve(
        serve::ServeConfig {
            config: docs_config,
            config_dir,
            template_dir: template,
            config_path: config_path_buf,
            host: args.host.clone(),
            port: args.port,
            show_override: args.show,
        },
        control,
    ))
}

#[cfg(test)]
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 show = match cli.command {
                    Commands::Build { show, .. } | Commands::Serve(ServeArgs { show, .. }) => show,
                };
                assert_eq!(show, Some(expected));
            }
            let cli = Cli::try_parse_from(["webui-press", command])?;
            let show = match cli.command {
                Commands::Build { show, .. } | Commands::Serve(ServeArgs { show, .. }) => show,
            };
            assert_eq!(show, None);
            assert!(Cli::try_parse_from(["webui-press", command, "--show=invalid"]).is_err());
        }
        Ok(())
    }

    #[test]
    fn shutdown_timeout_is_opt_in_and_positive() -> Result<()> {
        let Commands::Serve(args) = Cli::try_parse_from(["webui-press", "serve"])?.command else {
            panic!("expected serve command");
        };
        assert_eq!(args.shutdown_timeout, None);
        for value in ["1", "30"] {
            let Commands::Serve(args) =
                Cli::try_parse_from(["webui-press", "serve", "--shutdown-timeout", value])?.command
            else {
                panic!("expected serve command");
            };
            assert_eq!(
                args.shutdown_timeout.map(NonZeroU64::get),
                value.parse().ok()
            );
        }
        for value in ["0", "-1", "1.5", "invalid"] {
            assert!(
                Cli::try_parse_from(["webui-press", "serve", "--shutdown-timeout", value,])
                    .is_err()
            );
        }
        assert!(Cli::try_parse_from(["webui-press", "build", "--shutdown-timeout", "1"]).is_err());
        Ok(())
    }

    #[test]
    fn embedded_assets_extract_template_and_components() -> Result<()> {
        // The system temp directory shares this test process's volume, so the
        // resolved cache base is the shared one.
        let build = std::env::temp_dir();
        let project = std::env::temp_dir();
        let template = extract_embedded_assets(&build, &project)?;
        let root = template
            .parent()
            .ok_or_else(|| anyhow::anyhow!("template has no parent"))?;

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

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

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

            fs::create_dir_all(base.join("components"))?;
            assert!(is_complete_cache(&base));
            Ok(())
        })();
        let _ = fs::remove_dir_all(&base);
        outcome
    }
}