use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::aliases::{self, AliasOptions};
use crate::error::{Error, Result};
use crate::funnel::{self, BindDecl, DataSource};
use crate::parse::{self, Document, Element, Node};
use crate::render::RenderPlan;
use crate::scope;
#[derive(Debug, Clone)]
pub struct Fragment {
pub path: PathBuf,
pub template: Element,
pub render_plan: RenderPlan,
pub bind: BindDecl,
pub data: HashMap<String, DataSource>,
pub has_locale_data: bool,
}
pub struct FragmentRegistry {
site_root: PathBuf,
fragments: HashMap<String, Fragment>,
data_cache: HashMap<PathBuf, std::sync::Arc<crate::content::DataSet>>,
extra_bind_roots: Vec<String>,
}
impl FragmentRegistry {
#[must_use]
pub fn new(site_root: impl Into<PathBuf>) -> Self {
Self::with_extra_bind_roots(site_root, &[])
}
#[must_use]
pub fn with_extra_bind_roots(
site_root: impl Into<PathBuf>,
extra_bind_roots: &[String],
) -> Self {
Self {
site_root: site_root.into(),
fragments: HashMap::new(),
data_cache: HashMap::new(),
extra_bind_roots: extra_bind_roots.to_vec(),
}
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&Fragment> {
self.fragments.get(id)
}
#[must_use]
pub fn len(&self) -> usize {
self.fragments.len()
}
pub fn data_cache_mut(
&mut self,
) -> &mut HashMap<PathBuf, std::sync::Arc<crate::content::DataSet>> {
&mut self.data_cache
}
pub fn load_links_from_document(
&mut self,
doc: &Document,
base_dir: &Path,
aliases: &AliasOptions,
page: Option<(&str, &str)>,
) -> Result<()> {
for (id, href) in funnel::find_fragment_links(doc) {
self.ensure_loaded(&id, &href, base_dir, aliases, page)?;
}
Ok(())
}
pub fn ensure_loaded(
&mut self,
id: &str,
href: &str,
from_dir: &Path,
aliases: &AliasOptions,
page: Option<(&str, &str)>,
) -> Result<&Fragment> {
if self.fragments.contains_key(id) {
return self.fragments.get(id).ok_or_else(|| {
Error::at_file("<registry>", format!("missing fragment id `{id}`"))
});
}
let href = aliases::resolve_path(href, aliases, page, "href")?;
let path = resolve_local_path(&self.site_root, from_dir, &href, page, &href)?;
let raw =
fs::read_to_string(&path).map_err(|e| Error::read(path.display().to_string(), e))?;
let file = path.display().to_string();
let file_doc = parse::parse_fragment(&raw).map_err(|e| e.in_file(&file, &raw))?;
let base_dir = path.parent().unwrap_or(from_dir);
let data = funnel::load_data_from_document(
&file_doc,
&self.site_root,
base_dir,
&mut self.data_cache,
aliases,
Some((&file, &raw)),
)?;
let nested = funnel::find_fragment_links(&file_doc);
for (nid, nhref) in &nested {
if !self.fragments.contains_key(nid) {
self.ensure_loaded(nid, nhref, base_dir, aliases, Some((&file, &raw)))?;
}
}
let id_dq = format!("id=\"{id}\"");
let id_sq = format!("id='{id}'");
let template_el = funnel::find_template(&file_doc, id).ok_or_else(|| {
Error::at(
&file,
&raw,
&["<template", id_dq.as_str(), id_sq.as_str()],
format!("fragment `{id}` has no matching <template id=\"{id}\">"),
)
})?;
let bind_source = funnel::BindSource {
file: &file,
source: &raw,
};
let bind = match funnel::parse_bind_decl(template_el.attr("data-bind")) {
Ok(decl) => decl,
Err(reason) => {
let prop = template_el.attr("data-bind").unwrap_or("").to_string();
let dq = format!("data-bind=\"{prop}\"");
let sq = format!("data-bind='{prop}'");
return Err(Error::at(
&file,
&raw,
&[&dq, &sq, prop.as_str()],
format!("fragment `{id}` data-bind=`{prop}` is invalid — {reason}"),
));
}
};
let mut bind_roots = funnel::data_link_ids(&file_doc);
bind_roots.extend(self.extra_bind_roots.iter().cloned());
funnel::validate_template_binds_with_roots(
id,
&bind,
&template_el.children,
bind_source,
&bind_roots,
)?;
let hash = short_hash(&raw);
let scope_id = format!("{id}-{hash}");
let has_locale_data = funnel::document_has_locale_data(&file_doc);
let mut template = template_el.clone();
scope::apply_scope_to_nodes(&mut template.children, &scope_id);
scope::rewrite_scripts_in_nodes(&mut template.children, &scope_id);
let frag = Fragment {
path,
render_plan: RenderPlan::compile_fragment(&template.children),
template,
bind,
data,
has_locale_data,
};
self.fragments.insert(id.to_string(), frag);
self.fragments
.get(id)
.ok_or_else(|| Error::at_file("<registry>", format!("missing fragment id `{id}`")))
}
pub fn resolve_fragment_data(
&self,
frag: &Fragment,
locale: Option<&str>,
data_cache: &mut HashMap<PathBuf, std::sync::Arc<crate::content::DataSet>>,
aliases: &AliasOptions,
) -> Result<HashMap<String, DataSource>> {
let mut data = frag.data.clone();
if !frag.has_locale_data {
return Ok(data);
}
let Some(loc) = locale else {
return Ok(data);
};
let raw = fs::read_to_string(&frag.path)
.map_err(|e| Error::read(frag.path.display().to_string(), e))?;
let file = frag.path.display().to_string();
let file_doc = parse::parse_fragment(&raw).map_err(|e| e.in_file(&file, &raw))?;
let base_dir = frag.path.parent().unwrap_or_else(|| Path::new("."));
let locale_data = funnel::load_locale_data_from_document(
&file_doc,
&self.site_root,
base_dir,
data_cache,
aliases,
loc,
Some((&file, &raw)),
)?;
for (id, source) in locale_data {
data.insert(id, source);
}
Ok(data)
}
}
impl Default for FragmentRegistry {
fn default() -> Self {
Self::new(".")
}
}
fn short_hash(s: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(s.as_bytes());
hex::encode(&hasher.finalize()[..4])
}
fn resolve_local_path(
site_root: &Path,
page_dir: &Path,
rel: &str,
page: Option<(&str, &str)>,
href: &str,
) -> Result<PathBuf> {
let joined = aliases::resolve_local_href(site_root, page_dir, rel);
if let Ok(canon) = joined.canonicalize() {
return Ok(canon);
}
if joined.exists() {
return Ok(joined);
}
let path = joined.display().to_string();
if let Some((file, source)) = page {
let dq = format!("href=\"{href}\"");
let sq = format!("href='{href}'");
return Err(Error::at(
file,
source,
&[&dq, &sq, href],
format!("path not found: {path}"),
));
}
Err(Error::at_file(
path.clone(),
format!("path not found: {path}"),
))
}
#[must_use]
pub fn template_children(frag: &Fragment) -> Vec<Node> {
frag.template.children.clone()
}