use std::{
borrow::Cow,
collections::BTreeMap,
sync::{Arc, Mutex},
thread,
};
use serde::{Deserialize, Serialize};
use crate::{
DEFAULT_TEXTFX_RUNTIME_PATH, TEXTFX_PACKAGE_NAME, TEXTFX_PACKAGE_VERSION, TextFxConfig,
TextFxDiagnosticVerbosity, TextFxManifestFragment, TextFxOutputReport, TextFxRenderPreference,
TextFxRoutePolicy, TextFxSerializationFormat, explain_textfx, textfx_cache_key,
textfx_manifest_fragment,
};
pub const DEFAULT_TEXTFX_PREPAINT_STYLE_ID: &str = "__DXT_TEXTFX_PREPAINT__";
pub const DEFAULT_TEXTFX_RUNTIME_ASSET_ID: &str = "textfx.runtime";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxDiagnosticContext {
Build,
Ssr,
Runtime,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxDiagnosticVerbosityByContext {
pub build: TextFxDiagnosticVerbosity,
pub ssr: TextFxDiagnosticVerbosity,
pub runtime: TextFxDiagnosticVerbosity,
}
impl Default for TextFxDiagnosticVerbosityByContext {
fn default() -> Self {
Self {
build: TextFxDiagnosticVerbosity::Detailed,
ssr: TextFxDiagnosticVerbosity::Summary,
runtime: TextFxDiagnosticVerbosity::Off,
}
}
}
impl TextFxDiagnosticVerbosityByContext {
pub fn for_context(&self, context: TextFxDiagnosticContext) -> TextFxDiagnosticVerbosity {
match context {
TextFxDiagnosticContext::Build => self.build,
TextFxDiagnosticContext::Ssr => self.ssr,
TextFxDiagnosticContext::Runtime => self.runtime,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxRuntimeIds {
pub prepaint_style_id: String,
pub runtime_asset_id: String,
}
impl Default for TextFxRuntimeIds {
fn default() -> Self {
Self {
prepaint_style_id: DEFAULT_TEXTFX_PREPAINT_STYLE_ID.to_string(),
runtime_asset_id: DEFAULT_TEXTFX_RUNTIME_ASSET_ID.to_string(),
}
}
}
impl TextFxRuntimeIds {
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> {
if self.prepaint_style_id == self.runtime_asset_id {
vec![self.prepaint_style_id.clone()]
} else {
Vec::new()
}
}
pub fn deduped(mut self) -> Self {
if self.prepaint_style_id == self.runtime_asset_id {
self.runtime_asset_id.push_str("-2");
}
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxAssetPaths {
pub runtime_base_path: String,
pub runtime_asset_name: String,
}
impl Default for TextFxAssetPaths {
fn default() -> Self {
Self {
runtime_base_path: "/assets".to_string(),
runtime_asset_name: "dioxus-textfx.js".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextFxAssetBudgetCategory {
RuntimeScript,
PrepaintStyle,
ConfigJson,
StaticHtml,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxAssetBudgetEntry {
pub category: TextFxAssetBudgetCategory,
pub bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxAssetBudgetBridge {
pub package: String,
pub entries: Vec<TextFxAssetBudgetEntry>,
}
impl TextFxAssetBudgetBridge {
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 TextFxBatchOptions {
pub format: TextFxSerializationFormat,
pub deterministic_parallel: bool,
pub sort_by_cache_key: bool,
}
impl Default for TextFxBatchOptions {
fn default() -> Self {
Self {
format: TextFxSerializationFormat::CompactWhenSmaller,
deterministic_parallel: false,
sort_by_cache_key: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxBatchPayload {
pub id: String,
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 TextFxBatchSerializationReport {
pub package: String,
pub deterministic_parallel: bool,
pub payloads: Vec<TextFxBatchPayload>,
pub total_bytes: usize,
}
#[derive(Debug)]
pub struct TextFxBorrowedView<'a> {
pub config: &'a TextFxConfig,
pub policy: Cow<'a, TextFxRoutePolicy>,
}
impl<'a> TextFxBorrowedView<'a> {
pub fn new(config: &'a TextFxConfig, policy: &'a TextFxRoutePolicy) -> Self {
Self {
config,
policy: Cow::Borrowed(policy),
}
}
pub fn with_owned_policy(config: &'a TextFxConfig, policy: TextFxRoutePolicy) -> Self {
Self {
config,
policy: Cow::Owned(policy),
}
}
pub fn manifest_fragment(&self) -> TextFxManifestFragment {
textfx_manifest_fragment([self.config], self.policy.as_ref())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxCssFragment {
pub id: String,
pub css: String,
pub bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxMotionPolicy {
pub reduced_motion: String,
pub view_transition: String,
pub render_lane: String,
}
impl Default for TextFxMotionPolicy {
fn default() -> Self {
Self {
reduced_motion: "fade-only".to_string(),
view_transition: "optional".to_string(),
render_lane: "textfx".to_string(),
}
}
}
pub trait TextFxMotionPolicyHook {
fn apply(&self, policy: TextFxMotionPolicy) -> TextFxMotionPolicy;
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxRouteMetadata {
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 TextFxOptimizerArtifact {
pub id: String,
pub content_hash: String,
pub bytes: usize,
pub minified: bool,
}
pub trait TextFxOptimizerReuseHook {
fn reuse_artifact(&self, artifact: &TextFxOptimizerArtifact) -> Option<String>;
}
pub trait TextFxCacheBackend {
fn get(&self, key: &str) -> Option<String>;
fn put(&self, key: String, value: String);
}
#[derive(Clone, Default)]
pub struct TextFxMemoryCache {
values: Arc<Mutex<BTreeMap<String, String>>>,
}
impl TextFxCacheBackend for TextFxMemoryCache {
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 TextFxOffloadPlan {
pub package: String,
pub lane: String,
pub serializable: bool,
pub renderer: TextFxRenderPreference,
pub payload_cache_key: String,
pub tasks: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxCompactDictionary {
pub version: u8,
pub terms: BTreeMap<String, u16>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFxTraceEvent {
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 TextFxStrataMigrationPlan {
pub package: String,
pub route: Option<String>,
pub steps: Vec<String>,
}
pub fn textfx_serialize_batch<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: TextFxRoutePolicy,
options: TextFxBatchOptions,
) -> serde_json::Result<TextFxBatchSerializationReport> {
let configs = configs.into_iter().collect::<Vec<_>>();
let mut payloads = if options.deterministic_parallel && configs.len() > 1 {
thread::scope(|scope| {
let mut handles = Vec::new();
for config in configs {
let policy = policy.clone();
handles.push(scope.spawn(move || serialize_one(config, &policy, options.format)));
}
handles
.into_iter()
.map(|handle| handle.join().expect("TextFX batch worker panicked"))
.collect::<serde_json::Result<Vec<_>>>()
})?
} else {
configs
.into_iter()
.map(|config| 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(TextFxBatchSerializationReport {
package: TEXTFX_PACKAGE_NAME.to_string(),
deterministic_parallel: options.deterministic_parallel,
payloads,
total_bytes,
})
}
pub fn textfx_asset_budget_bridge<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: &TextFxRoutePolicy,
) -> TextFxAssetBudgetBridge {
let output = crate::textfx_output_report(configs, policy);
TextFxAssetBudgetBridge {
package: TEXTFX_PACKAGE_NAME.to_string(),
entries: vec![
TextFxAssetBudgetEntry {
category: TextFxAssetBudgetCategory::ConfigJson,
bytes: output.config_bytes,
},
TextFxAssetBudgetEntry {
category: TextFxAssetBudgetCategory::RuntimeScript,
bytes: output.runtime_bytes,
},
TextFxAssetBudgetEntry {
category: TextFxAssetBudgetCategory::StaticHtml,
bytes: output.static_html_bytes,
},
],
}
}
pub fn textfx_precomputed_css_fragments() -> Vec<TextFxCssFragment> {
crate::TextFxEffect::ALL
.into_iter()
.map(|effect| {
let css = format!(
".dxt-effect-{}{{--dxt-effect:\"{}\";}}",
effect.as_attr(),
effect.as_attr()
);
TextFxCssFragment {
id: effect.as_attr().to_string(),
bytes: css.len(),
css,
}
})
.collect()
}
pub fn textfx_apply_motion_hook<H: TextFxMotionPolicyHook>(
policy: TextFxMotionPolicy,
hook: &H,
) -> TextFxMotionPolicy {
hook.apply(policy)
}
pub fn textfx_coalesce_route_metadata(
route: impl Into<String>,
fragments: impl IntoIterator<Item = TextFxManifestFragment>,
) -> TextFxRouteMetadata {
let mut metadata = TextFxRouteMetadata {
route: route.into(),
..TextFxRouteMetadata::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 textfx_optimizer_artifacts(report: &TextFxOutputReport) -> Vec<TextFxOptimizerArtifact> {
[
("textfx.config", report.config_bytes, true),
("textfx.runtime", report.runtime_bytes, true),
("textfx.html", report.static_html_bytes, false),
]
.into_iter()
.map(|(id, bytes, minified)| TextFxOptimizerArtifact {
id: id.to_string(),
content_hash: integration_hash_hex([id, &report.cache_key, &bytes.to_string()]),
bytes,
minified,
})
.collect()
}
pub fn textfx_cache_put_report<B: TextFxCacheBackend>(
cache: &B,
report: &TextFxOutputReport,
) -> String {
let key = format!(
"{}:{}:{}",
TEXTFX_PACKAGE_NAME, TEXTFX_PACKAGE_VERSION, report.cache_key
);
cache.put(
key.clone(),
serde_json::to_string(report).unwrap_or_default(),
);
key
}
pub fn textfx_workertown_offload_plan<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: &TextFxRoutePolicy,
) -> TextFxOffloadPlan {
let configs = configs.into_iter().collect::<Vec<_>>();
TextFxOffloadPlan {
package: TEXTFX_PACKAGE_NAME.to_string(),
lane: "textfx-render".to_string(),
serializable: true,
renderer: TextFxRenderPreference::WorkerTownRender,
payload_cache_key: textfx_cache_key(
configs.iter().copied(),
policy.route.as_deref(),
Some("workertown"),
),
tasks: vec![
"split-text".to_string(),
"prepare-timeline".to_string(),
"reserve-layout".to_string(),
],
}
}
pub fn textfx_compact_dictionary() -> TextFxCompactDictionary {
let mut terms = BTreeMap::new();
for (index, term) in [
"config",
"runtime",
"style",
"route",
"profile",
"trigger",
"split",
"workertown",
]
.into_iter()
.enumerate()
{
terms.insert(term.to_string(), index as u16);
}
TextFxCompactDictionary { version: 1, terms }
}
pub fn textfx_trace_report<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: &TextFxRoutePolicy,
mut emit: impl FnMut(TextFxTraceEvent),
) {
let configs = configs.into_iter().collect::<Vec<_>>();
let explain = explain_textfx(configs.iter().copied(), policy);
emit(TextFxTraceEvent {
name: "textfx.policy".to_string(),
cache_key: explain.cache_key.clone(),
route: policy.route.clone(),
message: explain.runtime_decision,
});
emit(TextFxTraceEvent {
name: "textfx.output".to_string(),
cache_key: explain.cache_key,
route: policy.route.clone(),
message: format!("{} config bytes", explain.output.config_bytes),
});
}
pub fn textfx_strata_migration_plan<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: &TextFxRoutePolicy,
) -> TextFxStrataMigrationPlan {
let configs = configs.into_iter().collect::<Vec<_>>();
TextFxStrataMigrationPlan {
package: TEXTFX_PACKAGE_NAME.to_string(),
route: policy.route.clone(),
steps: vec![
"collect TextFxConfig values into a route batch".to_string(),
format!(
"register cache key {}",
textfx_cache_key(configs.iter().copied(), policy.route.as_deref(), None)
),
"attach TextFxManifestFragment to Strata route metadata".to_string(),
"preserve CSS-first and WorkerTown render policy decisions".to_string(),
],
}
}
pub fn textfx_conformance_fixture() -> serde_json::Value {
let config = TextFxConfig::new("headline", "Launch ready").scramble();
let policy = TextFxRoutePolicy::default().route("/textfx");
serde_json::json!({
"manifest": textfx_manifest_fragment([&config], &policy),
"dictionary": textfx_compact_dictionary(),
"cssFragments": textfx_precomputed_css_fragments(),
"runtimePath": DEFAULT_TEXTFX_RUNTIME_PATH,
})
}
fn serialize_one(
config: &TextFxConfig,
policy: &TextFxRoutePolicy,
format: TextFxSerializationFormat,
) -> serde_json::Result<TextFxBatchPayload> {
let json = match format {
TextFxSerializationFormat::ReadableJson | TextFxSerializationFormat::StableJson => {
config.to_json()?
}
TextFxSerializationFormat::CompactWhenSmaller => {
let full = config.to_json()?;
let compact = config.to_compact_json()?;
if compact.len() < full.len() {
compact
} else {
full
}
}
};
Ok(TextFxBatchPayload {
id: config.id.clone(),
route: policy.route.clone(),
cache_key: textfx_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::{TextFxOutputBudget, TextFxPresetProfile, TextFxRoutePolicy};
#[test]
fn batch_serialization_parallel_is_deterministic() {
let a = TextFxConfig::new("a", "Alpha").scramble();
let b = TextFxConfig::new("b", "Beta").typewriter();
let policy = TextFxRoutePolicy::default().route("/textfx");
let serial =
textfx_serialize_batch([&a, &b], policy.clone(), TextFxBatchOptions::default())
.unwrap();
let parallel = textfx_serialize_batch(
[&a, &b],
policy,
TextFxBatchOptions {
deterministic_parallel: true,
..TextFxBatchOptions::default()
},
)
.unwrap();
assert_eq!(serial.payloads, parallel.payloads);
assert!(parallel.deterministic_parallel);
}
#[test]
fn budget_duplicate_ids_cache_and_offload_are_reported() {
let config = TextFxConfig::new("headline", "Launch ready")
.route_profile(TextFxPresetProfile::Expressive);
let policy = TextFxRoutePolicy::default()
.route("/textfx")
.budget(TextFxOutputBudget::new().runtime_bytes(4));
let report = crate::textfx_output_report([&config], &policy);
let bridge = textfx_asset_budget_bridge([&config], &policy);
let ids =
TextFxRuntimeIds::default().with_runtime_asset_id(DEFAULT_TEXTFX_PREPAINT_STYLE_ID);
let cache = TextFxMemoryCache::default();
let key = textfx_cache_put_report(&cache, &report);
let offload = textfx_workertown_offload_plan([&config], &policy);
assert!(bridge.total_bytes() >= report.config_bytes);
assert_eq!(
ids.duplicate_ids(),
vec![DEFAULT_TEXTFX_PREPAINT_STYLE_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 == "split-text"));
}
#[test]
fn css_motion_metadata_optimizer_trace_and_migration_are_concrete() {
struct ForceStatic;
impl TextFxMotionPolicyHook for ForceStatic {
fn apply(&self, mut policy: TextFxMotionPolicy) -> TextFxMotionPolicy {
policy.reduced_motion = "static".to_string();
policy
}
}
let config = TextFxConfig::new("headline", "Launch ready").scramble();
let policy = TextFxRoutePolicy::default().route("/textfx");
let manifest = textfx_manifest_fragment([&config], &policy);
let metadata = textfx_coalesce_route_metadata("/textfx", [manifest]);
let css = textfx_precomputed_css_fragments();
let output = crate::textfx_output_report([&config], &policy);
let artifacts = textfx_optimizer_artifacts(&output);
let motion = textfx_apply_motion_hook(TextFxMotionPolicy::default(), &ForceStatic);
let dictionary = textfx_compact_dictionary();
let migration = textfx_strata_migration_plan([&config], &policy);
let mut traces = Vec::new();
textfx_trace_report([&config], &policy, |event| traces.push(event));
let fixture = textfx_conformance_fixture();
assert_eq!(metadata.route, "/textfx");
assert!(css.iter().any(|fragment| fragment.id == "scramble"));
assert!(
artifacts
.iter()
.any(|artifact| artifact.id == "textfx.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());
}
}