use std::{
borrow::Cow,
collections::BTreeMap,
sync::{Arc, Mutex},
thread,
};
use serde::{Deserialize, Serialize};
use crate::{
DEFAULT_THEME_RUNTIME_PATH, THEME_PACKAGE_NAME, THEME_PACKAGE_VERSION, ThemeConfig,
ThemeDefinition, ThemeDiagnosticVerbosity, ThemeFallbackStrategy, ThemeManifestFragment,
ThemeOutputReport, ThemeRoutePolicy, ThemeSerializationFormat, explain_theme, theme_cache_key,
theme_manifest_fragment, theme_tokens_css,
};
pub const DEFAULT_THEME_CONFIG_ID: &str = "__DXT_THEME_CONFIG__";
pub const DEFAULT_THEME_PREPAINT_STYLE_ID: &str = "__DXT_THEME_PREPAINT__";
pub const DEFAULT_THEME_RUNTIME_ASSET_ID: &str = "theme.runtime";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeDiagnosticContext {
Build,
Ssr,
Runtime,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeDiagnosticVerbosityByContext {
pub build: ThemeDiagnosticVerbosity,
pub ssr: ThemeDiagnosticVerbosity,
pub runtime: ThemeDiagnosticVerbosity,
}
impl Default for ThemeDiagnosticVerbosityByContext {
fn default() -> Self {
Self {
build: ThemeDiagnosticVerbosity::Detailed,
ssr: ThemeDiagnosticVerbosity::Summary,
runtime: ThemeDiagnosticVerbosity::Off,
}
}
}
impl ThemeDiagnosticVerbosityByContext {
pub fn for_context(&self, context: ThemeDiagnosticContext) -> ThemeDiagnosticVerbosity {
match context {
ThemeDiagnosticContext::Build => self.build,
ThemeDiagnosticContext::Ssr => self.ssr,
ThemeDiagnosticContext::Runtime => self.runtime,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeRuntimeIds {
pub config_id: String,
pub prepaint_style_id: String,
pub runtime_asset_id: String,
}
impl Default for ThemeRuntimeIds {
fn default() -> Self {
Self {
config_id: DEFAULT_THEME_CONFIG_ID.to_string(),
prepaint_style_id: DEFAULT_THEME_PREPAINT_STYLE_ID.to_string(),
runtime_asset_id: DEFAULT_THEME_RUNTIME_ASSET_ID.to_string(),
}
}
}
impl ThemeRuntimeIds {
pub fn with_config_id(mut self, id: impl Into<String>) -> Self {
self.config_id = id.into();
self
}
pub fn with_prepaint_style_id(mut self, id: impl Into<String>) -> Self {
self.prepaint_style_id = id.into();
self
}
pub fn with_runtime_asset_id(mut self, id: impl Into<String>) -> Self {
self.runtime_asset_id = id.into();
self
}
pub fn duplicate_ids(&self) -> Vec<String> {
let mut seen = BTreeMap::<&str, usize>::new();
for id in [
self.config_id.as_str(),
self.prepaint_style_id.as_str(),
self.runtime_asset_id.as_str(),
] {
*seen.entry(id).or_default() += 1;
}
seen.into_iter()
.filter_map(|(id, count)| (count > 1).then(|| id.to_string()))
.collect()
}
pub fn deduped(mut self) -> Self {
if self.config_id == self.prepaint_style_id {
self.prepaint_style_id.push_str("-2");
}
if self.config_id == self.runtime_asset_id
|| self.prepaint_style_id == self.runtime_asset_id
{
self.runtime_asset_id.push_str("-3");
}
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeAssetPaths {
pub runtime_base_path: String,
pub runtime_asset_name: String,
}
impl Default for ThemeAssetPaths {
fn default() -> Self {
Self {
runtime_base_path: "/assets".to_string(),
runtime_asset_name: "dioxus-theme.js".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeAssetBudgetCategory {
RuntimeScript,
PrepaintStyle,
ConfigJson,
TokenCss,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeAssetBudgetEntry {
pub category: ThemeAssetBudgetCategory,
pub bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeAssetBudgetBridge {
pub package: String,
pub entries: Vec<ThemeAssetBudgetEntry>,
}
impl ThemeAssetBudgetBridge {
pub fn total_bytes(&self) -> usize {
self.entries.iter().map(|entry| entry.bytes).sum()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeBatchOptions {
pub format: ThemeSerializationFormat,
pub deterministic_parallel: bool,
pub sort_by_cache_key: bool,
}
impl Default for ThemeBatchOptions {
fn default() -> Self {
Self {
format: ThemeSerializationFormat::StableJson,
deterministic_parallel: false,
sort_by_cache_key: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeBatchPayload {
pub route: Option<String>,
pub cache_key: String,
pub json: String,
pub bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeBatchSerializationReport {
pub package: String,
pub deterministic_parallel: bool,
pub payloads: Vec<ThemeBatchPayload>,
pub total_bytes: usize,
}
#[derive(Debug)]
pub struct ThemeBorrowedView<'a> {
pub config: &'a ThemeConfig,
pub policy: Cow<'a, ThemeRoutePolicy>,
}
impl<'a> ThemeBorrowedView<'a> {
pub fn new(config: &'a ThemeConfig, policy: &'a ThemeRoutePolicy) -> Self {
Self {
config,
policy: Cow::Borrowed(policy),
}
}
pub fn with_owned_policy(config: &'a ThemeConfig, policy: ThemeRoutePolicy) -> Self {
Self {
config,
policy: Cow::Owned(policy),
}
}
pub fn manifest_fragment(&self) -> ThemeManifestFragment {
theme_manifest_fragment(self.config, self.policy.as_ref())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeCssFragment {
pub id: String,
pub css: String,
pub bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeMotionPolicy {
pub reduced_motion: String,
pub view_transition: String,
pub render_lane: String,
}
impl Default for ThemeMotionPolicy {
fn default() -> Self {
Self {
reduced_motion: "respect".to_string(),
view_transition: "theme-switch".to_string(),
render_lane: "theme".to_string(),
}
}
}
pub trait ThemeMotionPolicyHook {
fn apply(&self, policy: ThemeMotionPolicy) -> ThemeMotionPolicy;
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeRouteMetadata {
pub route: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cache_keys: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub packages: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeOptimizerArtifact {
pub id: String,
pub content_hash: String,
pub bytes: usize,
pub minified: bool,
}
pub trait ThemeOptimizerReuseHook {
fn reuse_artifact(&self, artifact: &ThemeOptimizerArtifact) -> Option<String>;
}
pub trait ThemeCacheBackend {
fn get(&self, key: &str) -> Option<String>;
fn put(&self, key: String, value: String);
}
#[derive(Clone, Default)]
pub struct ThemeMemoryCache {
values: Arc<Mutex<BTreeMap<String, String>>>,
}
impl ThemeCacheBackend for ThemeMemoryCache {
fn get(&self, key: &str) -> Option<String> {
self.values.lock().ok()?.get(key).cloned()
}
fn put(&self, key: String, value: String) {
if let Ok(mut values) = self.values.lock() {
values.insert(key, value);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeOffloadPlan {
pub package: String,
pub lane: String,
pub serializable: bool,
pub fallback: ThemeFallbackStrategy,
pub payload_cache_key: String,
pub tasks: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeCompactDictionary {
pub version: u8,
pub terms: BTreeMap<String, u16>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeTraceEvent {
pub name: String,
pub cache_key: String,
pub route: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeStrataMigrationPlan {
pub package: String,
pub route: Option<String>,
pub steps: Vec<String>,
}
pub fn theme_serialize_batch<'a>(
entries: impl IntoIterator<Item = (&'a ThemeConfig, ThemeRoutePolicy)>,
options: ThemeBatchOptions,
) -> serde_json::Result<ThemeBatchSerializationReport> {
let entries = entries.into_iter().collect::<Vec<_>>();
let mut payloads = if options.deterministic_parallel && entries.len() > 1 {
thread::scope(|scope| {
let mut handles = Vec::new();
for (config, policy) in entries {
handles.push(scope.spawn(move || serialize_one(config, &policy, options.format)));
}
handles
.into_iter()
.map(|handle| handle.join().expect("Theme batch worker panicked"))
.collect::<serde_json::Result<Vec<_>>>()
})?
} else {
entries
.into_iter()
.map(|(config, policy)| serialize_one(config, &policy, options.format))
.collect::<serde_json::Result<Vec<_>>>()?
};
if options.sort_by_cache_key {
payloads.sort_by(|a, b| a.cache_key.cmp(&b.cache_key));
}
let total_bytes = payloads.iter().map(|payload| payload.bytes).sum();
Ok(ThemeBatchSerializationReport {
package: THEME_PACKAGE_NAME.to_string(),
deterministic_parallel: options.deterministic_parallel,
payloads,
total_bytes,
})
}
pub fn theme_asset_budget_bridge(
config: &ThemeConfig,
policy: &ThemeRoutePolicy,
) -> ThemeAssetBudgetBridge {
let output = config.output_report(policy);
ThemeAssetBudgetBridge {
package: THEME_PACKAGE_NAME.to_string(),
entries: vec![
ThemeAssetBudgetEntry {
category: ThemeAssetBudgetCategory::ConfigJson,
bytes: output.config_bytes,
},
ThemeAssetBudgetEntry {
category: ThemeAssetBudgetCategory::RuntimeScript,
bytes: output.runtime_bytes,
},
ThemeAssetBudgetEntry {
category: ThemeAssetBudgetCategory::TokenCss,
bytes: output.style_bytes,
},
],
}
}
pub fn theme_precomputed_css_fragments(config: &ThemeConfig) -> Vec<ThemeCssFragment> {
config
.registry
.themes
.iter()
.map(|theme| theme_css_fragment(theme))
.collect()
}
pub fn theme_apply_motion_hook<H: ThemeMotionPolicyHook>(
policy: ThemeMotionPolicy,
hook: &H,
) -> ThemeMotionPolicy {
hook.apply(policy)
}
pub fn theme_coalesce_route_metadata(
route: impl Into<String>,
fragments: impl IntoIterator<Item = ThemeManifestFragment>,
) -> ThemeRouteMetadata {
let mut metadata = ThemeRouteMetadata {
route: route.into(),
..ThemeRouteMetadata::default()
};
for fragment in fragments {
metadata.cache_keys.push(fragment.cache_key);
if !metadata.packages.contains(&fragment.package) {
metadata.packages.push(fragment.package);
}
metadata.labels.extend(fragment.labels);
}
metadata.cache_keys.sort();
metadata.packages.sort();
metadata
}
pub fn theme_optimizer_artifacts(report: &ThemeOutputReport) -> Vec<ThemeOptimizerArtifact> {
[
("theme.config", report.config_bytes, true),
("theme.runtime", report.runtime_bytes, true),
("theme.style", report.style_bytes, true),
]
.into_iter()
.map(|(id, bytes, minified)| ThemeOptimizerArtifact {
id: id.to_string(),
content_hash: integration_hash_hex([id, &report.cache_key, &bytes.to_string()]),
bytes,
minified,
})
.collect()
}
pub fn theme_cache_put_report<B: ThemeCacheBackend>(
cache: &B,
report: &ThemeOutputReport,
) -> String {
let key = format!(
"{}:{}:{}",
THEME_PACKAGE_NAME, THEME_PACKAGE_VERSION, report.cache_key
);
cache.put(
key.clone(),
serde_json::to_string(report).unwrap_or_default(),
);
key
}
pub fn theme_workertown_offload_plan(
config: &ThemeConfig,
policy: &ThemeRoutePolicy,
) -> ThemeOffloadPlan {
ThemeOffloadPlan {
package: THEME_PACKAGE_NAME.to_string(),
lane: "theme".to_string(),
serializable: true,
fallback: policy.fallback,
payload_cache_key: theme_cache_key(config, policy.route.as_deref(), Some("workertown")),
tasks: vec![
"resolve-system-theme".to_string(),
"serialize-token-css".to_string(),
"notify-visual-packages".to_string(),
],
}
}
pub fn theme_compact_dictionary() -> ThemeCompactDictionary {
let mut terms = BTreeMap::new();
for (index, term) in [
"config",
"runtime",
"style",
"route",
"profile",
"tokens",
"prepaint",
"view-transition",
]
.into_iter()
.enumerate()
{
terms.insert(term.to_string(), index as u16);
}
ThemeCompactDictionary { version: 1, terms }
}
pub fn theme_trace_report(
config: &ThemeConfig,
policy: &ThemeRoutePolicy,
mut emit: impl FnMut(ThemeTraceEvent),
) {
let explain = explain_theme(config, policy);
emit(ThemeTraceEvent {
name: "theme.policy".to_string(),
cache_key: explain.cache_key.clone(),
route: policy.route.clone(),
message: explain.runtime_decision,
});
emit(ThemeTraceEvent {
name: "theme.output".to_string(),
cache_key: explain.cache_key,
route: policy.route.clone(),
message: format!("{} style bytes", explain.output.style_bytes),
});
}
pub fn theme_strata_migration_plan(
config: &ThemeConfig,
policy: &ThemeRoutePolicy,
) -> ThemeStrataMigrationPlan {
ThemeStrataMigrationPlan {
package: THEME_PACKAGE_NAME.to_string(),
route: policy.route.clone(),
steps: vec![
"emit ThemeManifestFragment from standalone theme config".to_string(),
format!(
"register cache key {}",
config.cache_key(policy.route.as_deref())
),
"attach prepaint CSS and runtime asset through Strata route metadata".to_string(),
"preserve fallback and diagnostic context policy".to_string(),
],
}
}
pub fn theme_conformance_fixture() -> serde_json::Value {
let config = ThemeConfig::default();
let policy = ThemeRoutePolicy::default().route("/theme");
serde_json::json!({
"manifest": theme_manifest_fragment(&config, &policy),
"dictionary": theme_compact_dictionary(),
"cssFragments": theme_precomputed_css_fragments(&config),
"runtimePath": DEFAULT_THEME_RUNTIME_PATH,
})
}
fn theme_css_fragment(theme: &ThemeDefinition) -> ThemeCssFragment {
let css = theme_tokens_css(theme);
ThemeCssFragment {
id: theme.id.clone(),
bytes: css.len(),
css,
}
}
fn serialize_one(
config: &ThemeConfig,
policy: &ThemeRoutePolicy,
format: ThemeSerializationFormat,
) -> serde_json::Result<ThemeBatchPayload> {
let json = config.to_preferred_json(format)?;
Ok(ThemeBatchPayload {
route: policy.route.clone(),
cache_key: theme_cache_key(config, policy.route.as_deref(), Some(format.as_attr())),
bytes: json.len(),
json,
})
}
fn integration_hash_hex<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
let mut hash = 0xcbf29ce484222325u64;
for part in parts {
for byte in part.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash ^= 0xff;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ThemeOutputBudget, ThemePresetProfile, ThemeRoutePolicy};
#[test]
fn batch_serialization_parallel_is_deterministic() {
let config = ThemeConfig::default();
let a = ThemeRoutePolicy::default().route("/a");
let b = ThemeRoutePolicy::default().route("/b");
let serial = theme_serialize_batch(
[(&config, a.clone()), (&config, b.clone())],
ThemeBatchOptions::default(),
)
.unwrap();
let parallel = theme_serialize_batch(
[(&config, a), (&config, b)],
ThemeBatchOptions {
deterministic_parallel: true,
..ThemeBatchOptions::default()
},
)
.unwrap();
assert_eq!(serial.payloads, parallel.payloads);
assert!(parallel.deterministic_parallel);
}
#[test]
fn budget_duplicate_ids_cache_and_offload_are_reported() {
let config = ThemeConfig::default().route_profile(ThemePresetProfile::Conservative);
let policy = ThemeRoutePolicy::default()
.route("/theme")
.budget(ThemeOutputBudget::new().runtime_bytes(4));
let report = config.output_report(&policy);
let bridge = theme_asset_budget_bridge(&config, &policy);
let ids = ThemeRuntimeIds::default().with_runtime_asset_id(DEFAULT_THEME_CONFIG_ID);
let cache = ThemeMemoryCache::default();
let key = theme_cache_put_report(&cache, &report);
let offload = theme_workertown_offload_plan(&config, &policy);
assert!(bridge.total_bytes() >= report.config_bytes);
assert_eq!(
ids.duplicate_ids(),
vec![DEFAULT_THEME_CONFIG_ID.to_string()]
);
assert!(ids.deduped().duplicate_ids().is_empty());
assert!(cache.get(&key).is_some());
assert!(offload.serializable);
assert!(
offload
.tasks
.iter()
.any(|task| task == "serialize-token-css")
);
}
#[test]
fn css_motion_metadata_optimizer_trace_and_migration_are_concrete() {
struct ForceStatic;
impl ThemeMotionPolicyHook for ForceStatic {
fn apply(&self, mut policy: ThemeMotionPolicy) -> ThemeMotionPolicy {
policy.reduced_motion = "static".to_string();
policy
}
}
let config = ThemeConfig::default();
let policy = ThemeRoutePolicy::default().route("/theme");
let manifest = config.manifest_fragment(&policy);
let metadata = theme_coalesce_route_metadata("/theme", [manifest]);
let css = theme_precomputed_css_fragments(&config);
let output = config.output_report(&policy);
let artifacts = theme_optimizer_artifacts(&output);
let motion = theme_apply_motion_hook(ThemeMotionPolicy::default(), &ForceStatic);
let dictionary = theme_compact_dictionary();
let migration = theme_strata_migration_plan(&config, &policy);
let mut traces = Vec::new();
theme_trace_report(&config, &policy, |event| traces.push(event));
let fixture = theme_conformance_fixture();
assert_eq!(metadata.route, "/theme");
assert!(css.iter().any(|fragment| fragment.id == "light"));
assert!(
artifacts
.iter()
.any(|artifact| artifact.id == "theme.runtime")
);
assert_eq!(motion.reduced_motion, "static");
assert_eq!(dictionary.terms["runtime"], 1);
assert!(migration.steps.iter().any(|step| step.contains("Strata")));
assert_eq!(traces.len(), 2);
assert!(fixture.get("manifest").is_some());
}
}