1use std::{
2 borrow::Cow,
3 collections::BTreeMap,
4 sync::{Arc, Mutex},
5 thread,
6};
7
8use serde::{Deserialize, Serialize};
9
10use crate::{
11 DEFAULT_THEME_RUNTIME_PATH, THEME_PACKAGE_NAME, THEME_PACKAGE_VERSION, ThemeConfig,
12 ThemeDefinition, ThemeDiagnosticVerbosity, ThemeFallbackStrategy, ThemeManifestFragment,
13 ThemeOutputReport, ThemeRoutePolicy, ThemeSerializationFormat, explain_theme, theme_cache_key,
14 theme_manifest_fragment, theme_tokens_css,
15};
16
17pub const DEFAULT_THEME_CONFIG_ID: &str = "__DXT_THEME_CONFIG__";
18pub const DEFAULT_THEME_PREPAINT_STYLE_ID: &str = "__DXT_THEME_PREPAINT__";
19pub const DEFAULT_THEME_RUNTIME_ASSET_ID: &str = "theme.runtime";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum ThemeDiagnosticContext {
24 Build,
25 Ssr,
26 Runtime,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct ThemeDiagnosticVerbosityByContext {
32 pub build: ThemeDiagnosticVerbosity,
33 pub ssr: ThemeDiagnosticVerbosity,
34 pub runtime: ThemeDiagnosticVerbosity,
35}
36
37impl Default for ThemeDiagnosticVerbosityByContext {
38 fn default() -> Self {
39 Self {
40 build: ThemeDiagnosticVerbosity::Detailed,
41 ssr: ThemeDiagnosticVerbosity::Summary,
42 runtime: ThemeDiagnosticVerbosity::Off,
43 }
44 }
45}
46
47impl ThemeDiagnosticVerbosityByContext {
48 pub fn for_context(&self, context: ThemeDiagnosticContext) -> ThemeDiagnosticVerbosity {
49 match context {
50 ThemeDiagnosticContext::Build => self.build,
51 ThemeDiagnosticContext::Ssr => self.ssr,
52 ThemeDiagnosticContext::Runtime => self.runtime,
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct ThemeRuntimeIds {
60 pub config_id: String,
61 pub prepaint_style_id: String,
62 pub runtime_asset_id: String,
63}
64
65impl Default for ThemeRuntimeIds {
66 fn default() -> Self {
67 Self {
68 config_id: DEFAULT_THEME_CONFIG_ID.to_string(),
69 prepaint_style_id: DEFAULT_THEME_PREPAINT_STYLE_ID.to_string(),
70 runtime_asset_id: DEFAULT_THEME_RUNTIME_ASSET_ID.to_string(),
71 }
72 }
73}
74
75impl ThemeRuntimeIds {
76 pub fn with_config_id(mut self, id: impl Into<String>) -> Self {
77 self.config_id = id.into();
78 self
79 }
80
81 pub fn with_prepaint_style_id(mut self, id: impl Into<String>) -> Self {
82 self.prepaint_style_id = id.into();
83 self
84 }
85
86 pub fn with_runtime_asset_id(mut self, id: impl Into<String>) -> Self {
87 self.runtime_asset_id = id.into();
88 self
89 }
90
91 pub fn duplicate_ids(&self) -> Vec<String> {
92 let mut seen = BTreeMap::<&str, usize>::new();
93 for id in [
94 self.config_id.as_str(),
95 self.prepaint_style_id.as_str(),
96 self.runtime_asset_id.as_str(),
97 ] {
98 *seen.entry(id).or_default() += 1;
99 }
100 seen.into_iter()
101 .filter_map(|(id, count)| (count > 1).then(|| id.to_string()))
102 .collect()
103 }
104
105 pub fn deduped(mut self) -> Self {
106 if self.config_id == self.prepaint_style_id {
107 self.prepaint_style_id.push_str("-2");
108 }
109 if self.config_id == self.runtime_asset_id
110 || self.prepaint_style_id == self.runtime_asset_id
111 {
112 self.runtime_asset_id.push_str("-3");
113 }
114 self
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct ThemeAssetPaths {
121 pub runtime_base_path: String,
122 pub runtime_asset_name: String,
123}
124
125impl Default for ThemeAssetPaths {
126 fn default() -> Self {
127 Self {
128 runtime_base_path: "/assets".to_string(),
129 runtime_asset_name: "dioxus-theme.js".to_string(),
130 }
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "kebab-case")]
136pub enum ThemeAssetBudgetCategory {
137 RuntimeScript,
138 PrepaintStyle,
139 ConfigJson,
140 TokenCss,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct ThemeAssetBudgetEntry {
146 pub category: ThemeAssetBudgetCategory,
147 pub bytes: usize,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct ThemeAssetBudgetBridge {
153 pub package: String,
154 pub entries: Vec<ThemeAssetBudgetEntry>,
155}
156
157impl ThemeAssetBudgetBridge {
158 pub fn total_bytes(&self) -> usize {
159 self.entries.iter().map(|entry| entry.bytes).sum()
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct ThemeBatchOptions {
166 pub format: ThemeSerializationFormat,
167 pub deterministic_parallel: bool,
168 pub sort_by_cache_key: bool,
169}
170
171impl Default for ThemeBatchOptions {
172 fn default() -> Self {
173 Self {
174 format: ThemeSerializationFormat::StableJson,
175 deterministic_parallel: false,
176 sort_by_cache_key: true,
177 }
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct ThemeBatchPayload {
184 pub route: Option<String>,
185 pub cache_key: String,
186 pub json: String,
187 pub bytes: usize,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase")]
192pub struct ThemeBatchSerializationReport {
193 pub package: String,
194 pub deterministic_parallel: bool,
195 pub payloads: Vec<ThemeBatchPayload>,
196 pub total_bytes: usize,
197}
198
199#[derive(Debug)]
200pub struct ThemeBorrowedView<'a> {
201 pub config: &'a ThemeConfig,
202 pub policy: Cow<'a, ThemeRoutePolicy>,
203}
204
205impl<'a> ThemeBorrowedView<'a> {
206 pub fn new(config: &'a ThemeConfig, policy: &'a ThemeRoutePolicy) -> Self {
207 Self {
208 config,
209 policy: Cow::Borrowed(policy),
210 }
211 }
212
213 pub fn with_owned_policy(config: &'a ThemeConfig, policy: ThemeRoutePolicy) -> Self {
214 Self {
215 config,
216 policy: Cow::Owned(policy),
217 }
218 }
219
220 pub fn manifest_fragment(&self) -> ThemeManifestFragment {
221 theme_manifest_fragment(self.config, self.policy.as_ref())
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase")]
227pub struct ThemeCssFragment {
228 pub id: String,
229 pub css: String,
230 pub bytes: usize,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct ThemeMotionPolicy {
236 pub reduced_motion: String,
237 pub view_transition: String,
238 pub render_lane: String,
239}
240
241impl Default for ThemeMotionPolicy {
242 fn default() -> Self {
243 Self {
244 reduced_motion: "respect".to_string(),
245 view_transition: "theme-switch".to_string(),
246 render_lane: "theme".to_string(),
247 }
248 }
249}
250
251pub trait ThemeMotionPolicyHook {
252 fn apply(&self, policy: ThemeMotionPolicy) -> ThemeMotionPolicy;
253}
254
255#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct ThemeRouteMetadata {
258 pub route: String,
259 #[serde(default, skip_serializing_if = "Vec::is_empty")]
260 pub cache_keys: Vec<String>,
261 #[serde(default, skip_serializing_if = "Vec::is_empty")]
262 pub packages: Vec<String>,
263 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
264 pub labels: BTreeMap<String, String>,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "camelCase")]
269pub struct ThemeOptimizerArtifact {
270 pub id: String,
271 pub content_hash: String,
272 pub bytes: usize,
273 pub minified: bool,
274}
275
276pub trait ThemeOptimizerReuseHook {
277 fn reuse_artifact(&self, artifact: &ThemeOptimizerArtifact) -> Option<String>;
278}
279
280pub trait ThemeCacheBackend {
281 fn get(&self, key: &str) -> Option<String>;
282 fn put(&self, key: String, value: String);
283}
284
285#[derive(Clone, Default)]
286pub struct ThemeMemoryCache {
287 values: Arc<Mutex<BTreeMap<String, String>>>,
288}
289
290impl ThemeCacheBackend for ThemeMemoryCache {
291 fn get(&self, key: &str) -> Option<String> {
292 self.values.lock().ok()?.get(key).cloned()
293 }
294
295 fn put(&self, key: String, value: String) {
296 if let Ok(mut values) = self.values.lock() {
297 values.insert(key, value);
298 }
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "camelCase")]
304pub struct ThemeOffloadPlan {
305 pub package: String,
306 pub lane: String,
307 pub serializable: bool,
308 pub fallback: ThemeFallbackStrategy,
309 pub payload_cache_key: String,
310 pub tasks: Vec<String>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(rename_all = "camelCase")]
315pub struct ThemeCompactDictionary {
316 pub version: u8,
317 pub terms: BTreeMap<String, u16>,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "camelCase")]
322pub struct ThemeTraceEvent {
323 pub name: String,
324 pub cache_key: String,
325 pub route: Option<String>,
326 pub message: String,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330#[serde(rename_all = "camelCase")]
331pub struct ThemeStrataMigrationPlan {
332 pub package: String,
333 pub route: Option<String>,
334 pub steps: Vec<String>,
335}
336
337pub fn theme_serialize_batch<'a>(
351 entries: impl IntoIterator<Item = (&'a ThemeConfig, ThemeRoutePolicy)>,
352 options: ThemeBatchOptions,
353) -> serde_json::Result<ThemeBatchSerializationReport> {
354 let entries = entries.into_iter().collect::<Vec<_>>();
355 let mut payloads = if options.deterministic_parallel && entries.len() > 1 {
356 thread::scope(|scope| {
357 let mut handles = Vec::new();
358 for (config, policy) in entries {
359 handles.push(scope.spawn(move || serialize_one(config, &policy, options.format)));
360 }
361 handles
362 .into_iter()
363 .map(|handle| handle.join().expect("Theme batch worker panicked"))
364 .collect::<serde_json::Result<Vec<_>>>()
365 })?
366 } else {
367 entries
368 .into_iter()
369 .map(|(config, policy)| serialize_one(config, &policy, options.format))
370 .collect::<serde_json::Result<Vec<_>>>()?
371 };
372 if options.sort_by_cache_key {
373 payloads.sort_by(|a, b| a.cache_key.cmp(&b.cache_key));
374 }
375 let total_bytes = payloads.iter().map(|payload| payload.bytes).sum();
376 Ok(ThemeBatchSerializationReport {
377 package: THEME_PACKAGE_NAME.to_string(),
378 deterministic_parallel: options.deterministic_parallel,
379 payloads,
380 total_bytes,
381 })
382}
383
384pub fn theme_asset_budget_bridge(
385 config: &ThemeConfig,
386 policy: &ThemeRoutePolicy,
387) -> ThemeAssetBudgetBridge {
388 let output = config.output_report(policy);
389 ThemeAssetBudgetBridge {
390 package: THEME_PACKAGE_NAME.to_string(),
391 entries: vec![
392 ThemeAssetBudgetEntry {
393 category: ThemeAssetBudgetCategory::ConfigJson,
394 bytes: output.config_bytes,
395 },
396 ThemeAssetBudgetEntry {
397 category: ThemeAssetBudgetCategory::RuntimeScript,
398 bytes: output.runtime_bytes,
399 },
400 ThemeAssetBudgetEntry {
401 category: ThemeAssetBudgetCategory::TokenCss,
402 bytes: output.style_bytes,
403 },
404 ],
405 }
406}
407
408pub fn theme_precomputed_css_fragments(config: &ThemeConfig) -> Vec<ThemeCssFragment> {
409 config
410 .registry
411 .themes
412 .iter()
413 .map(|theme| theme_css_fragment(theme))
414 .collect()
415}
416
417pub fn theme_apply_motion_hook<H: ThemeMotionPolicyHook>(
418 policy: ThemeMotionPolicy,
419 hook: &H,
420) -> ThemeMotionPolicy {
421 hook.apply(policy)
422}
423
424pub fn theme_coalesce_route_metadata(
425 route: impl Into<String>,
426 fragments: impl IntoIterator<Item = ThemeManifestFragment>,
427) -> ThemeRouteMetadata {
428 let mut metadata = ThemeRouteMetadata {
429 route: route.into(),
430 ..ThemeRouteMetadata::default()
431 };
432 for fragment in fragments {
433 metadata.cache_keys.push(fragment.cache_key);
434 if !metadata.packages.contains(&fragment.package) {
435 metadata.packages.push(fragment.package);
436 }
437 metadata.labels.extend(fragment.labels);
438 }
439 metadata.cache_keys.sort();
440 metadata.packages.sort();
441 metadata
442}
443
444pub fn theme_optimizer_artifacts(report: &ThemeOutputReport) -> Vec<ThemeOptimizerArtifact> {
445 [
446 ("theme.config", report.config_bytes, true),
447 ("theme.runtime", report.runtime_bytes, true),
448 ("theme.style", report.style_bytes, true),
449 ]
450 .into_iter()
451 .map(|(id, bytes, minified)| ThemeOptimizerArtifact {
452 id: id.to_string(),
453 content_hash: integration_hash_hex([id, &report.cache_key, &bytes.to_string()]),
454 bytes,
455 minified,
456 })
457 .collect()
458}
459
460pub fn theme_cache_put_report<B: ThemeCacheBackend>(
461 cache: &B,
462 report: &ThemeOutputReport,
463) -> String {
464 let key = format!(
465 "{}:{}:{}",
466 THEME_PACKAGE_NAME, THEME_PACKAGE_VERSION, report.cache_key
467 );
468 cache.put(
469 key.clone(),
470 serde_json::to_string(report).unwrap_or_default(),
471 );
472 key
473}
474
475pub fn theme_workertown_offload_plan(
476 config: &ThemeConfig,
477 policy: &ThemeRoutePolicy,
478) -> ThemeOffloadPlan {
479 ThemeOffloadPlan {
480 package: THEME_PACKAGE_NAME.to_string(),
481 lane: "theme".to_string(),
482 serializable: true,
483 fallback: policy.fallback,
484 payload_cache_key: theme_cache_key(config, policy.route.as_deref(), Some("workertown")),
485 tasks: vec![
486 "resolve-system-theme".to_string(),
487 "serialize-token-css".to_string(),
488 "notify-visual-packages".to_string(),
489 ],
490 }
491}
492
493pub fn theme_compact_dictionary() -> ThemeCompactDictionary {
494 let mut terms = BTreeMap::new();
495 for (index, term) in [
496 "config",
497 "runtime",
498 "style",
499 "route",
500 "profile",
501 "tokens",
502 "prepaint",
503 "view-transition",
504 ]
505 .into_iter()
506 .enumerate()
507 {
508 terms.insert(term.to_string(), index as u16);
509 }
510 ThemeCompactDictionary { version: 1, terms }
511}
512
513pub fn theme_trace_report(
514 config: &ThemeConfig,
515 policy: &ThemeRoutePolicy,
516 mut emit: impl FnMut(ThemeTraceEvent),
517) {
518 let explain = explain_theme(config, policy);
519 emit(ThemeTraceEvent {
520 name: "theme.policy".to_string(),
521 cache_key: explain.cache_key.clone(),
522 route: policy.route.clone(),
523 message: explain.runtime_decision,
524 });
525 emit(ThemeTraceEvent {
526 name: "theme.output".to_string(),
527 cache_key: explain.cache_key,
528 route: policy.route.clone(),
529 message: format!("{} style bytes", explain.output.style_bytes),
530 });
531}
532
533pub fn theme_strata_migration_plan(
534 config: &ThemeConfig,
535 policy: &ThemeRoutePolicy,
536) -> ThemeStrataMigrationPlan {
537 ThemeStrataMigrationPlan {
538 package: THEME_PACKAGE_NAME.to_string(),
539 route: policy.route.clone(),
540 steps: vec![
541 "emit ThemeManifestFragment from standalone theme config".to_string(),
542 format!(
543 "register cache key {}",
544 config.cache_key(policy.route.as_deref())
545 ),
546 "attach prepaint CSS and runtime asset through Strata route metadata".to_string(),
547 "preserve fallback and diagnostic context policy".to_string(),
548 ],
549 }
550}
551
552pub fn theme_conformance_fixture() -> serde_json::Value {
553 let config = ThemeConfig::default();
554 let policy = ThemeRoutePolicy::default().route("/theme");
555 serde_json::json!({
556 "manifest": theme_manifest_fragment(&config, &policy),
557 "dictionary": theme_compact_dictionary(),
558 "cssFragments": theme_precomputed_css_fragments(&config),
559 "runtimePath": DEFAULT_THEME_RUNTIME_PATH,
560 })
561}
562
563fn theme_css_fragment(theme: &ThemeDefinition) -> ThemeCssFragment {
564 let css = theme_tokens_css(theme);
565 ThemeCssFragment {
566 id: theme.id.clone(),
567 bytes: css.len(),
568 css,
569 }
570}
571
572fn serialize_one(
573 config: &ThemeConfig,
574 policy: &ThemeRoutePolicy,
575 format: ThemeSerializationFormat,
576) -> serde_json::Result<ThemeBatchPayload> {
577 let json = config.to_preferred_json(format)?;
578 Ok(ThemeBatchPayload {
579 route: policy.route.clone(),
580 cache_key: theme_cache_key(config, policy.route.as_deref(), Some(format.as_attr())),
581 bytes: json.len(),
582 json,
583 })
584}
585
586fn integration_hash_hex<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
587 let mut hash = 0xcbf29ce484222325u64;
588 for part in parts {
589 for byte in part.as_bytes() {
590 hash ^= u64::from(*byte);
591 hash = hash.wrapping_mul(0x100000001b3);
592 }
593 hash ^= 0xff;
594 hash = hash.wrapping_mul(0x100000001b3);
595 }
596 format!("{hash:016x}")
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use crate::{ThemeOutputBudget, ThemePresetProfile, ThemeRoutePolicy};
603
604 #[test]
605 fn batch_serialization_parallel_is_deterministic() {
606 let config = ThemeConfig::default();
607 let a = ThemeRoutePolicy::default().route("/a");
608 let b = ThemeRoutePolicy::default().route("/b");
609 let serial = theme_serialize_batch(
610 [(&config, a.clone()), (&config, b.clone())],
611 ThemeBatchOptions::default(),
612 )
613 .unwrap();
614 let parallel = theme_serialize_batch(
615 [(&config, a), (&config, b)],
616 ThemeBatchOptions {
617 deterministic_parallel: true,
618 ..ThemeBatchOptions::default()
619 },
620 )
621 .unwrap();
622
623 assert_eq!(serial.payloads, parallel.payloads);
624 assert!(parallel.deterministic_parallel);
625 }
626
627 #[test]
628 fn budget_duplicate_ids_cache_and_offload_are_reported() {
629 let config = ThemeConfig::default().route_profile(ThemePresetProfile::Conservative);
630 let policy = ThemeRoutePolicy::default()
631 .route("/theme")
632 .budget(ThemeOutputBudget::new().runtime_bytes(4));
633 let report = config.output_report(&policy);
634 let bridge = theme_asset_budget_bridge(&config, &policy);
635 let ids = ThemeRuntimeIds::default().with_runtime_asset_id(DEFAULT_THEME_CONFIG_ID);
636 let cache = ThemeMemoryCache::default();
637 let key = theme_cache_put_report(&cache, &report);
638 let offload = theme_workertown_offload_plan(&config, &policy);
639
640 assert!(bridge.total_bytes() >= report.config_bytes);
641 assert_eq!(
642 ids.duplicate_ids(),
643 vec![DEFAULT_THEME_CONFIG_ID.to_string()]
644 );
645 assert!(ids.deduped().duplicate_ids().is_empty());
646 assert!(cache.get(&key).is_some());
647 assert!(offload.serializable);
648 assert!(
649 offload
650 .tasks
651 .iter()
652 .any(|task| task == "serialize-token-css")
653 );
654 }
655
656 #[test]
657 fn css_motion_metadata_optimizer_trace_and_migration_are_concrete() {
658 struct ForceStatic;
659
660 impl ThemeMotionPolicyHook for ForceStatic {
661 fn apply(&self, mut policy: ThemeMotionPolicy) -> ThemeMotionPolicy {
662 policy.reduced_motion = "static".to_string();
663 policy
664 }
665 }
666
667 let config = ThemeConfig::default();
668 let policy = ThemeRoutePolicy::default().route("/theme");
669 let manifest = config.manifest_fragment(&policy);
670 let metadata = theme_coalesce_route_metadata("/theme", [manifest]);
671 let css = theme_precomputed_css_fragments(&config);
672 let output = config.output_report(&policy);
673 let artifacts = theme_optimizer_artifacts(&output);
674 let motion = theme_apply_motion_hook(ThemeMotionPolicy::default(), &ForceStatic);
675 let dictionary = theme_compact_dictionary();
676 let migration = theme_strata_migration_plan(&config, &policy);
677 let mut traces = Vec::new();
678 theme_trace_report(&config, &policy, |event| traces.push(event));
679 let fixture = theme_conformance_fixture();
680
681 assert_eq!(metadata.route, "/theme");
682 assert!(css.iter().any(|fragment| fragment.id == "light"));
683 assert!(
684 artifacts
685 .iter()
686 .any(|artifact| artifact.id == "theme.runtime")
687 );
688 assert_eq!(motion.reduced_motion, "static");
689 assert_eq!(dictionary.terms["runtime"], 1);
690 assert!(migration.steps.iter().any(|step| step.contains("Strata")));
691 assert_eq!(traces.len(), 2);
692 assert!(fixture.get("manifest").is_some());
693 }
694}