From 6b8c368b020244f008d886af8a1b4255fc4c623a Mon Sep 17 00:00:00 2001 From: vshengbro Date: Sat, 10 Oct 2026 13:06:46 +0800 Subject: [PATCH] feat(docs): split the docs site into per-locale wasm bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All locales used to compile into a single wasm bundle (2.24 MB), so every visitor downloaded every language and the shell tracked locale state at runtime. The build now pins one locale per bundle and the CLI builds one bundle per locale, mirroring the directory layout static i18n sites already use: the default locale at the output root, every other locale in its own directory (en/, ja/, ko/, …). build.rs gains the pin: EUV_DOCS_LOCALE selects the locale by prefix, directory, or label (unset → the default '/' locale), alias entries are resolved to their canonical directory, pages of other locales are dropped, and the pinned prefix is stripped from every route and internal link so in-bundle routes are prefix-free. Non-default bundles also rebase relative asset URLs (./img/… → ../img/…) because assets are copied only by the default build and hosted once at the site root. The generated code emits SITE_LANGUAGES (switcher entries, one per unique content directory), SITE_LOCALE_DIR (this bundle's directory), and SITE_REDIRECTS (foreign prefix → owning directory) next to SITE, and writes .deploy/locales.tsv so the CLI discovers the remaining locales without a second YAML parser. Runtime: the shell keeps rendering from SITE unchanged (it now contains exactly one locale); the language switcher reads SITE_LANGUAGES and switches via cross-directory navigation (Location::assign), which the old code already approximated with a full-page reload. A boot-time redirect before mount maps legacy single-bundle links (/#/en/…) and alias prefixes (#/zh/…) onto the right bundle, so old links keep working. CLI: --locale builds a single bundle for fast iteration; the default invocation builds the default locale, reads the manifest, then builds every remaining locale into its own sub-directory. The redundant CLI-side public/ copy is gone (build.rs owns assets now). Verified locally on the 632-page, 4-locale docs site: zh 984 KB, en 1.02 MB, ja 1.06 MB, ko 1.04 MB (down from 2.24 MB, -54~57% per visitor); each bundle renders only its own sidebar; the switcher preserves the current route across directories; legacy #/en/… links redirect to /en/#/…; the /zh/ alias rewrites in place; images load in every bundle; the TOC scroll spy still tracks. --- Cargo.toml | 16 +- docs/build.rs | 468 ++++++++++++++++++++++++++- docs/codegen/const.rs | 19 ++ docs/src/app/const.rs | 4 + docs/src/bin/docs/const.rs | 21 +- docs/src/bin/docs/fn.rs | 196 ++++++----- docs/src/bin/docs/main.rs | 3 +- docs/src/bin/docs/struct.rs | 7 + docs/src/component/layout/view/fn.rs | 63 ++-- docs/src/data/struct.rs | 27 +- docs/src/lib.rs | 6 + docs/src/router/fn.rs | 83 +++-- 12 files changed, 750 insertions(+), 163 deletions(-) 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 }