use alloc::sync::Arc;
use alloc::vec::Vec;
use mf2_catalog::Catalog;
use crate::error::LoadError;
use crate::leptos::state;
#[cfg(feature = "ssr")]
use crate::line::reactive_graph;
pub fn read(bytes: Vec<u8>) -> Result<Arc<Catalog>, LoadError> {
if !state::installed() {
return Err(LoadError::NotInstalled);
}
Catalog::new(bytes, state::manifest_hash())
.map(Arc::new)
.map_err(LoadError::from_reader)
}
#[cfg(feature = "ssr")]
mod store {
use super::{Arc, Catalog, LoadError, Vec, read, state};
use std::sync::OnceLock;
pub struct CatalogEntry {
pub tag: &'static str,
pub file: &'static str,
pub bytes: &'static [u8],
pub catalog: Arc<Catalog>,
}
impl core::fmt::Debug for CatalogEntry {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("CatalogEntry")
.field("tag", &self.tag)
.field("file", &self.file)
.field("bytes", &self.bytes.len())
.finish_non_exhaustive()
}
}
static STORE: OnceLock<Vec<CatalogEntry>> = OnceLock::new();
pub fn install_catalogs(
catalogs: &[(&'static str, &'static str, &'static [u8])],
) -> Result<(), LoadError> {
let mut parsed = Vec::with_capacity(catalogs.len());
for (tag, file, bytes) in catalogs {
parsed.push(CatalogEntry {
tag,
file,
bytes,
catalog: read(bytes.to_vec())?,
});
}
let _ = STORE.set(parsed);
Ok(())
}
#[must_use]
pub fn catalog(tag: &str) -> Option<Arc<Catalog>> {
let store = STORE.get()?;
store
.iter()
.find(|e| e.tag == tag)
.map(|e| Arc::clone(&e.catalog))
}
#[must_use]
pub fn catalog_name(tag: &str) -> Option<&'static str> {
STORE.get()?.iter().find(|e| e.tag == tag).map(|e| e.file)
}
#[must_use]
pub fn catalog_file(file: &str) -> Option<&'static [u8]> {
STORE
.get()?
.iter()
.find(|e| e.file == file)
.map(|e| e.bytes)
}
#[must_use]
pub fn catalog_entries() -> &'static [CatalogEntry] {
STORE.get().map_or(&[], Vec::as_slice)
}
#[must_use]
pub fn default_catalog() -> Option<Arc<Catalog>> {
catalog(state::source_locale())
}
}
#[cfg(feature = "ssr")]
pub use store::{
CatalogEntry, catalog, catalog_entries, catalog_file, catalog_name, default_catalog,
install_catalogs,
};
#[cfg(feature = "ssr")]
#[derive(Clone, Debug)]
pub struct RequestI18n {
catalog: Arc<Catalog>,
registry: Option<&'static mf2_runtime::Registry>,
bidi: Option<mf2_runtime::BidiStrategy>,
time_zone: Option<mf2_runtime::TimeZone>,
}
#[cfg(feature = "ssr")]
impl RequestI18n {
#[must_use]
pub fn new(catalog: Arc<Catalog>) -> RequestI18n {
RequestI18n {
catalog,
registry: None,
bidi: None,
time_zone: None,
}
}
#[must_use]
pub fn with_registry(mut self, registry: &'static mf2_runtime::Registry) -> RequestI18n {
self.registry = Some(registry);
self
}
#[must_use]
pub fn with_bidi(mut self, bidi: mf2_runtime::BidiStrategy) -> RequestI18n {
self.bidi = Some(bidi);
self
}
#[must_use]
pub fn with_time_zone(mut self, zone: mf2_runtime::TimeZone) -> RequestI18n {
self.time_zone = Some(zone);
self
}
pub fn provide(self) {
reactive_graph::owner::provide_context(self);
}
}
#[cfg(feature = "ssr")]
pub fn provide_locale(tag: &str) -> &'static str {
provide_locale_in_zone(tag, None)
}
#[cfg(feature = "ssr")]
pub fn provide_locale_in_zone(tag: &str, zone: Option<mf2_runtime::TimeZone>) -> &'static str {
let found = state::locales()
.iter()
.find(|(t, _)| *t == tag)
.map(|(t, _)| *t);
if found.is_none()
&& let Some(named) = crate::warn::tag(tag)
{
crate::warn::once_for(crate::warn::Kind::UnknownLocale, named, || {
alloc::format!(
"mf2: provide_locale(\"{named}\"): this build has no catalog for that language, \
so the page renders in the source language, `{}`",
state::source_locale()
)
});
}
let tag = found.unwrap_or_else(state::source_locale);
if let Some(c) = catalog(tag) {
let request = RequestI18n::new(c);
match zone {
Some(zone) => request.with_time_zone(zone),
None => request,
}
.provide();
}
tag
}
#[cfg(feature = "ssr")]
#[derive(Clone, Copy, Debug)]
struct LocaleQuery(&'static str);
#[cfg(feature = "ssr")]
pub fn provide_locale_query(name: &'static str) {
reactive_graph::owner::provide_context(LocaleQuery(name));
}
pub(crate) fn locale_query() -> &'static str {
#[cfg(feature = "ssr")]
if let Some(LocaleQuery(name)) = reactive_graph::owner::use_context::<LocaleQuery>() {
return name;
}
#[cfg(all(feature = "ssr", feature = "axum"))]
if let Some(name) = reactive_graph::owner::with_context::<::http::request::Parts, _>(|parts| {
parts
.extensions
.get::<crate::axum::RequestLocale>()
.and_then(crate::axum::RequestLocale::query)
})
.flatten()
{
return name;
}
crate::links::LOCALE_QUERY
}
#[cfg(feature = "ssr")]
#[must_use]
pub fn request_time_zone() -> Option<mf2_runtime::TimeZone> {
current().and_then(|c| c.time_zone)
}
#[cfg(feature = "ssr")]
#[must_use]
pub fn active() -> Option<Arc<Catalog>> {
current().map(|c| c.catalog)
}
#[cfg(feature = "ssr")]
pub(crate) fn current() -> Option<RequestI18n> {
if let Some(cx) = request() {
return Some(cx);
}
let fallback = default_catalog().map(RequestI18n::new);
unrequested(fallback.is_some());
fallback
}
#[cfg(feature = "ssr")]
fn unrequested(source_language: bool) {
use crate::warn::{Kind, once, pending};
let kind = if source_language {
Kind::Unrequested
} else {
Kind::NoCatalogs
};
if !pending(kind) {
return;
}
#[cfg(feature = "axum")]
if source_language && !served() {
return;
}
once(kind, || {
if source_language {
alloc::format!(
"mf2: a page rendered without the request's language, so it is in the source \
language, `{}`; add mf2::axum's Negotiator layer to the router, or call \
provide_locale in the render",
state::source_locale()
)
} else {
alloc::string::String::from(
"mf2: a message was formatted with no catalogs installed, so it rendered as \
empty text; call the generated install() at start-up",
)
}
});
}
#[cfg(all(feature = "ssr", feature = "axum"))]
fn served() -> bool {
reactive_graph::owner::with_context::<::http::request::Parts, _>(|parts| {
parts
.extensions
.get::<::axum::extract::OriginalUri>()
.is_some()
})
.unwrap_or(false)
}
#[cfg(feature = "ssr")]
fn request() -> Option<RequestI18n> {
if let Some(cx) = reactive_graph::owner::use_context::<RequestI18n>() {
return Some(cx);
}
#[cfg(feature = "axum")]
if let Some((tag, zone, query)) = from_layer() {
if let Some(name) = query {
provide_locale_query(name);
}
provide_locale_in_zone(tag, zone);
return reactive_graph::owner::use_context::<RequestI18n>();
}
None
}
#[cfg(all(feature = "ssr", feature = "axum"))]
fn from_layer() -> Option<(
&'static str,
Option<mf2_runtime::TimeZone>,
Option<&'static str>,
)> {
reactive_graph::owner::with_context::<::http::request::Parts, _>(|parts| {
let request = parts.extensions.get::<crate::axum::RequestLocale>()?;
let tag = request.read().tag;
let zone = crate::axum::negotiate_cookie(parts, crate::leptos::links::TIME_ZONE_COOKIE)
.and_then(crate::leptos::reader_time_zone);
if zone.is_some() {
request.zoned();
}
Some((tag, zone, request.query()))
})
.flatten()
}
#[cfg(not(feature = "ssr"))]
pub(crate) fn current() -> Option<Resolved> {
let found = active().map(|catalog| Resolved { catalog });
#[cfg(all(debug_assertions, target_arch = "wasm32"))]
if found.is_none() {
no_catalog_warning();
}
found
}
#[cfg(all(not(feature = "ssr"), debug_assertions, target_arch = "wasm32"))]
fn no_catalog_warning() {
use core::sync::atomic::{AtomicBool, Ordering};
static GIVEN: AtomicBool = AtomicBool::new(false);
if !GIVEN.swap(true, Ordering::Relaxed) {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(
"mf2: a message was formatted before any catalog was active, so it rendered as \
empty text; start the page through mf2::leptos (hydrate_body, mount_to_body), \
which loads the catalog first. (Debug builds only.)",
));
}
}
#[cfg(all(feature = "ssr", feature = "native"))]
pub(crate) fn requested() -> Option<RequestI18n> {
request()
}
#[cfg(all(not(feature = "ssr"), feature = "native"))]
pub(crate) fn requested() -> Option<Resolved> {
current()
}
#[cfg(not(feature = "ssr"))]
pub(crate) struct Resolved {
catalog: Arc<Catalog>,
}
#[cfg(not(feature = "ssr"))]
impl Resolved {
pub(crate) fn catalog(&self) -> &Catalog {
&self.catalog
}
#[allow(clippy::unused_self)]
pub(crate) fn registry(&self) -> Option<&'static mf2_runtime::Registry> {
None
}
#[allow(clippy::unused_self)]
pub(crate) fn bidi(&self) -> Option<mf2_runtime::BidiStrategy> {
None
}
#[allow(clippy::unused_self)]
pub(crate) fn in_zone<R>(&self, body: impl FnOnce() -> R) -> R {
body()
}
}
#[cfg(feature = "ssr")]
impl RequestI18n {
pub(crate) fn catalog(&self) -> &Catalog {
&self.catalog
}
pub(crate) fn registry(&self) -> Option<&'static mf2_runtime::Registry> {
self.registry
}
pub(crate) fn bidi(&self) -> Option<mf2_runtime::BidiStrategy> {
self.bidi
}
pub(crate) fn in_zone<R>(&self, body: impl FnOnce() -> R) -> R {
crate::leptos::zone::scoped(self.time_zone, body)
}
}
#[cfg(not(feature = "ssr"))]
mod client {
use super::{Arc, Catalog};
use crate::line::reactive_graph;
use core::cell::RefCell;
use reactive_graph::signal::ArcTrigger;
std::thread_local! {
static ACTIVE: RefCell<Option<Arc<Catalog>>> = const { RefCell::new(None) };
static CHANGED: ArcTrigger = ArcTrigger::new();
}
#[must_use]
pub fn active() -> Option<Arc<Catalog>> {
ACTIVE.with(|a| a.borrow().clone())
}
pub fn set_active(catalog: Arc<Catalog>) {
ACTIVE.with(|a| *a.borrow_mut() = Some(catalog));
}
#[must_use]
pub fn changed() -> ArcTrigger {
CHANGED.with(Clone::clone)
}
pub fn track_locale() {
use reactive_graph::graph::{Observer, Source};
use reactive_graph::owner::Owner;
use reactive_graph::traits::Track;
let Some(observer) = Observer::get() else {
return;
};
let trigger = changed();
trigger.track();
Owner::on_cleanup(move || trigger.remove_subscriber(&observer));
}
}
#[cfg(not(feature = "ssr"))]
pub use client::{active, changed, set_active, track_locale};
#[cfg(all(test, feature = "ssr"))]
#[allow(clippy::expect_used, reason = "a test")]
mod tests {
use crate::warn::{Kind, given};
#[test]
fn a_render_with_no_catalogs_says_so_once() {
assert!(super::default_catalog().is_none());
for _ in 0..3 {
assert!(super::current().is_none());
}
assert_eq!(given(Kind::NoCatalogs).len(), 1);
}
#[test]
fn an_unknown_language_provided_is_named_once() {
for _ in 0..3 {
let _ = super::provide_locale("tlh-test");
}
let lines = given(Kind::UnknownLocale);
assert_eq!(
lines.iter().filter(|l| l.contains("\"tlh-test\"")).count(),
1
);
}
#[test]
fn a_request_rendered_without_its_language_says_so_once() {
#[cfg(all(feature = "axum", feature = "leptos"))]
through_leptos_axum();
#[cfg(not(all(feature = "axum", feature = "leptos")))]
{
use crate::line::reactive_graph::owner;
owner::Owner::new().with(|| {
#[cfg(feature = "axum")]
owner::provide_context(served_parts());
for _ in 0..3 {
super::unrequested(true);
}
});
}
let lines = given(Kind::Unrequested);
assert_eq!(lines.len(), 1);
assert!(
lines
.iter()
.all(|l| l.contains("without the request's language"))
);
}
#[cfg(all(feature = "axum", not(feature = "leptos")))]
fn served_parts() -> ::http::request::Parts {
let mut parts = ::http::Request::builder()
.body(())
.expect("a request")
.into_parts()
.0;
let uri = parts.uri.clone();
parts.extensions.insert(::axum::extract::OriginalUri(uri));
parts
}
#[cfg(all(feature = "axum", feature = "leptos"))]
fn through_leptos_axum() {
use ::axum::{Router, body::Body, http::Request};
use leptos_axum::{LeptosRoutes, generate_route_list};
use tower::ServiceExt;
fn page() {
if super::from_layer().is_none() {
super::unrequested(true);
}
}
fn server(negotiator: bool) -> Router {
let options = crate::line::leptos::config::LeptosOptions::builder()
.output_name("unrequested")
.build();
let routes = generate_route_list(page);
let router = Router::new().leptos_routes(&options, routes, page);
let router = if negotiator {
router.layer(crate::axum::Negotiator::default())
} else {
router
};
router.with_state(options)
}
async fn get(router: Router) {
let response = router
.oneshot(Request::get("/").body(Body::empty()).expect("a request"))
.await
.expect("a response");
let _ = ::axum::body::to_bytes(response.into_body(), usize::MAX).await;
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a runtime");
let with = server(true);
assert!(given(Kind::Unrequested).is_empty());
runtime.block_on(get(with));
assert!(given(Kind::Unrequested).is_empty());
let without = server(false);
runtime.block_on(get(without.clone()));
runtime.block_on(get(without));
}
}