use super::graph::AssetGraphPlan;
use super::json::{push_json_string, push_u64};
use super::payload::{render_style_resource, RenderedComponent, RenderedStyleResource};
use super::ComponentAssetFile;
use crate::WebUIError;
use webui_protocol::WebUIProtocol;
const ASSET_TYPE: &str = "webui-component-asset";
const ASSET_VERSION: u64 = 4;
const COMPONENT_ASSET_EXT: &str = "webui.js";
pub(super) struct RenderedOutput {
pub name: String,
pub bytes: usize,
pub root: Option<String>,
pub components: Vec<(String, usize)>,
pub required_components: Vec<String>,
pub external_components: Vec<String>,
pub imports: Vec<String>,
}
pub(super) struct PendingAsset {
pub logical_name: String,
pub root: Option<String>,
pub components: Vec<usize>,
pub required_components: Vec<usize>,
pub external_components: Vec<usize>,
pub imports: Vec<ResolvedImport>,
}
pub(super) struct ResolvedImport {
pub file_name: String,
}
pub(super) struct RenderedAsset {
pub file: ComponentAssetFile,
pub output: Option<RenderedOutput>,
}
pub(super) struct AssetRenderOptions<'a> {
pub emit_metafile: bool,
pub protocol: &'a WebUIProtocol,
}
struct StyleResourceWriter<'a, 'data> {
out: &'a mut String,
plan: &'a AssetGraphPlan<'data>,
payloads: &'a [Option<RenderedComponent<'data>>],
protocol: &'data WebUIProtocol,
attribution: &'a mut [usize],
}
pub(super) fn render_asset(
pending: &PendingAsset,
plan: &AssetGraphPlan,
payloads: &[Option<RenderedComponent<'_>>],
options: &AssetRenderOptions<'_>,
) -> Result<RenderedAsset, WebUIError> {
let mut js = String::with_capacity(estimate_asset_size(pending, plan, payloads));
let mut attribution = if options.emit_metafile {
vec![0usize; pending.components.len()]
} else {
Vec::new()
};
push_static_imports(&mut js, &pending.imports)?;
js.push_str("const asset={");
if let Some(root) = &pending.root {
js.push_str("\"type\":\"");
js.push_str(ASSET_TYPE);
js.push_str("\",\"version\":");
push_u64(&mut js, ASSET_VERSION);
js.push_str(",\"root\":");
push_json_string(&mut js, root, "component asset root")?;
js.push_str(",\"externalComponents\":[");
push_component_id_array(&mut js, &pending.external_components, plan)?;
js.push_str("],\"imports\":[");
push_imports(&mut js, &pending.imports)?;
js.push_str("],");
}
js.push_str("\"componentStyles\":{\"version\":1,\"strategy\":\"");
js.push_str(options.protocol.css_strategy().wire_name());
js.push_str("\",\"resources\":{");
{
let mut writer = StyleResourceWriter {
out: &mut js,
plan,
payloads,
protocol: options.protocol,
attribution: &mut attribution,
};
let written_resources = push_style_resources(&mut writer, &pending.components, 0, true)?;
push_style_resources(
&mut writer,
&pending.external_components,
written_resources,
false,
)?;
}
js.push_str("},\"closures\":{");
push_style_closures(&mut js, pending, plan, options.protocol)?;
js.push_str("}}");
if pending.root.is_none() {
js.push_str(",\"templates\":{");
push_templates(
&mut js,
&pending.components,
plan,
payloads,
&mut attribution,
)?;
js.push('}');
}
if pending.components.iter().any(|id| {
payloads
.get(*id)
.and_then(Option::as_ref)
.is_some_and(|payload| payload.functions.is_some())
}) {
js.push_str(",\"templateFunctions\":{");
push_functions(
&mut js,
&pending.components,
plan,
payloads,
&mut attribution,
)?;
js.push('}');
}
js.push_str("};\n");
js.push_str("export default asset;\n");
let mut name =
String::with_capacity(pending.logical_name.len() + COMPONENT_ASSET_EXT.len() + 1);
name.push_str(&pending.logical_name);
name.push('.');
name.push_str(COMPONENT_ASSET_EXT);
let output = options.emit_metafile.then(|| RenderedOutput {
bytes: js.len(),
root: pending.root.clone(),
components: pending
.components
.iter()
.enumerate()
.map(|(index, id)| (plan.component_names[*id].to_string(), attribution[index]))
.collect(),
required_components: component_names(&pending.required_components, plan),
external_components: component_names(&pending.external_components, plan),
imports: pending
.imports
.iter()
.map(|import| import.file_name.clone())
.collect(),
name: name.clone(),
});
Ok(RenderedAsset {
file: ComponentAssetFile { name, content: js },
output,
})
}
fn estimate_asset_size(
pending: &PendingAsset,
plan: &AssetGraphPlan,
payloads: &[Option<RenderedComponent<'_>>],
) -> usize {
let mut size = 256 + pending.logical_name.len();
for component in &pending.components {
size += plan.component_names[*component].len() * 3 + 16;
if let Some(payload) = payloads.get(*component).and_then(Option::as_ref) {
size += payload.template.len();
size += payload.resource.as_ref().map_or(0, |resource| {
let bytes = match resource {
RenderedStyleResource::Link(value)
| RenderedStyleResource::Style(value)
| RenderedStyleResource::Module(value) => value.len(),
};
bytes + 64
});
size += payload.functions.map_or(0, str::len);
}
}
for import in &pending.imports {
size += import.file_name.len() + 32;
}
size
}
fn push_component_id_array(
out: &mut String,
components: &[usize],
plan: &AssetGraphPlan,
) -> Result<(), WebUIError> {
for (index, component) in components.iter().copied().enumerate() {
if index > 0 {
out.push(',');
}
push_json_string(out, plan.component_names[component], "component tag")?;
}
Ok(())
}
fn push_static_imports(out: &mut String, imports: &[ResolvedImport]) -> Result<(), WebUIError> {
for (index, import) in imports.iter().enumerate() {
out.push_str("import __webuiChunk");
push_import_index(out, index)?;
out.push_str(" from ");
let mut relative = String::with_capacity(import.file_name.len() + 2);
relative.push_str("./");
relative.push_str(&import.file_name);
push_json_string(out, &relative, "component asset import")?;
out.push_str(";\n");
}
Ok(())
}
fn push_imports(out: &mut String, imports: &[ResolvedImport]) -> Result<(), WebUIError> {
for index in 0..imports.len() {
if index > 0 {
out.push(',');
}
out.push_str("__webuiChunk");
push_import_index(out, index)?;
}
Ok(())
}
fn push_import_index(out: &mut String, index: usize) -> Result<(), WebUIError> {
let index = u64::try_from(index).map_err(|_| {
WebUIError::InvalidBuildOptions(
"component asset import count exceeds the supported index range".to_string(),
)
})?;
push_u64(out, index);
Ok(())
}
fn push_style_resources(
writer: &mut StyleResourceWriter<'_, '_>,
components: &[usize],
mut written: usize,
attribute_bytes: bool,
) -> Result<usize, WebUIError> {
for (index, component) in components.iter().copied().enumerate() {
let resource = match writer.payloads.get(component).and_then(Option::as_ref) {
Some(payload) => payload.resource,
None => render_style_resource(writer.protocol, writer.plan.component_names[component])?,
};
let Some(resource) = resource else {
continue;
};
let start = writer.out.len();
if written > 0 {
writer.out.push(',');
}
let tag = writer.plan.component_names[component];
push_json_string(writer.out, tag, "component style resource ID")?;
match &resource {
RenderedStyleResource::Link(href) => {
writer.out.push_str(":{\"kind\":\"link\",\"href\":");
push_json_string(writer.out, href, "component style href")?;
writer.out.push('}');
}
RenderedStyleResource::Style(css) => {
writer.out.push_str(":{\"kind\":\"style\",\"css\":");
push_json_string(writer.out, css, "component style CSS")?;
writer.out.push('}');
}
RenderedStyleResource::Module(css) => {
writer.out.push_str(":{\"kind\":\"module\",\"specifier\":");
push_json_string(writer.out, tag, "component style module specifier")?;
writer.out.push_str(",\"css\":");
push_json_string(writer.out, css, "component style module CSS")?;
writer.out.push('}');
}
}
if attribute_bytes {
add_attribution(writer.attribution, index, writer.out.len() - start);
}
written += 1;
}
Ok(written)
}
fn push_style_closures(
out: &mut String,
pending: &PendingAsset,
plan: &AssetGraphPlan,
protocol: &WebUIProtocol,
) -> Result<(), WebUIError> {
for (index, component) in pending.components.iter().copied().enumerate() {
if index > 0 {
out.push(',');
}
let tag = plan.component_names[component];
push_json_string(out, tag, "component style closure root")?;
out.push_str(":[");
let closure = protocol.style_closures.get(tag).ok_or_else(|| {
WebUIError::InvalidBuildOptions(format!(
"component asset graph requires missing style closure metadata for <{tag}>"
))
})?;
for (closure_index, resource) in closure.component_tags.iter().enumerate() {
if closure_index > 0 {
out.push(',');
}
push_json_string(out, resource, "component style closure resource")?;
}
out.push(']');
}
Ok(())
}
fn push_templates(
out: &mut String,
components: &[usize],
plan: &AssetGraphPlan,
payloads: &[Option<RenderedComponent<'_>>],
attribution: &mut [usize],
) -> Result<(), WebUIError> {
for (index, component) in components.iter().copied().enumerate() {
let payload = payload(payloads, component)?;
let start = out.len();
if index > 0 {
out.push(',');
}
push_json_string(out, plan.component_names[component], "component tag")?;
out.push(':');
out.push_str(&payload.template);
add_attribution(attribution, index, out.len() - start);
}
Ok(())
}
fn push_functions(
out: &mut String,
components: &[usize],
plan: &AssetGraphPlan,
payloads: &[Option<RenderedComponent<'_>>],
attribution: &mut [usize],
) -> Result<(), WebUIError> {
let mut written = 0usize;
for (index, component) in components.iter().copied().enumerate() {
let payload = payload(payloads, component)?;
let Some(functions) = &payload.functions else {
continue;
};
let start = out.len();
if written > 0 {
out.push(',');
}
push_json_string(out, plan.component_names[component], "component tag")?;
out.push(':');
out.push_str(functions);
add_attribution(attribution, index, out.len() - start);
written += 1;
}
Ok(())
}
fn payload<'p, 'd>(
payloads: &'p [Option<RenderedComponent<'d>>],
component: usize,
) -> Result<&'p RenderedComponent<'d>, WebUIError> {
payloads
.get(component)
.and_then(Option::as_ref)
.ok_or_else(|| {
WebUIError::InvalidBuildOptions(
"component asset graph references a missing rendered payload".to_string(),
)
})
}
#[inline]
fn add_attribution(attribution: &mut [usize], index: usize, bytes: usize) {
if let Some(value) = attribution.get_mut(index) {
*value += bytes;
}
}
fn component_names(components: &[usize], plan: &AssetGraphPlan) -> Vec<String> {
components
.iter()
.map(|component| plan.component_names[*component].to_string())
.collect()
}