use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicU64;
use std::sync::Arc;
use std::time::Instant;
use crate::error::{Error, Result};
use crate::types::BundlerConfig;
static BUNDLE_REBUILD_NONCE: AtomicU64 = AtomicU64::new(0);
const WEBUI_TSCONFIG_RAW: &str =
r#"{"compilerOptions":{"experimentalDecorators":true,"useDefineForClassFields":false}}"#;
const ESBUILD_RUNNER: &str = r#"
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
const config = JSON.parse(await readFile(process.argv[2], "utf8"));
const started = performance.now();
const require = createRequire(pathToFileURL(path.join(config.resolveDir, "__webui_press__.cjs")));
const esbuild = require(require.resolve("esbuild"));
const projectionUrl = pathToFileURL(config.projectionEntry).href;
const { esbuildProjection } = await import(projectionUrl);
try {
await esbuild.build({
...config.build,
plugins: [esbuildProjection({ manifest: config.manifest })],
});
} catch (error) {
// esbuild's own summary message drops note text, which is where projection
// diagnostics put their actionable `help:` line. Re-format the raw messages
// so the cause survives to the terminal. Press reads this stderr through a
// pipe, so set `exitCode` rather than calling `process.exit`, which can drop
// the pending write.
const errors = error?.errors;
if (!Array.isArray(errors) || errors.length === 0) {
throw error;
}
const formatted = await esbuild.formatMessages(errors, {
kind: "error",
color: false,
});
process.stderr.write(formatted.join(""));
process.exitCode = 1;
}
if (process.env.WEBUI_PROJECTION_PROFILE === "1") {
console.error(`[webui-press] esbuild-total=${(performance.now() - started).toFixed(1)}ms`);
}
"#;
#[cfg(test)]
struct EsbuildCommand {
program: PathBuf,
prefix_args: Vec<String>,
}
pub(crate) fn resolve_config_component_source(source: &str, cwd: &Path) -> String {
if webui_discovery::is_local_source(source) {
cwd.join(source).to_string_lossy().to_string()
} else {
source.to_string()
}
}
const BUNDLE_SCRIPT_EXTENSIONS: &[&str] = &["js", "mjs", "jsx", "ts", "tsx"];
const SCRIPT_GRAPH_EXTENSIONS: &[&str] = &["js", "mjs", "jsx", "ts", "tsx"];
#[derive(Debug, Clone)]
pub(crate) struct PageBundleEntry {
pub(crate) id: usize,
pub(crate) page_path: String,
pub(crate) component_scripts: Vec<PathBuf>,
pub(crate) explicit_scripts: Vec<ScriptSource>,
}
#[derive(Debug, Clone)]
pub(crate) struct RootBundleEntry {
pub(crate) script_path: Option<PathBuf>,
pub(crate) component_scripts: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct ComponentScript {
html_content: String,
script_path: PathBuf,
}
struct TempDirGuard {
path: PathBuf,
}
pub(crate) struct BundleThread {
handle: Option<std::thread::JoinHandle<Result<BundleResult>>>,
}
impl BundleThread {
pub(crate) fn spawn<F>(build: F) -> Self
where
F: FnOnce() -> Result<BundleResult> + Send + 'static,
{
Self {
handle: Some(std::thread::spawn(build)),
}
}
pub(crate) fn join(mut self) -> Result<BundleResult> {
let Some(handle) = self.handle.take() else {
return Err(Error::Build("Bundle thread already joined".to_string()));
};
handle
.join()
.map_err(|_| Error::Build("Bundle thread panicked".to_string()))?
}
}
impl Drop for BundleThread {
fn drop(&mut self) {
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
impl TempDirGuard {
fn new(path: PathBuf) -> Self {
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDirGuard {
fn drop(&mut self) {
fs::remove_dir_all(&self.path).ok();
}
}
#[derive(Debug, Clone)]
pub(crate) enum ScriptSource {
Inline(String),
File(String),
}
fn is_component_html_file(path: &Path) -> bool {
path.extension()
.is_some_and(|extension| extension == "html")
&& path
.file_stem()
.and_then(|name| name.to_str())
.is_some_and(|name| name.contains('-'))
}
pub(crate) fn discover_component_scripts(
component_sources: &[String],
) -> Result<BTreeMap<String, ComponentScript>> {
let mut scripts = BTreeMap::new();
for source in component_sources {
let root = Path::new(source);
if !root.is_dir() {
continue;
}
collect_component_scripts(root, &mut scripts)?;
}
Ok(scripts)
}
fn collect_component_scripts(
dir: &Path,
scripts: &mut BTreeMap<String, ComponentScript>,
) -> Result<()> {
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let entries = fs::read_dir(&d)
.map_err(|e| Error::Io(format!("Cannot read component dir {}: {e}", d.display())))?;
for entry in entries {
let entry = entry.map_err(|e| Error::Io(e.to_string()))?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if is_component_html_file(&path) {
let Some(tag) = path.file_stem().and_then(|name| name.to_str()) else {
continue;
};
let script_path = path.with_extension("ts");
if script_path.exists() {
let script_path = script_path.canonicalize().unwrap_or(script_path);
let html_content = fs::read_to_string(&path).map_err(|e| {
Error::Io(format!(
"Cannot read component template {}: {e}",
path.display()
))
})?;
scripts.entry(tag.to_string()).or_insert(ComponentScript {
html_content,
script_path,
});
}
}
}
}
Ok(())
}
pub(crate) fn collect_component_scripts_for_html(
html_fragments: &[&str],
component_scripts: &BTreeMap<String, ComponentScript>,
) -> Result<Vec<PathBuf>> {
if component_scripts.is_empty() {
return Ok(Vec::new());
}
let mut pending = Vec::new();
for html in html_fragments {
push_custom_element_tags(html, component_scripts, &mut pending);
}
let mut seen_tags = HashSet::with_capacity(pending.len());
let mut script_paths = Vec::new();
let mut cursor = 0;
while cursor < pending.len() {
let tag = pending[cursor].clone();
cursor += 1;
if !seen_tags.insert(tag.clone()) {
continue;
}
let Some(component) = component_scripts.get(&tag) else {
continue;
};
script_paths.push(component.script_path.clone());
push_custom_element_tags(&component.html_content, component_scripts, &mut pending);
}
Ok(script_paths)
}
pub(crate) fn page_bundle_signature(
component_scripts: &[PathBuf],
explicit_scripts: &[ScriptSource],
config_dir: &Path,
) -> String {
let mut signature =
String::with_capacity(component_scripts.len() * 96 + explicit_scripts.len() * 96);
let mut component_paths: Vec<String> = component_scripts
.iter()
.map(|path| path_for_js(path))
.collect();
component_paths.sort();
component_paths.dedup();
for path in component_paths {
signature.push_str("component:");
signature.push_str(&path);
signature.push('\n');
}
for source in explicit_scripts {
match source {
ScriptSource::Inline(code) => {
signature.push_str("inline:");
signature.push_str(&format!("{:x}", fxhash_bytes(code.as_bytes())));
signature.push('\n');
}
ScriptSource::File(path) => {
let resolved = config_dir.join(path);
let resolved = resolved.canonicalize().unwrap_or(resolved);
signature.push_str("file:");
signature.push_str(&path_for_js(&resolved));
signature.push('\n');
}
}
}
signature
}
fn push_custom_element_tags(
html: &str,
component_scripts: &BTreeMap<String, ComponentScript>,
tags: &mut Vec<String>,
) {
let bytes = html.as_bytes();
let mut cursor = 0;
while cursor < bytes.len() {
let Some(rel) = html[cursor..].find('<') else {
break;
};
let tag_start = cursor + rel;
let name_start = tag_start + 1;
let Some(&first) = bytes.get(name_start) else {
break;
};
if first == b'!' {
if bytes.get(name_start + 1) == Some(&b'-') && bytes.get(name_start + 2) == Some(&b'-')
{
if let Some(end_rel) = html[name_start + 3..].find("-->") {
cursor = name_start + 3 + end_rel + 3;
continue;
}
break;
}
cursor = name_start + 1;
continue;
}
if matches!(first, b'/' | b'?') {
cursor = name_start + 1;
continue;
}
let mut name_end = name_start;
let mut has_hyphen = false;
while name_end < bytes.len() && is_tag_name_byte(bytes[name_end]) {
if bytes[name_end] == b'-' {
has_hyphen = true;
}
name_end += 1;
}
if name_end == name_start {
cursor = name_start + 1;
continue;
}
let tag = &html[name_start..name_end];
if tag == "script" || tag == "style" {
let close = if tag == "script" {
"</script>"
} else {
"</style>"
};
if let Some(end_rel) = html[name_end..].find(close) {
cursor = name_end + end_rel + close.len();
continue;
}
break;
}
if has_hyphen && component_scripts.contains_key(tag) {
tags.push(tag.to_string());
}
cursor = name_end;
}
}
fn is_tag_name_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'-'
}
pub(crate) struct BundleResult {
pub(crate) root_script: Option<String>,
pub(crate) component_count: usize,
pub(crate) page_entry_count: usize,
pub(crate) script_map: HashMap<usize, Vec<String>>,
pub(crate) projection: webui::PreparedProjectionManifests,
pub(crate) preloads: BundlePreloadMap,
pub(crate) duration: std::time::Duration,
}
#[derive(Clone)]
pub(crate) struct BundlePreloadMap {
projection: webui::PreparedProjectionManifests,
root_entry: Option<PathBuf>,
page_entries: HashMap<usize, PathBuf>,
output_urls: Arc<BTreeMap<PathBuf, String>>,
}
impl BundlePreloadMap {
fn empty(projection: webui::PreparedProjectionManifests) -> Self {
Self {
projection,
root_entry: None,
page_entries: HashMap::new(),
output_urls: Arc::new(BTreeMap::new()),
}
}
pub(crate) fn resolve(
&self,
existing_hrefs: &[String],
page_entry_id: Option<usize>,
) -> (Vec<String>, Vec<webui::Diagnostic>) {
let page_entry = page_entry_id.and_then(|id| self.page_entries.get(&id));
match (self.root_entry.as_deref(), page_entry.map(PathBuf::as_path)) {
(Some(root), Some(page)) => webui::resolve_generated_module_preloads(
&self.projection,
existing_hrefs,
&[root, page],
&self.output_urls,
),
(Some(root), None) => webui::resolve_generated_module_preloads(
&self.projection,
existing_hrefs,
&[root],
&self.output_urls,
),
(None, Some(page)) => webui::resolve_generated_module_preloads(
&self.projection,
existing_hrefs,
&[page],
&self.output_urls,
),
(None, None) => (existing_hrefs.to_vec(), Vec::new()),
}
}
}
pub(crate) struct BundleOptions<'a> {
pub(crate) site_dir: &'a Path,
pub(crate) base_path: &'a str,
pub(crate) node_modules: Option<&'a Path>,
pub(crate) root_bundle: Option<&'a RootBundleEntry>,
pub(crate) page_bundles: &'a [PageBundleEntry],
pub(crate) bundler_config: Option<&'a BundlerConfig>,
pub(crate) dev_mode: bool,
pub(crate) config_dir: &'a Path,
pub(crate) content_dir: &'a Path,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct EsbuildRunnerConfig {
resolve_dir: String,
projection_entry: String,
manifest: String,
build: EsbuildJsOptions,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct EsbuildJsOptions {
abs_working_dir: String,
entry_points: Vec<String>,
bundle: bool,
platform: &'static str,
format: &'static str,
splitting: bool,
target: String,
outdir: String,
outbase: String,
entry_names: &'static str,
chunk_names: &'static str,
loader: BTreeMap<String, String>,
tsconfig_raw: serde_json::Value,
log_level: &'static str,
minify: bool,
external: Vec<String>,
define: BTreeMap<String, String>,
alias: BTreeMap<String, String>,
write: bool,
}
fn path_for_js(path: &Path) -> String {
let raw = path.to_string_lossy();
if let Some(rest) = raw.strip_prefix(r"\\?\UNC\") {
let mut out = String::with_capacity(rest.len() + 2);
out.push_str("//");
for ch in rest.chars() {
out.push(if ch == '\\' { '/' } else { ch });
}
return out;
}
let body = raw.strip_prefix(r"\\?\").unwrap_or(raw.as_ref());
body.replace('\\', "/")
}
fn push_import_once(entry: &mut String, imports: &mut HashSet<String>, specifier: &str) {
if imports.insert(specifier.to_string()) {
entry.push_str("import \"");
entry.push_str(specifier);
entry.push_str("\";\n");
}
}
#[cfg(test)]
fn push_external_args(
args: &mut Vec<String>,
external: &[String],
aliases: &BTreeMap<String, String>,
) {
for ext in external {
if aliases.contains_key(ext.as_str()) {
continue;
}
args.push(format!("--external:{ext}"));
}
}
fn file_version(path: &Path) -> Result<String> {
let bytes =
fs::read(path).map_err(|e| Error::Io(format!("Cannot read {}: {e}", path.display())))?;
Ok(format!("{:x}", fxhash_bytes(&bytes)))
}
fn versioned_asset_path(rel_path: &str, full_path: &Path) -> Result<String> {
let version = file_version(full_path)?;
Ok(format!("{rel_path}?v={version}"))
}
fn served_asset_path(base_path: &str, rel_path: &str) -> String {
let mut served = String::with_capacity(base_path.len() + rel_path.len() + 1);
if base_path.is_empty() || base_path == "/" {
served.push('/');
} else {
served.push_str(base_path.trim_end_matches('/'));
served.push('/');
}
served.push_str(rel_path.trim_start_matches('/'));
served
}
fn has_allowed_extension(path: &Path, allowed: &[&str]) -> bool {
let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
return false;
};
allowed
.iter()
.any(|allowed_ext| ext.eq_ignore_ascii_case(allowed_ext))
}
fn resolve_existing_path(path: &Path) -> Result<PathBuf> {
path.canonicalize().map_err(|e| {
Error::Build(format!(
"Cannot resolve script path {}: {e}",
path.display()
))
})
}
fn resolve_allowed_root(path: &Path) -> Result<Option<PathBuf>> {
if !path.exists() {
return Ok(None);
}
Ok(Some(path.canonicalize().map_err(|e| {
Error::Build(format!(
"Cannot canonicalize allowed script root {}: {e}",
path.display()
))
})?))
}
fn allowed_script_roots(config_dir: &Path, content_dir: &Path) -> Result<Vec<PathBuf>> {
let mut roots = Vec::with_capacity(2);
if let Some(root) = resolve_allowed_root(config_dir)? {
roots.push(root);
}
if let Some(root) = resolve_allowed_root(content_dir)? {
if !roots.iter().any(|existing| existing == &root) {
roots.push(root);
}
}
Ok(roots)
}
fn path_is_under_root(path: &Path, root: &Path) -> bool {
path == root || path.starts_with(root)
}
fn resolve_bundle_script_file(
config_dir: &Path,
content_dir: &Path,
path: &str,
) -> Result<PathBuf> {
let requested = Path::new(path);
if requested.is_absolute() {
return Err(Error::Build(format!(
"Bundled script path {path} must be relative to config.json"
)));
}
if !has_allowed_extension(requested, BUNDLE_SCRIPT_EXTENSIONS) {
return Err(Error::Build(format!(
"Bundled script path {path} must use one of these extensions: {}",
BUNDLE_SCRIPT_EXTENSIONS.join(", ")
)));
}
let resolved = resolve_existing_path(&config_dir.join(requested))?;
let roots = allowed_script_roots(config_dir, content_dir)?;
if roots.iter().any(|root| path_is_under_root(&resolved, root)) {
Ok(resolved)
} else {
Err(Error::Build(format!(
"Bundled script path {} resolves outside the docs project",
resolved.display()
)))
}
}
fn package_import_prefix_len(specifier: &str) -> usize {
if specifier.starts_with('@') {
let mut slash_count = 0;
for (idx, byte) in specifier.bytes().enumerate() {
if byte == b'/' {
slash_count += 1;
if slash_count == 2 {
return idx;
}
}
}
return specifier.len();
}
specifier.find('/').unwrap_or(specifier.len())
}
fn is_external_or_alias_import(specifier: &str, cfg: Option<&BundlerConfig>) -> bool {
let Some(cfg) = cfg else {
return false;
};
if cfg.alias.contains_key(specifier)
|| cfg.external.iter().any(|external| external == specifier)
{
return true;
}
let prefix_len = package_import_prefix_len(specifier);
let prefix = &specifier[..prefix_len];
cfg.alias.contains_key(prefix) || cfg.external.iter().any(|external| external == prefix)
}
fn validate_script_graph_imports(
script_path: &Path,
allowed_roots: &[PathBuf],
bundler_config: Option<&BundlerConfig>,
) -> Result<()> {
let mut pending = vec![script_path.to_path_buf()];
let mut seen = HashSet::with_capacity(8);
while let Some(path) = pending.pop() {
let resolved = resolve_existing_path(&path)?;
if !seen.insert(resolved.clone())
|| !has_allowed_extension(&resolved, SCRIPT_GRAPH_EXTENSIONS)
{
continue;
}
let contents = fs::read_to_string(&resolved)
.map_err(|e| Error::Io(format!("Cannot read {}: {e}", resolved.display())))?;
validate_script_imports(
&contents,
&resolved,
allowed_roots,
bundler_config,
&mut pending,
)?;
}
Ok(())
}
fn validate_script_imports(
contents: &str,
owner: &Path,
allowed_roots: &[PathBuf],
bundler_config: Option<&BundlerConfig>,
pending: &mut Vec<PathBuf>,
) -> Result<()> {
for specifier in import_specifiers(contents) {
if specifier.starts_with('.') {
let Some(parent) = owner.parent() else {
continue;
};
let resolved = resolve_existing_path(&parent.join(specifier))?;
if !allowed_roots
.iter()
.any(|root| path_is_under_root(&resolved, root))
{
return Err(Error::Build(format!(
"Bundled script import {specifier} in {} resolves outside the docs project",
owner.display()
)));
}
pending.push(resolved);
} else if Path::new(specifier).is_absolute()
&& !is_external_or_alias_import(specifier, bundler_config)
{
return Err(Error::Build(format!(
"Bundled script import {specifier} in {} must not be an absolute filesystem path",
owner.display()
)));
}
}
Ok(())
}
fn validate_inline_script_imports(
code: &str,
config_dir: &Path,
allowed_roots: &[PathBuf],
bundler_config: Option<&BundlerConfig>,
) -> Result<()> {
let mut pending = Vec::new();
validate_script_imports(
code,
&config_dir.join("<inline bundle script>"),
allowed_roots,
bundler_config,
&mut pending,
)?;
let mut seen = HashSet::with_capacity(pending.len());
while let Some(path) = pending.pop() {
let resolved = resolve_existing_path(&path)?;
if !seen.insert(resolved.clone())
|| !has_allowed_extension(&resolved, SCRIPT_GRAPH_EXTENSIONS)
{
continue;
}
let contents = fs::read_to_string(&resolved)
.map_err(|e| Error::Io(format!("Cannot read {}: {e}", resolved.display())))?;
validate_script_imports(
&contents,
&resolved,
allowed_roots,
bundler_config,
&mut pending,
)?;
}
Ok(())
}
fn versioned_script_paths(site_dir: &Path, rel_paths: &[String]) -> Result<Vec<String>> {
let mut versioned = Vec::with_capacity(rel_paths.len());
for rel_path in rel_paths {
let full_path = site_dir.join(rel_path);
if !full_path.exists() {
return Err(Error::Build(format!(
"Bundled chunk output missing: {}",
full_path.display()
)));
}
versioned.push(versioned_asset_path(rel_path, &full_path)?);
}
Ok(versioned)
}
fn skip_js_whitespace(input: &str, mut cursor: usize) -> usize {
let bytes = input.as_bytes();
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
cursor
}
fn skip_js_trivia(input: &str, mut cursor: usize) -> usize {
let bytes = input.as_bytes();
loop {
while cursor < bytes.len() && (bytes[cursor].is_ascii_whitespace() || bytes[cursor] == b';')
{
cursor += 1;
}
if cursor + 1 >= bytes.len() || bytes[cursor] != b'/' {
return cursor;
}
if bytes[cursor + 1] == b'/' {
cursor += 2;
while cursor < bytes.len() && bytes[cursor] != b'\n' {
cursor += 1;
}
continue;
}
if bytes[cursor + 1] == b'*' {
if let Some(end_rel) = input[cursor + 2..].find("*/") {
cursor += end_rel + 4;
continue;
}
return input.len();
}
return cursor;
}
}
fn quoted_js_string_at(input: &str, cursor: usize) -> Option<(&str, usize)> {
let bytes = input.as_bytes();
let quote = *bytes.get(cursor)?;
if quote != b'"' && quote != b'\'' {
return None;
}
let start = cursor + 1;
let mut end = start;
while end < bytes.len() {
if bytes[end] == b'\\' {
end += 2;
continue;
}
if bytes[end] == quote {
return Some((&input[start..end], end + 1));
}
end += 1;
}
None
}
fn next_leading_import_statement<'a>(input: &'a str, cursor: &mut usize) -> Option<&'a str> {
let start = skip_js_trivia(input, *cursor);
if !is_import_keyword_at(input, start) {
*cursor = start;
return None;
}
let after_import = start + "import".len();
if let Some(byte) = input.as_bytes().get(after_import) {
if byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'$' {
*cursor = start;
return None;
}
}
let end_rel = input[after_import..].find(';')?;
let end = after_import + end_rel;
*cursor = end + 1;
Some(&input[start..=end])
}
fn is_identifier_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$'
}
fn is_import_keyword_at(input: &str, cursor: usize) -> bool {
if !input[cursor..].starts_with("import") {
return false;
}
let bytes = input.as_bytes();
if cursor > 0 && is_identifier_byte(bytes[cursor - 1]) {
return false;
}
let after = cursor + "import".len();
!bytes
.get(after)
.is_some_and(|byte| is_identifier_byte(*byte))
}
fn skip_quoted_js(input: &str, mut cursor: usize, quote: u8) -> usize {
let bytes = input.as_bytes();
cursor += 1;
while cursor < bytes.len() {
if bytes[cursor] == b'\\' {
cursor += 2;
} else if bytes[cursor] == quote {
return cursor + 1;
} else {
cursor += 1;
}
}
cursor
}
fn skip_line_comment(input: &str, mut cursor: usize) -> usize {
let bytes = input.as_bytes();
cursor += 2;
while cursor < bytes.len() && bytes[cursor] != b'\n' {
cursor += 1;
}
cursor
}
fn skip_block_comment(input: &str, cursor: usize) -> usize {
if let Some(end_rel) = input[cursor + 2..].find("*/") {
cursor + end_rel + 4
} else {
input.len()
}
}
fn skip_js_non_code(input: &str, cursor: usize) -> Option<usize> {
let bytes = input.as_bytes();
match bytes[cursor] {
b'\'' | b'"' | b'`' => Some(skip_quoted_js(input, cursor, bytes[cursor])),
b'/' if cursor + 1 < bytes.len() && bytes[cursor + 1] == b'/' => {
Some(skip_line_comment(input, cursor))
}
b'/' if cursor + 1 < bytes.len() && bytes[cursor + 1] == b'*' => {
Some(skip_block_comment(input, cursor))
}
_ => None,
}
}
fn js_statement_end(input: &str, mut cursor: usize) -> usize {
let bytes = input.as_bytes();
while cursor < bytes.len() {
if let Some(next) = skip_js_non_code(input, cursor) {
cursor = next;
} else if bytes[cursor] == b';' {
return cursor + 1;
} else {
cursor += 1;
}
}
cursor
}
fn import_specifiers(input: &str) -> Vec<&str> {
let bytes = input.as_bytes();
let mut specifiers = Vec::new();
let mut cursor = 0;
while cursor < bytes.len() {
if let Some(next) = skip_js_non_code(input, cursor) {
cursor = next;
continue;
}
if !is_import_keyword_at(input, cursor) {
cursor += 1;
continue;
}
let mut after_import = skip_js_whitespace(input, cursor + "import".len());
if let Some((specifier, end)) = quoted_js_string_at(input, after_import) {
specifiers.push(specifier);
cursor = end;
continue;
}
if bytes.get(after_import) == Some(&b'(') {
after_import = skip_js_whitespace(input, after_import + 1);
if let Some((specifier, end)) = quoted_js_string_at(input, after_import) {
specifiers.push(specifier);
cursor = end;
continue;
}
}
let statement_end = js_statement_end(input, after_import);
if let Some(specifier) = static_import_specifier(&input[cursor..statement_end]) {
specifiers.push(specifier);
}
cursor = statement_end.max(cursor + "import".len());
}
specifiers
}
fn side_effect_import_specifier(statement: &str) -> Option<&str> {
let cursor = skip_js_whitespace(statement, "import".len());
quoted_js_string_at(statement, cursor).map(|(specifier, _)| specifier)
}
fn static_import_specifier(statement: &str) -> Option<&str> {
if let Some(specifier) = side_effect_import_specifier(statement) {
return Some(specifier);
}
let from_pos = statement.rfind("from")?;
let cursor = skip_js_whitespace(statement, from_pos + "from".len());
quoted_js_string_at(statement, cursor).map(|(specifier, _)| specifier)
}
fn resolve_js_import_path(importer_rel: &str, specifier: &str) -> Option<String> {
if !specifier.starts_with("./") && !specifier.starts_with("../") {
return None;
}
let parent = importer_rel
.rsplit_once('/')
.map_or("", |(parent, _)| parent);
let mut parts = Vec::new();
for part in parent.split('/') {
if !part.is_empty() {
parts.push(part);
}
}
for part in specifier.split('/') {
match part {
"" | "." => {}
".." => {
parts.pop();
}
_ => parts.push(part),
}
}
Some(parts.join("/"))
}
fn collect_leading_relative_imports(
input: &str,
importer_rel: &str,
imports: &mut HashSet<String>,
) {
let mut cursor = 0;
while let Some(statement) = next_leading_import_statement(input, &mut cursor) {
if let Some(specifier) = static_import_specifier(statement) {
if let Some(rel_path) = resolve_js_import_path(importer_rel, specifier) {
imports.insert(rel_path);
}
}
}
}
fn import_only_relative_imports(input: &str, importer_rel: &str) -> Option<Vec<String>> {
let mut cursor = 0;
let mut imports = Vec::new();
loop {
cursor = skip_js_trivia(input, cursor);
if cursor >= input.len() {
return if imports.is_empty() {
None
} else {
Some(imports)
};
}
let statement = next_leading_import_statement(input, &mut cursor)?;
let specifier = side_effect_import_specifier(statement)?;
let rel_path = resolve_js_import_path(importer_rel, specifier)?;
imports.push(rel_path);
}
}
fn prune_redundant_imports(
site_dir: &Path,
root_imports: &HashSet<String>,
imports: &[String],
) -> Result<Vec<String>> {
let mut transitive_imports = HashSet::new();
for rel_path in imports {
let full_path = site_dir.join(rel_path);
if !full_path.exists() {
return Err(Error::Build(format!(
"Bundled chunk output missing: {}",
full_path.display()
)));
}
let contents = fs::read_to_string(&full_path)
.map_err(|e| Error::Io(format!("Cannot read {}: {e}", full_path.display())))?;
collect_leading_relative_imports(&contents, rel_path, &mut transitive_imports);
}
let mut seen = HashSet::with_capacity(imports.len());
let mut retained = Vec::with_capacity(imports.len());
for rel_path in imports {
if root_imports.contains(rel_path) || transitive_imports.contains(rel_path) {
continue;
}
if seen.insert(rel_path.clone()) {
retained.push(rel_path.clone());
}
}
Ok(retained)
}
fn page_script_paths(
site_dir: &Path,
output_file: &str,
full_path: &Path,
root_imports: &HashSet<String>,
) -> Result<Vec<String>> {
let contents = fs::read_to_string(full_path)
.map_err(|e| Error::Io(format!("Cannot read {}: {e}", full_path.display())))?;
if let Some(imports) = import_only_relative_imports(&contents, output_file) {
let rel_paths = prune_redundant_imports(site_dir, root_imports, &imports)?;
fs::remove_file(full_path).map_err(|e| {
Error::Io(format!(
"Cannot remove import-only script wrapper {}: {e}",
full_path.display()
))
})?;
versioned_script_paths(site_dir, &rel_paths)
} else {
Ok(vec![versioned_asset_path(output_file, full_path)?])
}
}
fn next_rebuild_suffix_hex() -> String {
format!(
"{:x}",
BUNDLE_REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
)
}
fn bundle_temp_path(site_dir: &Path, unique_suffix: &str) -> PathBuf {
site_dir.join(format!(
".webui-press-bundle-{}-{unique_suffix}",
std::process::id()
))
}
fn resolved_bundle_temp_path(site_dir: &Path, unique_suffix: &str) -> Result<PathBuf> {
Ok(bundle_temp_path(&absolute_path(site_dir)?, unique_suffix))
}
fn external_projection_sources(opts: &BundleOptions<'_>) -> Vec<webui::ProjectionManifestSource> {
opts.bundler_config
.map(|config| {
config
.projection_manifests
.iter()
.map(|manifest| {
webui::ProjectionManifestSource::Path(opts.config_dir.join(manifest))
})
.collect()
})
.unwrap_or_default()
}
fn collect_versioned_output_urls(
site_dir: &Path,
base_path: &str,
outputs: &[PathBuf],
) -> Result<BTreeMap<PathBuf, String>> {
let canonical_site = site_dir
.canonicalize()
.map_err(|error| Error::Io(format!("Cannot resolve {}: {error}", site_dir.display())))?;
let mut output_urls = BTreeMap::new();
for output in outputs {
let relative = output.strip_prefix(&canonical_site).map_err(|_| {
Error::Build(format!(
"Bundled output is outside the site directory: {}",
output.display()
))
})?;
let relative = path_for_js(relative);
let versioned = versioned_asset_path(&relative, output)?;
output_urls.insert(output.clone(), served_asset_path(base_path, &versioned));
}
Ok(output_urls)
}
pub(crate) fn resolve_node_modules(config_dir: &Path) -> Result<PathBuf> {
let start = config_dir.canonicalize().map_err(|e| {
Error::Build(format!(
"Cannot resolve config directory {} while locating node_modules: {e}",
config_dir.display()
))
})?;
for dir in start.ancestors() {
let node_modules = dir.join("node_modules");
if node_modules.exists() {
return Ok(node_modules);
}
}
Err(Error::Build(format!(
"Cannot find node_modules in {} or its ancestors. Run pnpm install from the docs project root before building bundled scripts.",
start.display()
)))
}
fn default_framework_alias(node_modules: &Path) -> Option<PathBuf> {
let pkg = node_modules.join("@microsoft").join("webui-framework");
let dist = pkg.join("dist").join("index.js");
if dist.exists() {
return Some(dist);
}
let src = pkg.join("src").join("index.ts");
if src.exists() {
return Some(src);
}
None
}
fn normalized_alias_target(config_dir: &Path, target: &str) -> String {
let path = Path::new(target);
if path.is_absolute() {
return target.replace('\\', "/");
}
if target.starts_with('.') {
return path_for_js(&config_dir.join(path));
}
target.replace('\\', "/")
}
fn build_aliases(opts: &BundleOptions<'_>) -> Result<BTreeMap<String, String>> {
let config_dir = absolute_path(opts.config_dir)?;
let mut aliases: BTreeMap<String, String> = BTreeMap::new();
if let Some(node_modules) = opts.node_modules {
if let Some(path) = default_framework_alias(node_modules) {
aliases.insert("@microsoft/webui-framework".to_string(), path_for_js(&path));
}
}
if let Some(cfg) = opts.bundler_config {
for (from, to) in &cfg.alias {
aliases.insert(from.clone(), normalized_alias_target(&config_dir, to));
}
}
Ok(aliases)
}
#[cfg(test)]
fn push_alias_args(args: &mut Vec<String>, aliases: &BTreeMap<String, String>) {
for (from, to) in aliases {
args.push(format!("--alias:{from}={to}"));
}
}
#[cfg(test)]
fn push_define_args(args: &mut Vec<String>, cfg: &BundlerConfig) {
for (key, value) in &cfg.define {
args.push(format!("--define:{key}={value}"));
}
}
#[cfg(test)]
fn esbuild_args(
opts: &BundleOptions<'_>,
entry_files: &[(String, PathBuf)],
bundle_tmp: &Path,
) -> Result<Vec<String>> {
let aliases = build_aliases(opts)?;
let target = opts
.bundler_config
.and_then(|cfg| cfg.target.as_deref())
.unwrap_or("es2022");
let mut args = Vec::with_capacity(14 + entry_files.len());
args.push("--bundle".to_string());
args.push("--platform=browser".to_string());
args.push("--format=esm".to_string());
args.push("--splitting".to_string());
args.push(format!("--target={target}"));
args.push(format!("--outdir={}", path_for_js(opts.site_dir)));
args.push(format!("--outbase={}", path_for_js(bundle_tmp)));
args.push("--entry-names=[dir]/[name]".to_string());
args.push("--chunk-names=assets/[name]-[hash]".to_string());
args.push("--loader:.html=text".to_string());
args.push("--loader:.css=text".to_string());
args.push(format!("--tsconfig-raw={WEBUI_TSCONFIG_RAW}"));
args.push("--log-level=warning".to_string());
if !opts.dev_mode {
args.push("--minify".to_string());
args.push("--define:__WEBUI_DEV__=false".to_string());
}
if let Some(cfg) = opts.bundler_config {
push_external_args(&mut args, &cfg.external, &aliases);
push_define_args(&mut args, cfg);
}
push_alias_args(&mut args, &aliases);
for (_, path) in entry_files {
args.push(path_for_js(path));
}
Ok(args)
}
fn esbuild_build_config(
opts: &BundleOptions<'_>,
node_modules: &Path,
entry_files: &[(String, PathBuf)],
bundle_tmp: &Path,
manifest_path: &Path,
) -> Result<serde_json::Value> {
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 target = opts
.bundler_config
.and_then(|cfg| cfg.target.as_deref())
.unwrap_or("es2022");
let external: Vec<String> = opts
.bundler_config
.map(|cfg| {
cfg.external
.iter()
.filter(|value| !aliases.contains_key(value.as_str()))
.cloned()
.collect()
})
.unwrap_or_default();
let mut define = BTreeMap::new();
if !opts.dev_mode {
define.insert("__WEBUI_DEV__".to_string(), "false".to_string());
}
if let Some(cfg) = opts.bundler_config {
for (key, value) in &cfg.define {
define.insert(key.clone(), value.clone());
}
}
let tsconfig_raw: serde_json::Value = serde_json::from_str(WEBUI_TSCONFIG_RAW)
.map_err(|error| Error::Build(format!("Invalid WebUI tsconfig: {error}")))?;
let projection_entry = projection_package_entry(node_modules)?;
let loader = BTreeMap::from([
(".css".to_string(), "text".to_string()),
(".html".to_string(), "text".to_string()),
]);
serde_json::to_value(EsbuildRunnerConfig {
resolve_dir: path_for_js(&working_dir),
projection_entry,
manifest: path_for_js(&manifest_path),
build: EsbuildJsOptions {
abs_working_dir: path_for_js(&working_dir),
entry_points: entry_files
.iter()
.map(|(_, path)| path_for_js(path))
.collect(),
bundle: true,
platform: "browser",
format: "esm",
splitting: true,
target: target.to_string(),
outdir: path_for_js(&site_dir),
outbase: path_for_js(bundle_tmp),
entry_names: "[dir]/[name]",
chunk_names: "assets/[name]-[hash]",
loader,
tsconfig_raw,
log_level: "warning",
minify: !opts.dev_mode,
external,
define,
alias: aliases,
write: true,
},
})
.map_err(|error| Error::Build(format!("Cannot serialize esbuild config: {error}")))
}
fn absolute_path(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
return Ok(path.to_path_buf());
}
std::env::current_dir()
.map(|cwd| cwd.join(path))
.map_err(|error| Error::Io(format!("Cannot resolve current directory: {error}")))
}
fn projection_package_entry(node_modules: &Path) -> Result<String> {
let package_dir = node_modules.join("@microsoft").join("webui");
let package_json_path = package_dir.join("package.json");
let package_json = fs::read_to_string(&package_json_path).map_err(|error| {
Error::Build(format!(
"Cannot read {}. Install @microsoft/webui in the docs project: {error}",
package_json_path.display()
))
})?;
let value: serde_json::Value = serde_json::from_str(&package_json)
.map_err(|error| Error::Build(format!("Invalid @microsoft/webui package.json: {error}")))?;
let export = value
.pointer("/exports/.~1projection.js/import/default")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
Error::Build(
"@microsoft/webui does not export ./projection.js for ESM imports".to_string(),
)
})?;
let entry = package_dir.join(export);
if !entry.exists() {
return Err(Error::Build(format!(
"@microsoft/webui projection entry is missing: {}. Build the package first.",
entry.display()
)));
}
Ok(entry.to_string_lossy().into_owned())
}
fn run_esbuild_with_projection(
opts: &BundleOptions<'_>,
node_modules: &Path,
entry_files: &[(String, PathBuf)],
bundle_tmp: &Path,
manifest_path: &Path,
) -> Result<()> {
let runner_path = bundle_tmp.join("webui-press-esbuild.mjs");
let config_path = bundle_tmp.join("webui-press-esbuild.json");
fs::write(&runner_path, ESBUILD_RUNNER)
.map_err(|error| Error::Build(format!("Cannot write esbuild runner: {error}")))?;
let config = esbuild_build_config(opts, node_modules, entry_files, bundle_tmp, manifest_path)?;
fs::write(
&config_path,
serde_json::to_vec(&config)
.map_err(|error| Error::Build(format!("Cannot serialize esbuild config: {error}")))?,
)
.map_err(|error| Error::Build(format!("Cannot write esbuild config: {error}")))?;
let output = std::process::Command::new("node")
.arg(&runner_path)
.arg(&config_path)
.current_dir(absolute_path(opts.config_dir)?)
.env("NODE_PATH", node_modules)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|error| Error::Build(format!("esbuild failed to start: {error}")))?;
if !output.status.success() {
return Err(Error::Build(format!(
"esbuild projection error: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
if std::env::var_os("WEBUI_PROJECTION_PROFILE").is_some() && !output.stderr.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
Ok(())
}
pub(crate) fn bundle_assets(opts: &BundleOptions<'_>) -> Result<BundleResult> {
let started = Instant::now();
if opts.root_bundle.is_none() && opts.page_bundles.is_empty() {
let projection = webui::prepare_projection_manifests(&external_projection_sources(opts))
.map_err(|error| Error::Build(error.chain_message()))?;
let preloads = BundlePreloadMap::empty(projection.clone());
return Ok(BundleResult {
root_script: None,
component_count: 0,
page_entry_count: 0,
script_map: HashMap::new(),
projection,
preloads,
duration: started.elapsed(),
});
}
let Some(node_modules) = opts.node_modules else {
return Err(Error::Build(
"Bundled scripts require node_modules. Run pnpm install from the docs project root."
.to_string(),
));
};
let allowed_roots = allowed_script_roots(opts.config_dir, opts.content_dir)?;
let unique_suffix = next_rebuild_suffix_hex();
let bundle_tmp = resolved_bundle_temp_path(opts.site_dir, &unique_suffix)?;
if bundle_tmp.exists() {
fs::remove_dir_all(&bundle_tmp).ok();
}
fs::create_dir_all(&bundle_tmp)
.map_err(|e| Error::Build(format!("Cannot create bundle temp dir: {e}")))?;
let bundle_tmp = TempDirGuard::new(bundle_tmp);
let assets_dir = opts.site_dir.join("assets");
fs::create_dir_all(&assets_dir)
.map_err(|e| Error::Io(format!("Cannot create assets dir: {e}")))?;
let mut entry_files: Vec<(String, std::path::PathBuf)> = Vec::new();
let mut component_imports = HashSet::new();
if let Some(root) = opts.root_bundle {
let entry_path = bundle_tmp.path().join("index.ts");
let mut entry = String::with_capacity(32 + root.component_scripts.len() * 80);
entry.push_str("// Root template script\n");
let mut imports = HashSet::with_capacity(
root.component_scripts.len() + usize::from(root.script_path.is_some()),
);
if let Some(path) = root.script_path.as_ref() {
let specifier = path_for_js(path);
push_import_once(&mut entry, &mut imports, &specifier);
}
for path in &root.component_scripts {
component_imports.insert(path.clone());
let specifier = path_for_js(path);
push_import_once(&mut entry, &mut imports, &specifier);
}
fs::write(&entry_path, &entry)
.map_err(|e| Error::Build(format!("Cannot write root script entry: {e}")))?;
entry_files.push(("index".to_string(), entry_path));
}
for bundle in opts.page_bundles {
let entry_name = format!("assets/page-{}", bundle.id);
let entry_path = bundle_tmp.path().join(format!("{entry_name}.ts"));
if let Some(parent) = entry_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| Error::Build(format!("Cannot create script entry dir: {e}")))?;
}
let mut entry = String::with_capacity(
32 + bundle.page_path.len()
+ (bundle.component_scripts.len() + bundle.explicit_scripts.len()) * 80,
);
entry.push_str("// Page: ");
entry.push_str(&bundle.page_path);
entry.push('\n');
let mut imports =
HashSet::with_capacity(bundle.component_scripts.len() + bundle.explicit_scripts.len());
for path in &bundle.component_scripts {
component_imports.insert(path.clone());
let specifier = path_for_js(path);
push_import_once(&mut entry, &mut imports, &specifier);
}
for (idx, source) in bundle.explicit_scripts.iter().enumerate() {
match source {
ScriptSource::Inline(code) => {
validate_inline_script_imports(
code,
opts.config_dir,
&allowed_roots,
opts.bundler_config,
)?;
let inline_path = bundle_tmp
.path()
.join(format!("inline/page-{}-{idx}.ts", bundle.id));
if let Some(parent) = inline_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Error::Build(format!("Cannot create inline script dir: {e}"))
})?;
}
fs::write(&inline_path, code)
.map_err(|e| Error::Build(format!("Cannot write inline script: {e}")))?;
let specifier = path_for_js(&inline_path);
push_import_once(&mut entry, &mut imports, &specifier);
}
ScriptSource::File(path) => {
let abs_path =
resolve_bundle_script_file(opts.config_dir, opts.content_dir, path)?;
validate_script_graph_imports(&abs_path, &allowed_roots, opts.bundler_config)?;
let specifier = path_for_js(&abs_path);
push_import_once(&mut entry, &mut imports, &specifier);
}
}
}
fs::write(&entry_path, &entry)
.map_err(|e| Error::Build(format!("Cannot write script entry: {e}")))?;
entry_files.push((entry_name, entry_path));
}
let manifest_path = opts.site_dir.join("webui-projection.json");
run_esbuild_with_projection(
opts,
node_modules,
&entry_files,
bundle_tmp.path(),
&manifest_path,
)?;
let mut projection_sources = Vec::with_capacity(
1 + opts
.bundler_config
.map_or(0, |cfg| cfg.projection_manifests.len()),
);
projection_sources.push(webui::ProjectionManifestSource::Path(manifest_path.clone()));
projection_sources.extend(external_projection_sources(opts));
let projection_result = webui::prepare_projection_manifests(&projection_sources)
.map_err(|error| Error::Build(error.chain_message()));
fs::remove_file(&manifest_path).ok();
let projection = projection_result?;
let root_entry = if opts.root_bundle.is_some() {
Some(
opts.site_dir
.join("index.js")
.canonicalize()
.map_err(|error| {
Error::Build(format!("Cannot resolve generated root entry: {error}"))
})?,
)
} else {
None
};
let mut page_entries = HashMap::with_capacity(opts.page_bundles.len());
for bundle in opts.page_bundles {
let path = opts
.site_dir
.join(format!("assets/page-{}.js", bundle.id))
.canonicalize()
.map_err(|error| {
Error::Build(format!(
"Cannot resolve generated page entry {}: {error}",
bundle.id
))
})?;
page_entries.insert(bundle.id, path);
}
let mut generated_entries =
Vec::with_capacity(usize::from(root_entry.is_some()) + page_entries.len());
if let Some(root) = root_entry.as_deref() {
generated_entries.push(root);
}
generated_entries.extend(page_entries.values().map(PathBuf::as_path));
let preload_outputs = webui::generated_module_preload_outputs(&projection, &generated_entries);
let output_urls = Arc::new(collect_versioned_output_urls(
opts.site_dir,
opts.base_path,
&preload_outputs,
)?);
let preloads = BundlePreloadMap {
projection: projection.clone(),
root_entry,
page_entries,
output_urls,
};
let mut root_imports = HashSet::new();
let root_script = if opts.root_bundle.is_some() {
let output_file = "index.js";
let full_path = opts.site_dir.join(output_file);
if full_path.exists() {
let contents = fs::read_to_string(&full_path)
.map_err(|e| Error::Io(format!("Cannot read {}: {e}", full_path.display())))?;
collect_leading_relative_imports(&contents, output_file, &mut root_imports);
Some(versioned_asset_path(output_file, &full_path)?)
} else {
return Err(Error::Build(format!(
"Bundled root script output missing: {}",
full_path.display()
)));
}
} else {
None
};
let mut script_map = HashMap::with_capacity(opts.page_bundles.len());
for bundle in opts.page_bundles {
let entry_name = format!("page-{}", bundle.id);
let output_file = format!("assets/{entry_name}.js");
let full_path = opts.site_dir.join(&output_file);
if full_path.exists() {
script_map.insert(
bundle.id,
page_script_paths(opts.site_dir, &output_file, &full_path, &root_imports)?,
);
} else {
return Err(Error::Build(format!(
"Bundled script output missing: {}",
full_path.display()
)));
}
}
Ok(BundleResult {
root_script,
component_count: component_imports.len(),
page_entry_count: opts.page_bundles.len(),
script_map,
projection,
preloads,
duration: started.elapsed(),
})
}
#[cfg(test)]
fn esbuild_command(node_modules: &Path) -> EsbuildCommand {
if let Some(project_dir) = node_modules.parent() {
for dir in project_dir.ancestors() {
if cfg!(windows) {
let candidate = dir
.join("node_modules")
.join("esbuild")
.join("bin")
.join("esbuild");
if candidate.exists() {
return EsbuildCommand {
program: PathBuf::from("node"),
prefix_args: vec![path_for_js(&candidate)],
};
}
continue;
}
let candidate = dir.join("node_modules").join(".bin").join("esbuild");
if candidate.exists() {
return EsbuildCommand {
program: candidate,
prefix_args: Vec::new(),
};
}
}
}
EsbuildCommand {
program: PathBuf::from(if cfg!(windows) {
"esbuild.cmd"
} else {
"esbuild"
}),
prefix_args: Vec::new(),
}
}
pub(crate) fn extract_bundle_scripts(content: &str) -> (String, Vec<ScriptSource>) {
let mut scripts = Vec::new();
let mut out = String::with_capacity(content.len());
let mut cursor = 0;
let bytes = content.as_bytes();
while cursor < bytes.len() {
let Some(rel) = content[cursor..].find("<script") else {
break;
};
let tag_start = cursor + rel;
let after_tag = tag_start + 7;
match bytes.get(after_tag) {
Some(b' ' | b'\t' | b'\n' | b'\r' | b'>') => {}
_ => {
out.push_str(&content[cursor..after_tag]);
cursor = after_tag;
continue;
}
}
let Some(gt_rel) = content[after_tag..].find('>') else {
break;
};
let gt_pos = after_tag + gt_rel;
let attrs_region = &content[after_tag..gt_pos];
if !has_bundle_attr(attrs_region) {
out.push_str(&content[cursor..gt_pos + 1]);
cursor = gt_pos + 1;
continue;
}
let Some(close_rel) = content[gt_pos + 1..].find("</script>") else {
break;
};
let close_start = gt_pos + 1 + close_rel;
let close_end = close_start + "</script>".len();
let src_attr = extract_src_attr(attrs_region);
let inline_body = &content[gt_pos + 1..close_start];
let source = if let Some(src) = src_attr {
ScriptSource::File(src.to_string())
} else if !inline_body.trim().is_empty() {
ScriptSource::Inline(inline_body.to_string())
} else {
out.push_str(&content[cursor..close_end]);
cursor = close_end;
continue;
};
scripts.push(source);
out.push_str(&content[cursor..tag_start]);
cursor = close_end;
}
out.push_str(&content[cursor..]);
(out, scripts)
}
fn has_bundle_attr(attrs: &str) -> bool {
let bytes = attrs.as_bytes();
let target = b"bundle";
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b'/') {
i += 1;
}
if i >= bytes.len() {
break;
}
let name_start = i;
while i < bytes.len()
&& !matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b'=' | b'/' | b'>')
{
i += 1;
}
if name_start == i {
i += 1;
continue;
}
if i - name_start == target.len() && &bytes[name_start..i] == target {
return true;
}
while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r') {
i += 1;
}
if i < bytes.len() && bytes[i] == b'=' {
i += 1;
while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r') {
i += 1;
}
if i < bytes.len() && matches!(bytes[i], b'"' | b'\'') {
let quote = bytes[i];
i += 1;
while i < bytes.len() && bytes[i] != quote {
i += 1;
}
if i < bytes.len() {
i += 1;
}
} else {
while i < bytes.len() && !matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r') {
i += 1;
}
}
}
}
false
}
fn extract_src_attr(attrs: &str) -> Option<&str> {
let bytes = attrs.as_bytes();
let mut i = 0;
while i + 4 <= bytes.len() {
if let Some(rel) = attrs[i..].find("src") {
let pos = i + rel;
let before_ok = pos == 0 || matches!(bytes[pos - 1], b' ' | b'\t' | b'\n' | b'\r');
let after_src = pos + 3;
if !before_ok || after_src >= bytes.len() {
i = pos + 1;
continue;
}
let mut eq = after_src;
while eq < bytes.len() && matches!(bytes[eq], b' ' | b'\t') {
eq += 1;
}
if eq >= bytes.len() || bytes[eq] != b'=' {
i = pos + 1;
continue;
}
eq += 1;
while eq < bytes.len() && matches!(bytes[eq], b' ' | b'\t') {
eq += 1;
}
if eq >= bytes.len() {
i = pos + 1;
continue;
}
let quote = bytes[eq];
if quote != b'"' && quote != b'\'' {
i = pos + 1;
continue;
}
let val_start = eq + 1;
if let Some(val_end_rel) = attrs[val_start..].find(quote as char) {
return Some(&attrs[val_start..val_start + val_end_rel]);
}
i = pos + 1;
} else {
break;
}
}
None
}
pub(crate) fn module_script_tag(base_path: &str, rel_path: &str) -> String {
let mut tag = String::with_capacity(base_path.len() + rel_path.len() + 40);
tag.push_str("\n<script type=\"module\" src=\"");
if base_path.is_empty() || base_path == "/" {
tag.push('/');
tag.push_str(rel_path.trim_start_matches('/'));
} else {
tag.push_str(base_path.trim_end_matches('/'));
tag.push('/');
tag.push_str(rel_path.trim_start_matches('/'));
}
tag.push_str("\"></script>");
tag
}
pub(crate) fn inject_module_script_tags(
html: &mut String,
base_path: &str,
rel_paths: &[String],
) -> usize {
let mut count = 0;
for rel_path in rel_paths {
let tag = module_script_tag(base_path, rel_path);
inject_script_tag(html, &tag);
count += 1;
}
count
}
pub(crate) fn inject_script_tag(html: &mut String, tag: &str) {
if let Some(pos) = html.rfind("</body>") {
html.insert_str(pos, tag);
} else {
html.push_str(tag);
}
}
fn fxhash_bytes(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x100_0000_01b3);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
fn test_hash(s: &str) -> u64 {
fxhash_bytes(s.as_bytes())
}
#[test]
fn path_for_js_strips_windows_verbatim_prefix() {
assert_eq!(
path_for_js(Path::new(r"\\?\C:\tmp\webui-press\template\index.ts")),
"C:/tmp/webui-press/template/index.ts"
);
assert_eq!(
path_for_js(Path::new(r"\\?\UNC\server\share\index.ts")),
"//server/share/index.ts"
);
assert_eq!(path_for_js(Path::new(r"C:\a\b.ts")), "C:/a/b.ts");
assert_eq!(path_for_js(Path::new("/tmp/a/b.ts")), "/tmp/a/b.ts");
}
#[test]
fn esbuild_command_resolves_from_node_modules() {
let tmp = std::env::temp_dir().join("webui-press-esbuild-test");
let bin_path = if cfg!(windows) {
tmp.join("node_modules/esbuild/bin/esbuild")
} else {
tmp.join("node_modules/.bin/esbuild")
};
fs::remove_dir_all(&tmp).ok();
fs::create_dir_all(bin_path.parent().unwrap()).unwrap();
fs::write(&bin_path, "").unwrap();
let resolved = esbuild_command(&tmp.join("node_modules"));
if cfg!(windows) {
assert_eq!(resolved.program, PathBuf::from("node"));
assert_eq!(resolved.prefix_args, [path_for_js(&bin_path)]);
} else {
assert_eq!(resolved.program, bin_path);
assert!(resolved.prefix_args.is_empty());
}
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn projection_package_entry_reads_export_target() -> TestResult {
let root = std::env::temp_dir().join(format!(
"webui-press-projection-package-test-{}",
std::process::id()
));
fs::remove_dir_all(&root).ok();
let package = root.join("node_modules/@microsoft/webui");
let entry = package.join("dist/projection/index.js");
fs::create_dir_all(entry.parent().ok_or("entry parent missing")?)?;
fs::write(&entry, "export {};")?;
fs::write(
package.join("package.json"),
r#"{"exports":{"./projection.js":{"import":{"default":"./dist/projection/index.js"}}}}"#,
)?;
let resolved = projection_package_entry(&root.join("node_modules"))?;
fs::remove_dir_all(&root)?;
assert_eq!(PathBuf::from(resolved), entry);
Ok(())
}
#[test]
fn push_external_args_filters_aliased_packages() {
let external = vec![
"@microsoft/webui-framework".to_string(),
"cdn-only-package".to_string(),
];
let mut aliases = BTreeMap::new();
aliases.insert(
"@microsoft/webui-framework".to_string(),
"/repo/packages/webui-framework/dist/index.js".to_string(),
);
let mut args = Vec::new();
push_external_args(&mut args, &external, &aliases);
assert!(!args
.iter()
.any(|arg| arg.contains("@microsoft/webui-framework")));
assert!(args.contains(&"--external:cdn-only-package".to_string()));
}
#[test]
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 {
site_dir,
base_path: "/",
node_modules: None,
root_bundle: None,
page_bundles: &[],
bundler_config: None,
dev_mode: false,
config_dir,
content_dir: Path::new("/site"),
};
let args = esbuild_args(&opts, &[], Path::new("/tmp/webui-press-bundle"))?;
assert!(args.contains(&format!("--tsconfig-raw={WEBUI_TSCONFIG_RAW}")));
Ok(())
}
#[test]
fn esbuild_args_folds_webui_dev_flag_for_production_only() -> TestResult {
fn opts<'a>(
site_dir: &'a Path,
config_dir: &'a Path,
dev_mode: bool,
cfg: Option<&'a BundlerConfig>,
) -> BundleOptions<'a> {
BundleOptions {
site_dir,
base_path: "/",
node_modules: None,
root_bundle: None,
page_bundles: &[],
bundler_config: cfg,
dev_mode,
config_dir,
content_dir: site_dir,
}
}
let site_dir = Path::new("/site");
let config_dir = Path::new("/site/.webui-press");
let tmp = Path::new("/tmp/b");
let define = "--define:__WEBUI_DEV__=false".to_string();
let prod = esbuild_args(&opts(site_dir, config_dir, false, None), &[], tmp)?;
assert!(prod.contains(&define));
let dev = esbuild_args(&opts(site_dir, config_dir, true, None), &[], tmp)?;
assert!(!dev.iter().any(|arg| arg.contains("__WEBUI_DEV__")));
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 ours = overridden.iter().position(|arg| arg == &define);
let theirs = overridden
.iter()
.position(|arg| arg == "--define:__WEBUI_DEV__=true");
assert!(ours.is_some(), "framework default define should be present");
assert!(theirs.is_some(), "user override define should be present");
assert!(
ours < theirs,
"framework default must precede the user override so esbuild's last-wins keeps the user's value",
);
Ok(())
}
#[test]
fn config_component_source_preserves_npm_packages() {
let cwd = Path::new("project");
assert_eq!(resolve_config_component_source("@mai-ui", cwd), "@mai-ui");
assert_eq!(
resolve_config_component_source("@mai-ui/button", cwd),
"@mai-ui/button"
);
assert_eq!(
resolve_config_component_source("plain-widget", cwd),
"plain-widget"
);
}
#[test]
fn config_component_source_resolves_local_paths() {
let cwd = Path::new("project");
assert_eq!(
std::path::PathBuf::from(resolve_config_component_source("./components", cwd)),
cwd.join("./components")
);
}
#[test]
fn discover_component_scripts_pairs_html_and_ts() -> TestResult {
let root = std::env::temp_dir().join(format!(
"webui-press-component-script-test-{}-{:x}",
std::process::id(),
test_hash("component-script")
));
if root.exists() {
fs::remove_dir_all(&root)?;
}
fs::create_dir_all(root.join("my-widget"))?;
fs::create_dir_all(root.join("html-only"))?;
fs::write(root.join("my-widget/my-widget.ts"), "")?;
fs::write(root.join("my-widget/my-widget.html"), "<p>widget</p>")?;
fs::write(root.join("html-only/html-only.html"), "<p>no script</p>")?;
let index = discover_component_scripts(&[root.to_string_lossy().into_owned()])?;
let expected = root.join("my-widget/my-widget.ts").canonicalize()?;
fs::remove_dir_all(&root)?;
assert_eq!(index.len(), 1);
let Some(script) = index.get("my-widget") else {
panic!("my-widget script should be discovered");
};
assert_eq!(script.script_path, expected);
Ok(())
}
#[test]
fn collect_component_scripts_for_html_follows_nested_local_components() -> TestResult {
let root = std::env::temp_dir().join(format!(
"webui-press-component-nesting-test-{}-{:x}",
std::process::id(),
test_hash("component-nesting")
));
if root.exists() {
fs::remove_dir_all(&root)?;
}
fs::create_dir_all(root.join("live-preview"))?;
fs::create_dir_all(root.join("inner-card"))?;
fs::write(
root.join("live-preview/live-preview.html"),
"<section><inner-card></inner-card></section>",
)?;
fs::write(root.join("live-preview/live-preview.ts"), "")?;
fs::write(root.join("inner-card/inner-card.html"), "<slot></slot>")?;
fs::write(root.join("inner-card/inner-card.ts"), "")?;
let index = discover_component_scripts(&[root.to_string_lossy().into_owned()])?;
let scripts =
collect_component_scripts_for_html(&["<live-preview></live-preview>"], &index)?;
let expected_live = root.join("live-preview/live-preview.ts").canonicalize()?;
let expected_inner = root.join("inner-card/inner-card.ts").canonicalize()?;
fs::remove_dir_all(&root)?;
assert_eq!(scripts, vec![expected_live, expected_inner]);
Ok(())
}
#[test]
fn page_bundle_signature_matches_identical_import_sets() {
let config_dir = Path::new("/repo/.webui-press");
let components = vec![
PathBuf::from("/repo/components/live-preview/live-preview.ts"),
PathBuf::from("/repo/components/inner-card/inner-card.ts"),
];
let reversed_components = vec![
PathBuf::from("/repo/components/inner-card/inner-card.ts"),
PathBuf::from("/repo/components/live-preview/live-preview.ts"),
];
let scripts = vec![
ScriptSource::File("./scripts/custom.ts".to_string()),
ScriptSource::Inline("import \"@mai-ui/button/define.js\";".to_string()),
];
assert_eq!(
page_bundle_signature(&components, &scripts, config_dir),
page_bundle_signature(&reversed_components, &scripts, config_dir)
);
let changed = vec![ScriptSource::Inline(
"import \"@mai-ui/card/define.js\";".to_string(),
)];
assert_ne!(
page_bundle_signature(&components, &scripts, config_dir),
page_bundle_signature(&components, &changed, config_dir)
);
}
#[test]
fn bundle_assets_skips_node_modules_when_no_scripts() -> TestResult {
let root = std::env::temp_dir().join(format!(
"webui-press-no-script-bundle-test-{}-{:x}",
std::process::id(),
test_hash("no-script-bundle")
));
if root.exists() {
fs::remove_dir_all(&root)?;
}
fs::create_dir_all(&root)?;
let result = bundle_assets(&BundleOptions {
site_dir: &root,
base_path: "/",
node_modules: None,
root_bundle: None,
page_bundles: &[],
bundler_config: None,
dev_mode: false,
config_dir: &root,
content_dir: &root,
})?;
fs::remove_dir_all(&root)?;
assert!(result.root_script.is_none());
assert_eq!(result.page_entry_count, 0);
Ok(())
}
#[test]
fn external_projection_manifest_is_loaded_without_local_scripts() -> TestResult {
let root = std::env::temp_dir().join(format!(
"webui-press-external-only-projection-test-{}-{:x}",
std::process::id(),
test_hash("external-only-projection")
));
fs::remove_dir_all(&root).ok();
fs::create_dir_all(&root)?;
let config = BundlerConfig {
projection_manifests: vec!["missing-projection.json".to_string()],
..BundlerConfig::default()
};
let result = bundle_assets(&BundleOptions {
site_dir: &root,
base_path: "/",
node_modules: None,
root_bundle: None,
page_bundles: &[],
bundler_config: Some(&config),
dev_mode: false,
config_dir: &root,
content_dir: &root,
});
fs::remove_dir_all(&root)?;
let Err(error) = result else {
panic!("configured external manifest must be loaded");
};
assert!(error.to_string().contains("PROJ-M001"));
Ok(())
}
#[test]
fn generated_bundle_entries_stay_on_the_site_volume() {
let site_dir = Path::new("D:/project/dist");
let generated = bundle_temp_path(site_dir, "abc");
let expected_name = format!(".webui-press-bundle-{}-abc", std::process::id());
assert!(generated.starts_with(site_dir));
assert_eq!(
generated.file_name().and_then(|name| name.to_str()),
Some(expected_name.as_str())
);
}
#[test]
fn generated_bundle_entries_use_absolute_child_process_paths() -> TestResult {
let generated = resolved_bundle_temp_path(Path::new("relative-site"), "abc")?;
assert!(generated.is_absolute());
Ok(())
}
#[test]
fn versioned_output_map_uses_exact_cache_busted_served_urls() -> TestResult {
let root = std::env::temp_dir().join(format!(
"webui-press-output-url-test-{}-{:x}",
std::process::id(),
test_hash("output-url-map")
));
fs::remove_dir_all(&root).ok();
fs::create_dir_all(root.join("assets"))?;
let chunk = root.join("assets/chunk.js");
fs::write(&chunk, "export const value = 1;")?;
let identity = chunk.canonicalize()?;
let urls = collect_versioned_output_urls(&root, "/docs/", std::slice::from_ref(&identity))?;
let href = urls.get(&identity).ok_or("generated output URL missing")?;
assert!(href.starts_with("/docs/assets/chunk.js?v="));
fs::remove_dir_all(&root)?;
Ok(())
}
#[test]
fn temp_dir_guard_removes_directory_on_drop() -> TestResult {
let root = std::env::temp_dir().join(format!(
"webui-press-temp-guard-test-{}-{:x}",
std::process::id(),
test_hash("temp-guard")
));
if root.exists() {
fs::remove_dir_all(&root)?;
}
fs::create_dir_all(&root)?;
{
let _guard = TempDirGuard::new(root.clone());
assert!(root.exists());
}
assert!(!root.exists());
Ok(())
}
#[test]
fn resolve_bundle_script_file_rejects_outside_project() -> TestResult {
let root = std::env::temp_dir().join(format!(
"webui-press-script-sandbox-test-{}-{:x}",
std::process::id(),
test_hash("script-sandbox")
));
if root.exists() {
fs::remove_dir_all(&root)?;
}
let project = root.join("project");
let config_dir = project.join(".webui-press");
let outside = root.join("outside");
fs::create_dir_all(&config_dir)?;
fs::create_dir_all(&outside)?;
fs::write(outside.join("secret.ts"), "console.log('secret');")?;
let Err(err) = resolve_bundle_script_file(&config_dir, &project, "../../outside/secret.ts")
else {
panic!("outside script should be rejected");
};
fs::remove_dir_all(&root)?;
assert!(err.to_string().contains("outside the docs project"));
Ok(())
}
#[test]
fn validate_inline_script_imports_rejects_absolute_import_after_code() -> TestResult {
let root = std::env::temp_dir().join(format!(
"webui-press-inline-sandbox-test-{}-{:x}",
std::process::id(),
test_hash("inline-sandbox")
));
if root.exists() {
fs::remove_dir_all(&root)?;
}
fs::create_dir_all(root.join(".webui-press"))?;
let allowed_roots = allowed_script_roots(&root.join(".webui-press"), &root)?;
let absolute_import = root.join("secret.js").to_string_lossy().replace('\\', "/");
let code = format!("console.log('before'); import \"{absolute_import}\";");
let Err(err) =
validate_inline_script_imports(&code, &root.join(".webui-press"), &allowed_roots, None)
else {
panic!("absolute import should be rejected");
};
fs::remove_dir_all(&root)?;
assert!(err.to_string().contains("absolute filesystem path"));
Ok(())
}
#[test]
fn import_specifiers_find_imports_after_code_and_dynamic_imports() {
let imports = import_specifiers(
r#"console.log("start"); import "./after.js"; const x = import('@pkg/dynamic');"#,
);
assert_eq!(imports, vec!["./after.js", "@pkg/dynamic"]);
}
#[test]
fn import_only_relative_imports_detects_wrapper_entries() {
let imports = import_only_relative_imports(
r#"import"./chunk-a.js";import "./chunk-b.js";"#,
"assets/page-0.js",
);
assert_eq!(
imports,
Some(vec![
"assets/chunk-a.js".to_string(),
"assets/chunk-b.js".to_string()
])
);
assert_eq!(
import_only_relative_imports(
r#"import{a as b}from"./chunk-a.js";console.log(b);"#,
"assets/page-0.js"
),
None
);
}
#[test]
fn page_script_paths_flattens_import_only_wrapper() -> TestResult {
let root = std::env::temp_dir().join(format!(
"webui-press-wrapper-flatten-test-{}-{:x}",
std::process::id(),
test_hash("wrapper-flatten")
));
if root.exists() {
fs::remove_dir_all(&root)?;
}
let assets = root.join("assets");
fs::create_dir_all(&assets)?;
fs::write(
assets.join("page-0.js"),
r#"import"./chunk-a.js";import"./chunk-b.js";import"./chunk-shared.js";"#,
)?;
fs::write(
assets.join("chunk-a.js"),
r#"import"./chunk-shared.js";console.log("a");"#,
)?;
fs::write(assets.join("chunk-b.js"), r#"console.log("b");"#)?;
fs::write(assets.join("chunk-shared.js"), r#"console.log("shared");"#)?;
let mut root_imports = HashSet::new();
root_imports.insert("assets/chunk-shared.js".to_string());
let paths = page_script_paths(
&root,
"assets/page-0.js",
&assets.join("page-0.js"),
&root_imports,
)?;
assert!(!assets.join("page-0.js").exists());
assert_eq!(paths.len(), 2);
assert!(paths[0].starts_with("assets/chunk-a.js?v="));
assert!(paths[1].starts_with("assets/chunk-b.js?v="));
assert!(!paths
.iter()
.any(|path| path.starts_with("assets/chunk-shared.js")));
fs::remove_dir_all(&root)?;
Ok(())
}
#[test]
fn extract_bundle_scripts_inline() {
let html =
r#"<p>Hello</p><script type="module" bundle>import "custom";</script><p>World</p>"#;
let (out, scripts) = extract_bundle_scripts(html);
assert_eq!(scripts.len(), 1);
assert!(matches!(&scripts[0], ScriptSource::Inline(s) if s.contains("custom")));
assert!(!out.contains("<script"));
assert!(out.contains("<p>Hello</p>"));
assert!(out.contains("<p>World</p>"));
}
#[test]
fn extract_bundle_scripts_src() {
let html = r#"<script type="module" bundle src="./scripts/playground.ts"></script>"#;
let (out, scripts) = extract_bundle_scripts(html);
assert_eq!(scripts.len(), 1);
assert!(matches!(&scripts[0], ScriptSource::File(s) if s == "./scripts/playground.ts"));
assert_eq!(out, "");
}
#[test]
fn extract_bundle_scripts_ignores_non_bundle() {
let html = r#"<script type="module">console.log("hi");</script>"#;
let (out, scripts) = extract_bundle_scripts(html);
assert_eq!(scripts.len(), 0);
assert_eq!(out, html);
}
#[test]
fn extract_bundle_scripts_multiple() {
let html = concat!(
r#"<script type="module" bundle>import "a";</script>"#,
r#"<p>middle</p>"#,
r#"<script type="module" bundle src="./b.ts"></script>"#,
);
let (out, scripts) = extract_bundle_scripts(html);
assert_eq!(scripts.len(), 2);
assert!(matches!(&scripts[0], ScriptSource::Inline(s) if s.contains("import \"a\"")));
assert!(matches!(&scripts[1], ScriptSource::File(s) if s == "./b.ts"));
assert!(out.contains("<p>middle</p>"));
assert!(!out.contains("<script"));
}
#[test]
fn extract_bundle_scripts_empty_body_no_src_skipped() {
let html = r#"<script type="module" bundle></script>"#;
let (out, scripts) = extract_bundle_scripts(html);
assert_eq!(scripts.len(), 0);
assert_eq!(out, html); }
#[test]
fn has_bundle_attr_standalone() {
assert!(has_bundle_attr(r#" type="module" bundle"#));
assert!(has_bundle_attr(r#" bundle type="module""#));
assert!(has_bundle_attr(" bundle"));
assert!(has_bundle_attr(r#" bundle="" type="module""#));
}
#[test]
fn has_bundle_attr_not_substring() {
assert!(!has_bundle_attr(r#" type="module" data-bundle="true""#));
assert!(!has_bundle_attr(r#" type="module" data-mode="bundle""#));
assert!(!has_bundle_attr(r#" unbundle"#));
}
#[test]
fn extract_src_attr_double_quotes() {
assert_eq!(extract_src_attr(r#" src="./foo.ts""#), Some("./foo.ts"));
}
#[test]
fn extract_src_attr_single_quotes() {
assert_eq!(extract_src_attr(" src='bar.js'"), Some("bar.js"));
}
#[test]
fn extract_src_attr_none_when_missing() {
assert_eq!(extract_src_attr(r#" type="module" bundle"#), None);
}
#[test]
fn module_script_tag_prefixes_base_path() {
let tag = module_script_tag("/webui/", "assets/page-0.js?v=abc");
assert_eq!(
tag,
"\n<script type=\"module\" src=\"/webui/assets/page-0.js?v=abc\"></script>"
);
}
#[test]
fn module_script_tag_handles_root_base_path() {
assert_eq!(
module_script_tag("/", "assets/page-0.js"),
"\n<script type=\"module\" src=\"/assets/page-0.js\"></script>"
);
}
#[test]
fn inject_script_tag_inserts_before_body_close() {
let mut html = "<html><body><p>content</p></body></html>".to_string();
inject_script_tag(
&mut html,
"\n<script type=\"module\" src=\"/assets/page-0.js\"></script>",
);
assert!(html.contains(
"<p>content</p>\n<script type=\"module\" src=\"/assets/page-0.js\"></script></body>"
));
}
}