diff --git a/Cargo.toml b/Cargo.toml index 1b8c8a44..6c596bd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["network-programming", "web-programming"] exclude.workspace = true [workspace.package] -version = "0.28.22" +version = "0.28.23" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] @@ -38,13 +38,13 @@ resolver = "3" crate-type = ["rlib"] [workspace.dependencies] -euv = { path = ".", version = "0.28.22" } -euv-ui = { path = "ui", version = "0.28.22" } -euv-cli = { path = "cli", version = "0.28.22" } -euv-core = { path = "core", version = "0.28.22" } -euv-engine = { path = "engine", version = "0.28.22" } -euv-macros = { path = "macros", version = "0.28.22" } -euv-example = { path = "example", version = "0.28.22" } +euv = { path = ".", version = "0.28.23" } +euv-ui = { path = "ui", version = "0.28.23" } +euv-cli = { path = "cli", version = "0.28.23" } +euv-core = { path = "core", version = "0.28.23" } +euv-engine = { path = "engine", version = "0.28.23" } +euv-macros = { path = "macros", version = "0.28.23" } +euv-example = { path = "example", version = "0.28.23" } log = "0.4.34" toml = "0.9.12" diff --git a/docs/build.rs b/docs/build.rs index 3ac2b90f..e6f2f007 100644 --- a/docs/build.rs +++ b/docs/build.rs @@ -33,7 +33,7 @@ struct SiteConfig { } /// One locale entry. -#[derive(Debug)] +#[derive(Debug, Clone)] struct LocaleConfig { /// Route prefix, e.g. `/` or `/en/`. prefix: String, @@ -260,6 +260,7 @@ fn main() { println!("cargo:rerun-if-changed={}", docs_dir.display()); println!("cargo:rerun-if-env-changed=EUV_DOCS_SRC_DIR"); println!("cargo:rerun-if-env-changed=EUV_DOCS_OUT_DIR"); + println!("cargo:rerun-if-env-changed=EUV_DOCS_LOCALE"); println!("cargo:rerun-if-changed=build.rs"); let config: Config = match load_config_from_readme(&docs_dir) { @@ -267,6 +268,30 @@ fn main() { Err(message) => fail(&message), }; + // Per-locale bundling: every build compiles exactly one locale into the + // wasm (the CLI invokes one build per locale). `EUV_DOCS_LOCALE` selects + // the locale by prefix (`/en/`), bare directory (`en`), or label; unset + // pins the default (prefix `/`) locale. Pages of other locales — and + // alias routes of the pinned locale's own content directory (e.g. the + // legacy `/zh/` prefix) — are dropped, and the pinned locale's URL + // prefix is stripped from every route and internal link, so inside a + // bundle every route is prefix-free and the runtime needs no locale + // detection at all. + let pinned: usize = resolve_pinned_locale(&config); + let pinned_prefix: String = config.locales[pinned].prefix.clone(); + let default_bundle: bool = pinned_prefix == URL_PREFIX_ROOT; + + let public_dir: PathBuf = docs_dir.join(DIR_PUBLIC); + // Assets are copied only by the default (site-root) build; non-default + // bundles live one directory deeper and reference the root copies via + // rebased `../` URLs, so the bytes are hosted exactly once. + if default_bundle { + if public_dir.is_dir() { + copy_dir(&public_dir, &www_dir); + } + copy_doc_assets(&docs_dir, &www_dir); + } + // (content directory, every URL prefix served from it) per locale // directory. A file directly under belongs to no locale // and is not a page. @@ -284,25 +309,41 @@ fn main() { pages.extend(process_page(&docs_dir, file, &locale_prefixes)); } - let public_dir: PathBuf = docs_dir.join(DIR_PUBLIC); - if public_dir.is_dir() { - copy_dir(&public_dir, &www_dir); - } - copy_doc_assets(&docs_dir, &www_dir); - let locale_roots: Vec<(String, PathBuf, String)> = match resolve_locale_roots(&docs_dir, &config) { Ok(roots) => roots, Err(message) => fail(&message), }; - let mut sidebars: Vec<(String, Vec)> = Vec::new(); - for (prefix, root, build_locale) in &locale_roots { - let items: Vec = build_sidebar(root, root, build_locale, &pages); - sidebars.push((prefix.clone(), items)); + pages.retain(|page: &Page| page_in_locale(&page.route, &pinned_prefix, &config)); + if pinned_prefix != URL_PREFIX_ROOT { + for page in &mut pages { + strip_page_prefix(page, &pinned_prefix); + } + } + + let mut pinned_locale: LocaleConfig = config.locales[pinned].clone(); + pinned_locale.prefix = URL_PREFIX_ROOT.to_string(); + if pinned_prefix != URL_PREFIX_ROOT + && let Some(items) = &mut pinned_locale.navbar + { + for item in items { + item.link = strip_url_prefix(&item.link, &pinned_prefix); + } } - let code: String = codegen(&config, &pages, &sidebars); + let pinned_root: PathBuf = docs_dir.join(&config.locales[pinned].dir); + // The sidebar is rebuilt against the stripped route space + // (`URL_PREFIX_ROOT` as the route prefix), so its links match the + // stripped page routes exactly. + let sidebar_items: Vec = + build_sidebar(&pinned_root, &pinned_root, URL_PREFIX_ROOT, &pages); + let sidebars: Vec<(String, Vec)> = vec![(URL_PREFIX_ROOT.to_string(), sidebar_items)]; + let _ = locale_roots; + + write_locale_manifest(&www_dir, &config); + + let code: String = codegen(&config, &pinned_locale, &pages, &sidebars); if let Err(reason) = fs::write(PathBuf::from(out_dir).join(GENERATED_FILE_NAME), code) { fail(&format!("failed to write docs_gen.rs: {reason}")); } @@ -322,6 +363,335 @@ fn fail(message: &str) -> ! { std::process::exit(1); } +/// Resolves which locale this build compiles into the wasm bundle. +/// +/// `EUV_DOCS_LOCALE` selects the locale by URL prefix (`/en/`), bare +/// content directory (`en`), or label (`English`); when unset the build +/// pins the default locale (prefix `/`). Aliases share a content directory +/// with their canonical locale (the legacy `/zh/` entry backs the same +/// `zh/` dir as `/`), so the request is resolved to the FIRST entry +/// declaring that directory — asking for an alias compiles the canonical +/// bundle, and the alias's URL space is served by the runtime redirect +/// table instead of duplicate pages. +/// +/// # Arguments +/// +/// - `&Config` - The parsed site configuration listing the locales. +/// +/// # Returns +/// +/// - `usize` - The index into `config.locales` of the canonical entry of +/// the locale this bundle compiles. +fn resolve_pinned_locale(config: &Config) -> usize { + let requested: Option = var(ENV_DOCS_LOCALE) + .ok() + .map(|value: String| value.trim().to_string()) + .filter(|value: &String| !value.is_empty()); + let index: usize = match requested { + None => config + .locales + .iter() + .position(|locale: &LocaleConfig| locale.prefix == URL_PREFIX_ROOT) + .unwrap_or(0), + Some(name) => { + let bare: &str = name.trim_matches('/'); + let normalized: String = if bare.is_empty() { + URL_PREFIX_ROOT.to_string() + } else { + format!("/{bare}/") + }; + config + .locales + .iter() + .position(|locale: &LocaleConfig| locale.prefix == normalized) + .or_else(|| { + config + .locales + .iter() + .position(|locale: &LocaleConfig| locale.dir == bare) + }) + .or_else(|| { + config + .locales + .iter() + .position(|locale: &LocaleConfig| locale.label == name) + }) + .unwrap_or_else(|| { + let available: String = config + .locales + .iter() + .map(|locale: &LocaleConfig| { + format!("{} (dir {})", locale.prefix, locale.dir) + }) + .collect::>() + .join(", "); + fail(&format!( + "unknown locale `{name}`; available locales: {available}" + )); + }) + } + }; + let dir: &str = &config.locales[index].dir; + config + .locales + .iter() + .position(|locale: &LocaleConfig| locale.dir == dir) + .unwrap_or(index) +} + +/// Whether `route` belongs to the locale with URL prefix `pinned`. +/// +/// Every configured prefix ends with `/` and routes are generated as +/// ``, so prefix matching is boundary-safe. The default +/// locale owns every route no other locale claims — which also drops alias +/// routes (e.g. `/zh/...`) from the default bundle. +/// +/// # Arguments +/// +/// - `&str` - The page route being tested. +/// - `&str` - The pinned locale's URL prefix. +/// - `&Config` - The parsed site configuration listing every prefix. +/// +/// # Returns +/// +/// - `bool` - `true` when the route belongs in this bundle. +fn page_in_locale(route: &str, pinned: &str, config: &Config) -> bool { + if pinned != URL_PREFIX_ROOT { + return route.starts_with(pinned); + } + !config + .locales + .iter() + .filter(|locale: &&LocaleConfig| locale.prefix != URL_PREFIX_ROOT) + .any(|locale: &LocaleConfig| route.starts_with(&locale.prefix)) +} + +/// Removes the locale URL prefix from an internal route or link. +/// +/// `/en/ltpp/` → `/ltpp/` under a pinned `/en/`; the locale home `/en/` +/// collapses to `/`. External (`https://…`) and already-root links are +/// returned unchanged. +/// +/// # Arguments +/// +/// - `&str` - The route or internal link. +/// - `&str` - The pinned locale's URL prefix (with trailing slash). +/// +/// # Returns +/// +/// - `String` - The prefix-free route. +fn strip_url_prefix(url: &str, pinned: &str) -> String { + let head: &str = pinned.trim_end_matches('/'); + if url == head { + return URL_PREFIX_ROOT.to_string(); + } + match url.strip_prefix(pinned) { + Some(rest) => format!("{URL_PREFIX_ROOT}{rest}"), + None => url.to_string(), + } +} + +/// Strips the locale prefix from every internal reference of a page: the +/// route, heading permalink hrefs (rebuilt from the stripped route), link +/// destinations inside the block AST, and hero action / feature card links. +/// +/// # Arguments +/// +/// - `&mut Page` - The page to rewrite in place. +/// - `&str` - The pinned locale's URL prefix. +fn strip_page_prefix(page: &mut Page, pinned: &str) { + page.route = strip_url_prefix(&page.route, pinned); + strip_blocks_prefix(&mut page.blocks, pinned); + for (_text, link, _kind) in &mut page.actions { + *link = strip_url_prefix(link, pinned); + } + for (_icon, _title, _details, link) in &mut page.features { + *link = strip_url_prefix(link, pinned); + } +} + +/// Strips the locale prefix from link destinations inside a block list. +/// +/// # Arguments +/// +/// - `&mut [AstBlock]` - The blocks to rewrite in place. +/// - `&str` - The pinned locale's URL prefix. +fn strip_blocks_prefix(blocks: &mut [AstBlock], pinned: &str) { + for block in blocks { + match block { + AstBlock::Heading { inline, .. } => strip_inlines_prefix(inline, pinned), + AstBlock::Paragraph(inline) => strip_inlines_prefix(inline, pinned), + AstBlock::BlockQuote(inner) => strip_blocks_prefix(inner, pinned), + AstBlock::List { items, .. } => { + for item in items { + strip_blocks_prefix(item, pinned); + } + } + AstBlock::Table { head, rows } => { + for cell in head { + strip_inlines_prefix(cell, pinned); + } + for row in rows { + for cell in row { + strip_inlines_prefix(cell, pinned); + } + } + } + AstBlock::Container { blocks: inner, .. } => strip_blocks_prefix(inner, pinned), + AstBlock::Html(raw) => *raw = rebase_html_asset_srcs(raw), + AstBlock::CodeBlock { .. } | AstBlock::Rule => {} + } + } +} + +/// Strips the locale prefix from link destinations inside an inline list, +/// and rebases asset URLs (`./img/…` → `../img/…`) for the one-level-deep +/// bundle directory. +/// +/// # Arguments +/// +/// - `&mut [Inline]` - The inline nodes to rewrite in place. +/// - `&str` - The pinned locale's URL prefix. +fn strip_inlines_prefix(inlines: &mut [Inline], pinned: &str) { + for inline in inlines { + match inline { + Inline::Link { href, children, .. } => { + *href = strip_url_prefix(href, pinned); + strip_inlines_prefix(children, pinned); + } + Inline::Strong(children) | Inline::Em(children) | Inline::Del(children) => { + strip_inlines_prefix(children, pinned); + } + Inline::Image { src, .. } => *src = rebase_asset_url(src), + Inline::Html(raw) => *raw = rebase_html_asset_srcs(raw), + Inline::Text(_) + | Inline::Code(_) + | Inline::TaskMarker(_) + | Inline::SoftBreak + | Inline::HardBreak => {} + } + } +} + +/// Rebases a site-root-relative asset URL for a non-default locale bundle. +/// +/// The bundle is served one directory below the site root (`/en/`), +/// while asset bytes are hosted only at the root by the default-locale +/// build, so `./img/x.png` (and the bare `img/x.png` form) become +/// `../img/x.png`. Author-written `../…` URLs already resolve to the root +/// from one level deep and pass through unchanged, as do absolute and +/// external URLs. +/// +/// # Arguments +/// +/// - `&str` - The asset URL as emitted by `rewrite_image_src`. +/// +/// # Returns +/// +/// - `String` - The URL relative to the bundle directory. +fn rebase_asset_url(url: &str) -> String { + if url.is_empty() + || url.starts_with("../") + || url.starts_with('/') + || url.starts_with(URL_SCHEME_HTTP) + || url.starts_with(URL_SCHEME_DATA) + || url.starts_with(URL_SCHEME_BLOB) + { + return url.to_string(); + } + if let Some(rest) = url.strip_prefix("./") { + return format!("../{rest}"); + } + format!("../{url}") +} + +/// Rebases every `src="…"` attribute inside a raw HTML block for a +/// non-default locale bundle, mirroring [`rebase_asset_url`]. +/// +/// # Arguments +/// +/// - `&str` - The raw HTML fragment. +/// +/// # Returns +/// +/// - `String` - The fragment with asset `src` attributes rebased. +fn rebase_html_asset_srcs(html: &str) -> String { + let mut out: String = String::with_capacity(html.len()); + let mut rest: &str = html; + while let Some(pos) = rest.find("src=\"") { + out.push_str(&rest[..pos + 5]); + let after: &str = &rest[pos + 5..]; + match after.find('"') { + Some(end) => { + out.push_str(&rebase_asset_url(&after[..end])); + rest = &after[end..]; + } + None => { + out.push_str(after); + return out; + } + } + } + out.push_str(rest); + out +} + +/// Maps a locale URL prefix to the bundle directory it is served from: +/// the default locale lives at the site root (`""`), every other locale in +/// a directory named after its prefix (`/en/` → `en/`). +/// +/// # Arguments +/// +/// - `&str` - The locale URL prefix. +/// +/// # Returns +/// +/// - `String` - The directory-relative path fragment. +fn locale_dir_url(prefix: &str) -> String { + let trimmed: &str = prefix.trim_matches('/'); + if trimmed.is_empty() { + String::new() + } else { + format!("{trimmed}/") + } +} + +/// Writes `/.deploy/locales.tsv` listing every locale (one row per +/// unique content directory: URL prefix, bundle directory, label). +/// +/// The `euv-docs` CLI reads the manifest after the first (default-locale) +/// build to discover the remaining locales it must build, so the locale +/// list has exactly one source of truth — the README frontmatter parsed +/// here — and the CLI never needs its own YAML parser. The file also +/// documents the deployed bundle layout for debugging. +/// +/// # Arguments +/// +/// - `&Path` - The site output directory the current build writes to. +/// - `&Config` - The parsed site configuration listing the locales. +fn write_locale_manifest(www_dir: &Path, config: &Config) { + let mut seen: Vec<&str> = Vec::new(); + let mut lines: String = String::from("prefix\tdir\tlabel\n"); + for locale in &config.locales { + if seen.contains(&locale.dir.as_str()) { + continue; + } + seen.push(&locale.dir); + lines.push_str(&format!( + "{}\t{}\t{}\n", + locale.prefix, + locale_dir_url(&locale.prefix), + locale.label + )); + } + let deploy_dir: PathBuf = www_dir.join(DEPLOY_DIR_NAME); + if let Err(reason) = fs::create_dir_all(&deploy_dir) + .and_then(|()| fs::write(deploy_dir.join(LOCALE_MANIFEST_FILE_NAME), lines)) + { + fail(&format!("failed to write locale manifest: {reason}")); + } +} + /// Indexes every configured prefix by the content directory that serves it. /// /// A content directory may back SEVERAL URL prefixes: `docs-pages/docs` @@ -2367,7 +2737,12 @@ fn build_sidebar(dir: &Path, locale_root: &Path, locale: &str, pages: &[Page]) - /// # Returns /// /// - `String` - the generated Rust source defining `crate::data::SITE` -fn codegen(config: &Config, pages: &[Page], sidebars: &[(String, Vec)]) -> String { +fn codegen( + config: &Config, + pinned: &LocaleConfig, + pages: &[Page], + sidebars: &[(String, Vec)], +) -> String { let mut code: String = String::new(); code.push_str(GENERATED_BANNER); @@ -2446,7 +2821,10 @@ fn codegen(config: &Config, pages: &[Page], sidebars: &[(String, Vec)] } let mut locales_code: String = String::new(); - for locale in &config.locales { + // Exactly one locale is compiled into a bundle: the pinned one, with its + // URL prefix rewritten to `/` and its navbar links already stripped, so + // the runtime's locale resolution degenerates to a constant. + for locale in std::slice::from_ref(pinned) { let Some((_, items)): Option<&(String, Vec)> = sidebars .iter() .find(|(prefix, _): &&(String, Vec)| prefix == &locale.prefix) @@ -2475,9 +2853,8 @@ fn codegen(config: &Config, pages: &[Page], sidebars: &[(String, Vec)] .unwrap_or_default(); let sidebar_code: String = emit_sidebar(sidebar_src); locales_code.push_str(&format!( - "crate::data::DocsLocale {{ prefix: {:?}, label: {:?}, title: {:?}, footer: {:?}, toc_label: {:?}, prev_label: {:?}, next_label: {:?}, navbar: &[{}], sidebar: {} }},\n", + "crate::data::DocsLocale {{ prefix: {:?}, title: {:?}, footer: {:?}, toc_label: {:?}, prev_label: {:?}, next_label: {:?}, navbar: &[{}], sidebar: {} }},\n", locale.prefix, - locale.label, locale.title.clone().unwrap_or_default(), locale.footer.clone().unwrap_or_default(), locale @@ -2503,6 +2880,65 @@ fn codegen(config: &Config, pages: &[Page], sidebars: &[(String, Vec)] locales_code, pages_code, )); + + // Language switcher entries: one per unique content directory (aliases + // deduplicated). `dir` is the bundle directory relative to the site + // root — "" for the default locale, "en/" for `/en/`, and so on. + let mut seen_dirs: Vec<&str> = Vec::new(); + let mut languages_code: String = String::new(); + for locale in &config.locales { + if seen_dirs.contains(&locale.dir.as_str()) { + continue; + } + seen_dirs.push(&locale.dir); + languages_code.push_str(&format!( + "crate::data::DocsLanguageLink {{ label: {:?}, dir: {:?} }},", + locale.label, + locale_dir_url(&locale.prefix) + )); + } + code.push_str(&format!( + "/// Every site language for the cross-bundle switcher.\npub(crate) static SITE_LANGUAGES: &[crate::data::DocsLanguageLink] = &[{languages_code}];\n" + )); + code.push_str(&format!( + "/// The bundle directory this build is served from (relative to the site root).\npub(crate) const SITE_LOCALE_DIR: &str = {:?};\n", + config + .locales + .iter() + .find(|locale: &&LocaleConfig| locale.dir == pinned.dir) + .map(|locale: &LocaleConfig| locale_dir_url(&locale.prefix)) + .unwrap_or_default() + )); + + // Boot-time redirects for URL spaces this bundle does not serve: any + // other locale's prefix (an old single-bundle link, or a mispaste) jumps + // to that locale's bundle directory; an alias of this bundle's own + // directory (e.g. `/zh/`) is rewritten in place to the canonical + // prefix-free route. + let mut redirects_code: String = String::new(); + for locale in &config.locales { + if locale.prefix == URL_PREFIX_ROOT { + continue; + } + let from: &str = locale.prefix.trim_end_matches('/'); + let to_dir: String = if locale.dir == pinned.dir { + String::new() + } else { + let canonical: &LocaleConfig = config + .locales + .iter() + .find(|candidate: &&LocaleConfig| candidate.dir == locale.dir) + .unwrap_or(locale); + locale_dir_url(&canonical.prefix) + }; + redirects_code.push_str(&format!( + "crate::data::DocsRedirect {{ from: {:?}, to_dir: {:?} }},", + from, to_dir + )); + } + code.push_str(&format!( + "/// Foreign-prefix redirects applied once at boot.\npub(crate) static SITE_REDIRECTS: &[crate::data::DocsRedirect] = &[{redirects_code}];\n" + )); code } diff --git a/docs/codegen/const.rs b/docs/codegen/const.rs index 37b344fc..56680bc8 100644 --- a/docs/codegen/const.rs +++ b/docs/codegen/const.rs @@ -273,3 +273,22 @@ pub(crate) const CODE_MD_INLINE_HARD_BREAK: &str = "euv_ui::EuvMdInline::HardBre /// Emitted `Option::None` expression for a sidebar item without a link. pub(crate) const CODE_NONE: &str = "None"; + +// --------------------------------------------------------------------------- +// Per-locale bundling +// --------------------------------------------------------------------------- + +/// The root URL prefix served by the default locale. +pub(crate) const URL_PREFIX_ROOT: &str = "/"; + +/// Environment variable selecting which locale a build compiles (`/en/`, +/// `en`, or a label); unset pins the default (prefix `/`) locale. +pub(crate) const ENV_DOCS_LOCALE: &str = "EUV_DOCS_LOCALE"; + +/// Hidden deployment-metadata directory inside the site output. +pub(crate) const DEPLOY_DIR_NAME: &str = ".deploy"; + +/// Locale manifest file inside `.deploy/` (one TSV row per unique content +/// directory: URL prefix, bundle directory, label). The `euv-docs` CLI +/// reads it to discover the remaining locales after the first build. +pub(crate) const LOCALE_MANIFEST_FILE_NAME: &str = "locales.tsv"; diff --git a/docs/src/app/const.rs b/docs/src/app/const.rs index 9dd828e2..5a8f9801 100644 --- a/docs/src/app/const.rs +++ b/docs/src/app/const.rs @@ -221,3 +221,7 @@ pub(crate) const ROUTE_HTML_SUFFIX: &str = ".html"; /// The URL scheme prefix that marks a link as external (leaving the SPA), /// tested with `starts_with` so both `http` and `https` match. pub(crate) const URL_SCHEME_HTTP_PREFIX: &str = "http"; + +/// The root URL path, used as the site-root fallback when the location is +/// unavailable (non-browser unit tests). +pub(crate) const URL_PATH_ROOT: &str = "/"; diff --git a/docs/src/bin/docs/const.rs b/docs/src/bin/docs/const.rs index 5b87ecad..0256c421 100644 --- a/docs/src/bin/docs/const.rs +++ b/docs/src/bin/docs/const.rs @@ -61,10 +61,6 @@ pub(crate) const WASM_TARGET_WEB: &str = "web"; /// is present. pub(crate) const CONFIG_FILE_NAME: &str = "config.toml"; -/// Sub-directory of `` that holds static assets copied -/// verbatim into the output root. Mirrors VuePress's `public/` layout. -pub(crate) const PUBLIC_DIR_NAME: &str = "public"; - /// euv subcommand that triggers the actual build. pub(crate) const EUV_BUILD_SUBCMD: &str = "build"; @@ -110,6 +106,23 @@ pub(crate) const EUV_NO_GITIGNORE_FLAG: &str = "--no-gitignore"; /// CLI flag disabling TypeScript generation in wasm-pack. pub(crate) const EUV_NO_TYPESCRIPT_FLAG: &str = "--no-typescript"; +/// CLI flag (space form) building a single locale bundle. +pub(crate) const LOCALE_FLAG: &str = "--locale"; + +/// Prefix marker for `--locale=` inline form; slice delimiter for the value. +pub(crate) const PREFIX_LOCALE: &str = "--locale="; + +/// Environment variable the build script reads for the pinned locale. +pub(crate) const EUV_DOCS_LOCALE_ENV: &str = "EUV_DOCS_LOCALE"; + +/// Hidden deployment-metadata directory inside the site output where the +/// build script writes the locale manifest. +pub(crate) const DEPLOY_DIR_NAME: &str = ".deploy"; + +/// Locale manifest file the first build writes into `.deploy/`; the CLI +/// reads it to discover the remaining locales to build. +pub(crate) const LOCALE_MANIFEST_FILE_NAME: &str = "locales.tsv"; + /// Argument missing the value for `--out ` / `--name `. pub(crate) const MSG_FLAG_REQUIRES_VALUE: &str = "flag requires a value"; diff --git a/docs/src/bin/docs/fn.rs b/docs/src/bin/docs/fn.rs index 8c615b15..f7dd4d52 100644 --- a/docs/src/bin/docs/fn.rs +++ b/docs/src/bin/docs/fn.rs @@ -16,6 +16,7 @@ pub(crate) fn parse_args() -> Result { let mut out_dir: Option = None; let mut name: Option = None; let mut index_html: Option = None; + let mut locale: Option = None; let mut release: bool = true; let mut iter: Skip = env::args().skip(1); while let Some(arg) = iter.next() { @@ -46,6 +47,12 @@ pub(crate) fn parse_args() -> Result { .ok_or_else(|| format!("{INDEX_HTML_FLAG} {MSG_FLAG_REQUIRES_VALUE}"))?; index_html = Some(PathBuf::from(value)); } + LOCALE_FLAG => { + let value: String = iter + .next() + .ok_or_else(|| format!("{LOCALE_FLAG} {MSG_FLAG_REQUIRES_VALUE}"))?; + locale = Some(value); + } DEBUG_FLAG => { release = false; } @@ -61,6 +68,9 @@ pub(crate) fn parse_args() -> Result { flag if flag.starts_with(PREFIX_INDEX_HTML) => { index_html = Some(PathBuf::from(&flag[PREFIX_INDEX_HTML.len()..])); } + flag if flag.starts_with(PREFIX_LOCALE) => { + locale = Some(flag[PREFIX_LOCALE.len()..].to_string()); + } flag if flag.starts_with('-') => { return Err(format!("{MSG_UNKNOWN_FLAG}: {flag}")); } @@ -78,12 +88,22 @@ pub(crate) fn parse_args() -> Result { Some(value) => Box::leak(value.into_boxed_str()), None => DEFAULT_NAME, }; - Ok(Args::new(src_dir, out_dir, name, release, index_html)) + let locale: Option<&'static str> = + locale.map(|value: String| &*Box::leak(value.into_boxed_str())); + Ok(Args::new( + src_dir, out_dir, name, release, index_html, locale, + )) } /// Invoke `euv build` for the supplied [`Args`], applying the env-var /// contract that `build.rs` understands. /// +/// Per-locale bundling: with `--locale` a single bundle is compiled into +/// the output directory; without it the default locale builds first (the +/// build script writes the locale manifest into `/.deploy/`), then +/// every remaining locale builds into its own sub-directory of the output +/// root (`/en/`, …), one wasm bundle per locale. +/// /// # Arguments /// /// - `&Args` - The parsed command line driving the build. @@ -93,6 +113,92 @@ pub(crate) fn parse_args() -> Result { /// - `Result<(), String>` - `Ok` once the site is written, or a message /// describing which phase failed. pub(crate) fn run(args: &Args) -> Result<(), String> { + let user_cwd: PathBuf = env::current_dir().map_err(|e: io::Error| e.to_string())?; + let out_dir: PathBuf = { + let out_dir: &Path = args.get_out_dir().as_path(); + if out_dir.is_absolute() { + out_dir.to_path_buf() + } else { + user_cwd.join(out_dir) + } + }; + if args.try_get_locale().is_some() { + return build_one(args, &out_dir); + } + // Default locale first: its build writes the locale manifest the loop + // below discovers the remaining locales from. + build_one(args, &out_dir)?; + for entry in read_locale_manifest(&out_dir)? { + if entry.dir.is_empty() { + continue; + } + let mut next: Args = args.clone(); + next.set_locale(Some(&*Box::leak(entry.prefix.into_boxed_str()))); + build_one(&next, &out_dir.join(entry.dir.trim_end_matches('/')))?; + } + Ok(()) +} + +/// One locale-manifest row (`prefix`, bundle `dir`, human `label`). +struct LocaleManifestEntry { + /// URL prefix of the locale (`/`, `/en/`). + prefix: String, + /// Bundle directory relative to the output root (`""`, `en/`). + dir: String, +} + +/// Reads the locale manifest the first build wrote into +/// `/.deploy/locales.tsv`. +/// +/// # Arguments +/// +/// - `&Path` - The absolute output directory of the completed first build. +/// +/// # Returns +/// +/// - `Result, String>` - One entry per unique +/// locale content directory, or the read/parse failure. +fn read_locale_manifest(out_dir: &Path) -> Result, String> { + let path: PathBuf = out_dir + .join(DEPLOY_DIR_NAME) + .join(LOCALE_MANIFEST_FILE_NAME); + let raw: String = fs::read_to_string(&path).map_err(|e: io::Error| { + format!( + "locale manifest missing at {} after the default-locale build: {e}", + path.display() + ) + })?; + let mut entries: Vec = Vec::new(); + for line in raw.lines().skip(1) { + if line.trim().is_empty() { + continue; + } + let mut columns = line.split('\t'); + let (Some(prefix), Some(dir), Some(_label)) = + (columns.next(), columns.next(), columns.next()) + else { + return Err(format!("malformed locale manifest row: {line}")); + }; + entries.push(LocaleManifestEntry { + prefix: prefix.to_string(), + dir: dir.to_string(), + }); + } + Ok(entries) +} + +/// Builds one locale bundle into `out_dir`. +/// +/// # Arguments +/// +/// - `&Args` - The parsed command line; `locale` selects the bundle. +/// - `&Path` - The absolute output directory for this bundle. +/// +/// # Returns +/// +/// - `Result<(), String>` - `Ok` once the bundle is written, or a message +/// describing which phase failed. +fn build_one(args: &Args, out_dir: &Path) -> Result<(), String> { let manifest_dir: PathBuf = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let src_dir: &Path = args.get_src_dir().as_path(); if !src_dir.is_dir() { @@ -113,7 +219,6 @@ pub(crate) fn run(args: &Args) -> Result<(), String> { ); } // Phase 1: prepare output directory and template path. - let out_dir: &Path = args.get_out_dir().as_path(); fs::create_dir_all(out_dir).map_err(|e: io::Error| { format!( "failed to create output directory {}: {e}", @@ -144,13 +249,10 @@ pub(crate) fn run(args: &Args) -> Result<(), String> { .arg(&template_path); command.env(EUV_DOCS_SRC_DIR_ENV, src_dir); command.env(EUV_DOCS_OUT_DIR_ENV, out_dir); - let user_cwd: PathBuf = env::current_dir().unwrap_or_else(|_| manifest_dir.clone()); - let resolved_out_dir: PathBuf = if out_dir.is_absolute() { - out_dir.to_path_buf() - } else { - user_cwd.join(out_dir) - }; - let pkg_dir: PathBuf = resolved_out_dir.join(PKG_DIR_NAME); + if let Some(locale) = args.try_get_locale() { + command.env(EUV_DOCS_LOCALE_ENV, locale); + } + let pkg_dir: PathBuf = out_dir.join(PKG_DIR_NAME); command.arg(WASM_PACK_DELIMITER); command .arg(EUV_TARGET_FLAG) @@ -174,11 +276,10 @@ pub(crate) fn run(args: &Args) -> Result<(), String> { if !status.success() { return Err(format!("{EUV_BIN} build exited with status {status}")); } - // Phase 4: copy `/public/` into the output root. `euv build` - // only generates `index.html` + `pkg/*`; static assets that the site - // references under `/foo.png` etc. live in the user's source tree - // under `public/` and need to be copied into `/` ourselves. - copy_public_assets(src_dir, out_dir)?; + // Assets are NOT copied here: `build.rs` copies `public/` and per-doc + // assets during the wasm build, and only for the default (site-root) + // locale bundle — non-default bundles reference the root copies via + // rebased `../` URLs, so every asset byte is hosted exactly once. // Generate a 404.html fallback (copy of index.html) so static hosts // like GitHub Pages serve the SPA shell for unknown paths instead // of returning a plain 404 — the wasm router will then resolve the @@ -193,70 +294,6 @@ pub(crate) fn run(args: &Args) -> Result<(), String> { Ok(()) } -/// Recursively copy `/public/` into `/`. Missing -/// `public/` is treated as success (the source may have no static -/// assets); per-file copy errors are returned verbatim so the user -/// sees them. -/// -/// # Arguments -/// -/// - `&Path` - The site source directory holding `public/`. -/// - `&Path` - The output directory receiving the copied assets. -/// -/// # Returns -/// -/// - `Result<(), String>` - `Ok` when every entry copied, or the first -/// per-file error. -fn copy_public_assets(src_dir: &Path, out_dir: &Path) -> Result<(), String> { - let public_dir: PathBuf = src_dir.join(PUBLIC_DIR_NAME); - if !public_dir.is_dir() { - return Ok(()); - } - copy_dir_recursive(&public_dir, out_dir) -} - -/// Walk `src` and copy every entry under it to the matching relative -/// path under `dst`, creating intermediate directories as needed. -/// -/// # Arguments -/// -/// - `&Path` - The directory to read entries from. -/// - `&Path` - The directory receiving the mirrored tree. -/// -/// # Returns -/// -/// - `Result<(), String>` - `Ok` when the whole tree copied, or the -/// first error encountered. -fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { - let entries: Vec = fs::read_dir(src) - .map_err(|e: io::Error| format!("read_dir({}): {e}", src.display()))? - .collect::, _>>() - .map_err(|e: io::Error| format!("read_dir({}): {e}", src.display()))?; - for entry in entries { - let entry_path: PathBuf = entry.path(); - let file_name: OsString = entry.file_name(); - let target_path: PathBuf = dst.join(&file_name); - let file_type: FileType = entry - .file_type() - .map_err(|e: io::Error| format!("file_type({}): {e}", entry_path.display()))?; - if file_type.is_dir() { - fs::create_dir_all(&target_path).map_err(|e: io::Error| { - format!("create_dir_all({}): {e}", target_path.display()) - })?; - copy_dir_recursive(&entry_path, &target_path)?; - } else if file_type.is_file() { - fs::copy(&entry_path, &target_path).map_err(|e: io::Error| { - format!( - "copy {} -> {}: {e}", - entry_path.display(), - target_path.display() - ) - })?; - } - } - Ok(()) -} - /// Print the CLI usage banner to stdout. pub(crate) fn print_usage() { println!( @@ -269,6 +306,9 @@ pub(crate) fn print_usage() { --out Output directory (default: ./dist)\n \ --name Wasm package name (default: euv_docs)\n \ --index-html Custom index.html template (default: CLI-bundled)\n \ + --locale Build a single locale bundle (prefix `/en/`, dir `en`,\n \ + or label); default builds every locale, one bundle\n \ + per locale under //\n \ --release Use release profile (default)\n \ --debug Use dev profile (faster build, slower runtime)\n \ -h, --help Print this help\n \ diff --git a/docs/src/bin/docs/main.rs b/docs/src/bin/docs/main.rs index d4a3897c..777d598f 100644 --- a/docs/src/bin/docs/main.rs +++ b/docs/src/bin/docs/main.rs @@ -6,8 +6,7 @@ pub(crate) use {r#const::*, r#fn::*, r#struct::*}; use std::{ env, - ffi::OsString, - fs::{self, DirEntry, FileType}, + fs::{self}, io, iter::Skip, path::{Path, PathBuf}, diff --git a/docs/src/bin/docs/struct.rs b/docs/src/bin/docs/struct.rs index 73409e48..f9e36e37 100644 --- a/docs/src/bin/docs/struct.rs +++ b/docs/src/bin/docs/struct.rs @@ -30,4 +30,11 @@ pub(crate) struct Args { #[get_mut(pub(crate))] #[set(pub(crate))] index_html: Option, + /// Single-locale build selector (`--locale en`): `Some` compiles only + /// that locale's bundle; `None` builds every locale (default bundle at + /// the output root, one sub-directory per additional locale). + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + locale: Option<&'static str>, } diff --git a/docs/src/component/layout/view/fn.rs b/docs/src/component/layout/view/fn.rs index 14973933..8a739e1a 100644 --- a/docs/src/component/layout/view/fn.rs +++ b/docs/src/component/layout/view/fn.rs @@ -354,6 +354,11 @@ fn nav_footer_node(github: Option<&'static str>) -> VirtualNode { /// Renders the locale switcher row for the nav column / drawer (empty when /// the site has a single locale). /// +/// Entries come from the generated `SITE_LANGUAGES` table (one per unique +/// content directory), not from `SITE.locales`: per-locale bundling +/// compiles exactly one locale into the wasm, so the full language list no +/// longer exists in `SITE`. +/// /// # Arguments /// /// - `Signal` - The current route signal. @@ -363,22 +368,23 @@ fn nav_footer_node(github: Option<&'static str>) -> VirtualNode { /// /// - `VirtualNode` - The locale row virtual DOM tree. fn locale_row_node(route_signal: Signal, locale_menu_open: Signal) -> VirtualNode { - let site: &DocsSite = &crate::generated::SITE; - if site.locales.len() <= 1 { + let languages: &'static [DocsLanguageLink] = crate::generated::SITE_LANGUAGES; + if languages.len() <= 1 { return html! { "" }; } - let (path, _anchor) = parse_route(&route_signal.get()); - let current: &DocsLocale = locale_of(&path); - let current_label: &str = current.label; - let items: Vec = site - .locales + let current_label: &str = languages + .iter() + .find(|language: &&DocsLanguageLink| language.dir == crate::generated::SITE_LOCALE_DIR) + .map(|language: &DocsLanguageLink| language.label) + .unwrap_or(languages[0].label); + let items: Vec = languages .iter() - .map(|target: &DocsLocale| EuvDropdownItem { + .map(|target: &DocsLanguageLink| EuvDropdownItem { label: target.label, - value: target.prefix, - active: target.prefix == current.prefix, + value: target.dir, + active: target.dir == crate::generated::SITE_LOCALE_DIR, }) .collect(); html! { @@ -461,12 +467,14 @@ fn toggle_menu(menu_open: Signal) -> Option> { Some(Rc::new(move |_| menu_open.set(!menu_open.get()))) } -/// Switches the current route into the locale chosen from the dropdown. +/// Switches to the language chosen from the dropdown. /// -/// The shell (sidebar tree, labels, brand title) is locale-bound and computed -/// at mount, so after navigating to the new locale's route the page is -/// reloaded — the standard behaviour of i18n documentation sites — to rebuild -/// every localized label deterministically. +/// Every locale is its own wasm bundle served from its own directory, so +/// switching languages navigates across directories: the target URL is the +/// site root plus the target bundle's directory plus the current (already +/// prefix-free) in-bundle route. `Location::assign` performs a full load — +/// the standard behaviour of i18n documentation sites — which also swaps +/// the wasm bundle itself. /// /// # Arguments /// @@ -476,29 +484,20 @@ fn toggle_menu(menu_open: Signal) -> Option> { /// # Returns /// /// - `Option>` - The select handler receiving the -/// target locale prefix. +/// target bundle directory relative to the site root. fn switch_locale( route_signal: Signal, menu_open: Signal, ) -> Option> { - Some(Rc::new(move |prefix: &'static str| { - let site: &DocsSite = &crate::generated::SITE; - let Some(target) = site - .locales - .iter() - .find(|locale: &&DocsLocale| locale.prefix == prefix) - else { - return; - }; - let (path, _anchor) = parse_route(&route_signal.get()); + Some(Rc::new(move |dir: &'static str| { menu_open.set(false); + if dir == crate::generated::SITE_LOCALE_DIR { + return; + } if let Some(window) = window() { - let location: Location = window.location(); - // `set_hash` applies synchronously, so the reload below boots the - // app straight into the target locale's route. - let route: String = route_in_locale(&path, target); - let _: Result<(), JsValue> = location.set_hash(&route); - let _: Result<(), JsValue> = location.reload(); + let route: String = route_signal.get(); + let url: String = format!("{}{dir}#{route}", site_root()); + let _: Result<(), JsValue> = window.location().assign(&url); } })) } diff --git a/docs/src/data/struct.rs b/docs/src/data/struct.rs index 3624f63f..f1dd075d 100644 --- a/docs/src/data/struct.rs +++ b/docs/src/data/struct.rs @@ -68,10 +68,8 @@ pub(crate) struct DocsPage { /// One locale. #[derive(Clone, Copy, Debug)] pub(crate) struct DocsLocale { - /// Route prefix (`/` or `/zh/`). + /// Route prefix (`/` or `/en/`). pub(crate) prefix: &'static str, - /// Human label for the language dropdown. - pub(crate) label: &'static str, /// Locale title override. pub(crate) title: &'static str, /// Footer text. @@ -98,3 +96,26 @@ pub(crate) struct DocsSite { /// All pages. pub(crate) pages: &'static [DocsPage], } + +/// One language entry of the cross-bundle switcher. +/// +/// Per-locale bundling serves every locale from its own directory (the +/// default locale at the site root), so switching languages is a +/// cross-directory navigation rather than an in-app route change. +#[derive(Clone, Copy, Debug)] +pub(crate) struct DocsLanguageLink { + /// Human label shown in the dropdown. + pub(crate) label: &'static str, + /// Bundle directory relative to the site root (`""` or `"en/"`). + pub(crate) dir: &'static str, +} + +/// One boot-time redirect for a URL space this bundle does not serve. +#[derive(Clone, Copy, Debug)] +pub(crate) struct DocsRedirect { + /// Foreign route prefix as it appears after the `#` (`"/en"`, `"/zh"`). + pub(crate) from: &'static str, + /// Target bundle directory relative to the site root; `""` rewrites the + /// hash inside this bundle (alias of the pinned locale's directory). + pub(crate) to_dir: &'static str, +} diff --git a/docs/src/lib.rs b/docs/src/lib.rs index 42378b98..dfd410d3 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -37,6 +37,12 @@ use { #[wasm_bindgen] pub fn main() { console_error_panic_hook::set_once(); + // Foreign-locale hash routes (old single-bundle links, legacy aliases) + // are redirected before any rendering work happens; a cross-bundle + // redirect replaces the page, so mounting is skipped entirely then. + if redirect_foreign_route() { + return; + } inject_app_global_css(); Css::inject_css(euv_md_css()); Css::inject_css(APP_GLOBAL_CSS); diff --git a/docs/src/router/fn.rs b/docs/src/router/fn.rs index 6a5cb2ad..a6cd97e0 100644 --- a/docs/src/router/fn.rs +++ b/docs/src/router/fn.rs @@ -197,32 +197,75 @@ pub(crate) fn flatten_links(items: &'static [EuvSidebarItem]) -> Vec<&'static Eu out } -/// Maps a route to the equivalent route in another locale. +/// Computes the site root URL path from the current location. /// -/// Falls back to the target locale home when the page has no counterpart. +/// Every locale bundle is served from its own directory under the site +/// root (the default locale at the root itself, `/en/` from `/en/`), +/// so stripping this bundle's directory (`SITE_LOCALE_DIR`) off the +/// current pathname yields the site root the language switcher and the +/// boot redirect build cross-locale URLs from. /// -/// # Arguments +/// # Returns +/// +/// - `String` - The site root path with a trailing slash (e.g. `/pages/`). +pub(crate) fn site_root() -> String { + let Some(window) = window() else { + return URL_PATH_ROOT.to_string(); + }; + let Ok(mut path) = window.location().pathname() else { + return URL_PATH_ROOT.to_string(); + }; + if !path.ends_with('/') { + path.push('/'); + } + if !crate::generated::SITE_LOCALE_DIR.is_empty() + && let Some(root) = path.strip_suffix(crate::generated::SITE_LOCALE_DIR) + { + return root.to_string(); + } + path +} + +/// Redirects boot when the hash names a route this bundle does not serve. /// -/// - `&str` - The current page route path. -/// - `&'static DocsLocale` - The target locale. +/// Per-locale bundling gives every locale its own directory, but links +/// written against the old single-bundle site (`/#/en/…`) still +/// arrive at the default bundle, and alias prefixes (`/zh/…`) arrive at +/// their canonical bundle. `SITE_REDIRECTS` maps those foreign prefixes: +/// an entry with an empty `to_dir` rewrites the hash in place, any other +/// entry replaces the location with the owning bundle's directory. /// /// # Returns /// -/// - `String` - The target route. -pub(crate) fn route_in_locale(route: &str, target: &'static DocsLocale) -> String { - let current: &DocsLocale = locale_of(route); - let suffix: &str = route - .strip_prefix(current.prefix.trim_end_matches('/')) - .unwrap_or(route); - let suffix: &str = if suffix.is_empty() { "/" } else { suffix }; - let candidate: String = if target.prefix == "/" { - suffix.to_string() - } else { - format!("{}{}", target.prefix.trim_end_matches('/'), suffix) +/// - `bool` - `true` when a cross-directory navigation was started and the +/// caller must skip mounting (the page is being replaced). +pub(crate) fn redirect_foreign_route() -> bool { + let Some(window) = window() else { + return false; }; - if find_page(&candidate).is_some() { - candidate - } else { - target.prefix.to_string() + let location: Location = window.location(); + let Ok(hash) = location.hash() else { + return false; + }; + let path: &str = hash.strip_prefix('#').unwrap_or(&hash); + for redirect in crate::generated::SITE_REDIRECTS { + let head: &str = redirect.from; + let boundary: bool = path == head + || path + .strip_prefix(head) + .is_some_and(|rest: &str| rest.starts_with('/')); + if !boundary { + continue; + } + let bare: &str = &path[head.len()..]; + let bare: &str = if bare.is_empty() { URL_PATH_ROOT } else { bare }; + if redirect.to_dir.is_empty() { + let _: Result<(), JsValue> = location.set_hash(bare); + return false; + } + let url: String = format!("{}{}#{}", site_root(), redirect.to_dir, bare); + let _: Result<(), JsValue> = location.replace(&url); + return true; } + false }