From 8500bf95cb202d62b19facf920c1b91c555b1f5f Mon Sep 17 00:00:00 2001 From: Jesse Ditson Date: Wed, 9 Sep 2026 10:27:53 -0700 Subject: [PATCH] Add ContextValue wrapper migrate displayed values to ContextWrapper, which allows for custom display rules when parsing values - this enables File types to render as URLs instead of expanded fields. Requires an upstream change in liquid-core --- Cargo.lock | 26 +++- Cargo.toml | 3 + src/binary/carriers/objects.rs | 18 +-- src/binary/command/objects.rs | 24 +-- src/fields/field_value.rs | 13 +- src/fields/file.rs | 52 ++++++- src/object/context_value.rs | 274 +++++++++++++++++++++++++++++++++ src/object/mod.rs | 17 +- src/object/to_liquid.rs | 62 +++++--- src/page.rs | 116 +++++++------- src/site.rs | 9 +- src/tags/output.rs | 117 ++++++++++++++ 12 files changed, 601 insertions(+), 130 deletions(-) create mode 100644 src/object/context_value.rs diff --git a/Cargo.lock b/Cargo.lock index b78b3ef..1b9d4aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1469,6 +1469,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1581,7 +1590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a494c3f9dad3cb7ed16f1c51812cbe4b29493d6c2e5cd1e2b87477263d9534d" dependencies = [ "liquid-core", - "liquid-derive", + "liquid-derive 0.26.10 (registry+https://github.com/rust-lang/crates.io-index)", "liquid-lib", "serde", ] @@ -1589,13 +1598,11 @@ dependencies = [ [[package]] name = "liquid-core" version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc623edee8a618b4543e8e8505584f4847a4e51b805db1af6d9af0a3395d0d57" dependencies = [ "anymap2", - "itertools 0.14.0", + "itertools 0.15.0", "kstring", - "liquid-derive", + "liquid-derive 0.26.10", "pest", "pest_derive", "regex", @@ -1603,6 +1610,15 @@ dependencies = [ "time", ] +[[package]] +name = "liquid-derive" +version = "0.26.10" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "liquid-derive" version = "0.26.10" diff --git a/Cargo.toml b/Cargo.toml index 89e532f..710af63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,3 +152,6 @@ assertables = "9.8.6" [build-dependencies] prost-build = { version = "0.14.1", optional = true } + +[patch.crates-io] +liquid-core = { path = "/Users/jesseditson/code/liquid-rust/crates/core" } diff --git a/src/binary/carriers/objects.rs b/src/binary/carriers/objects.rs index 18c979e..9fc55e4 100644 --- a/src/binary/carriers/objects.rs +++ b/src/binary/carriers/objects.rs @@ -52,15 +52,15 @@ pub(crate) fn build_payload( .object_definitions .get(&name) .unwrap_or_else(|| panic!("missing object definition {}", name)); - let value = - match &entry { - ObjectEntry::List(objects) => Value::array(objects.iter().map(|o| { - o.liquid_object_with(definition, &site.field_config, CARRIER_OPTIONS) - })), - ObjectEntry::Object(o) => { - o.liquid_object_with(definition, &site.field_config, CARRIER_OPTIONS) - } - }; + let value = match &entry { + ObjectEntry::List(objects) => Value::array(objects.iter().map(|o| { + o.liquid_object_with(definition, &site.field_config, CARRIER_OPTIONS) + .to_value() + })), + ObjectEntry::Object(o) => o + .liquid_object_with(definition, &site.field_config, CARRIER_OPTIONS) + .to_value(), + }; for object in entry.into_iter() { collect_uploads(object, &mut uploads); } diff --git a/src/binary/command/objects.rs b/src/binary/command/objects.rs index 253befd..d91c226 100644 --- a/src/binary/command/objects.rs +++ b/src/binary/command/objects.rs @@ -5,14 +5,15 @@ use crate::{ ExitStatus, }, file_system_stdlib, - object::ObjectEntry, + object::{ + context_value::{ContextObject, ContextValue}, + ObjectEntry, + }, page::debug_context, site::Site, }; use anyhow::Result; use clap::ArgMatches; -use liquid_core::Value; -use ordermap::OrderMap; use std::sync::{atomic::AtomicBool, Arc}; pub struct Command {} @@ -34,25 +35,24 @@ impl BinaryCommand for Command { let root_dir = command_root(args); let fs = file_system_stdlib::NativeFileSystem::new(&root_dir); let site = Site::load(&fs, Some(""))?; - let mut objects: OrderMap = OrderMap::new(); + let mut objects = ContextObject::new(); let definitions = &site.object_definitions; for (name, obj_entry) in site.get_objects(&fs)? { let definition = definitions .get(&name) .unwrap_or_else(|| panic!("missing object definition {}", name)); let values = match obj_entry { - ObjectEntry::List(l) => Value::array( + ObjectEntry::List(l) => ContextValue::array( l.iter() - .map(|o| o.liquid_object(definition, &site.field_config)), + .map(|o| o.liquid_object(definition, &site.field_config).into()), ), - ObjectEntry::Object(o) => o.liquid_object(definition, &site.field_config), + ObjectEntry::Object(o) => o.liquid_object(definition, &site.field_config).into(), }; - objects.insert(name.to_string(), values); + objects.insert(liquid::model::KString::from_string(name.clone()), values); } - println!( - "{}", - debug_context(&liquid::object!({"objects": objects}), 0) - ); + let mut context = ContextObject::new(); + context.insert("objects".into(), ContextValue::Object(objects)); + println!("{}", debug_context(&context, 0)); // let page = Page::new( // "objects-template", // "", diff --git a/src/fields/field_value.rs b/src/fields/field_value.rs index ac437a2..6228e5c 100644 --- a/src/fields/field_value.rs +++ b/src/fields/field_value.rs @@ -9,7 +9,7 @@ use crate::object::to_liquid::{object_to_liquid_with, ToLiquidOptions}; use crate::object::Renderable; use crate::util::integer_decode; use crate::value_path::ValuePathError; -use crate::{FieldConfig, ObjectDefinition, ValuePath}; +use crate::{object::context_value::ContextValue, FieldConfig, ObjectDefinition, ValuePath}; use anyhow::Result; use comrak::{markdown_to_html, ComrakOptions}; use liquid::{model, ValueView}; @@ -275,7 +275,7 @@ impl FieldValue { &self, definition: &ObjectDefinition, field_config: &FieldConfig, - ) -> model::Value { + ) -> ContextValue { self.typed_objects_with(definition, field_config, ToLiquidOptions::default()) } @@ -284,13 +284,13 @@ impl FieldValue { definition: &ObjectDefinition, field_config: &FieldConfig, options: ToLiquidOptions, - ) -> model::Value { + ) -> ContextValue { if let FieldValue::Objects(children) = self { - model::Value::Array( + ContextValue::Array( children .iter() .map(|child| { - model::Value::Object(object_to_liquid_with( + ContextValue::Object(object_to_liquid_with( child, definition, field_config, @@ -1274,7 +1274,8 @@ pub mod file_tests { .parse("{% if file %}BLANK{% else %}OH NO!!{% endif %}") .unwrap(); let field_config = FieldConfig::default(); - let ctx = liquid::object!({ "file": file.to_liquid(&field_config) }); + let mut ctx = crate::object::context_value::ContextObject::new(); + ctx.insert("file".into(), file.to_liquid(&field_config)); assert_eq!(template.render(&ctx).unwrap(), "BLANK"); } } diff --git a/src/fields/file.rs b/src/fields/file.rs index d8f303c..dd1557a 100644 --- a/src/fields/file.rs +++ b/src/fields/file.rs @@ -91,9 +91,7 @@ impl ValueView for DisplayType { } } -#[derive( - Debug, ObjectView, ValueView, Deserialize, Serialize, Clone, PartialEq, PartialOrd, Hash, -)] +#[derive(Debug, ObjectView, Deserialize, Serialize, Clone, PartialEq, PartialOrd, Hash)] #[cfg_attr(feature = "typescript", derive(typescript_type_def::TypeDef))] pub struct RenderedFile { pub display_type: DisplayType, @@ -104,6 +102,54 @@ pub struct RenderedFile { pub description: Option, pub url: String, } +/// A file is its url everywhere a template asks for one value - `{{ photo }}`, +/// a string filter, a comparison - while `{{ photo.url }}` and every other key +/// keep working through `as_object`. Rendering it as an object instead spells +/// each key and value into the page run together, which builds, so nothing but +/// the broken page reports it. +/// +/// `to_value` stays an object: it is what a value flattens to when liquid has +/// to own it (`{% assign %}`, json for carriers), where dropping the keys would +/// be the worse trade. +impl ValueView for RenderedFile { + fn as_debug(&self) -> &dyn std::fmt::Debug { + self + } + fn render(&self) -> model::DisplayCow<'_> { + model::DisplayCow::Borrowed(&self.url) + } + fn source(&self) -> model::DisplayCow<'_> { + model::DisplayCow::Borrowed(&self.url) + } + fn type_name(&self) -> &'static str { + "object" + } + fn query_state(&self, state: model::State) -> bool { + match state { + model::State::Truthy => true, + model::State::DefaultValue | model::State::Empty | model::State::Blank => { + self.url.is_empty() + } + } + } + fn to_kstr(&self) -> model::KStringCow<'_> { + model::KStringCow::from_ref(&self.url) + } + fn as_scalar(&self) -> Option> { + Some(model::ScalarCow::new(self.url.as_str())) + } + fn to_value(&self) -> liquid_core::Value { + liquid_core::Value::Object( + ObjectView::iter(self) + .map(|(k, v)| (model::KString::from_string(k.into_string()), v.to_value())) + .collect(), + ) + } + fn as_object(&self) -> Option<&dyn ObjectView> { + Some(self) + } +} + impl RenderedFile { pub fn from_file(file: File, field_config: &FieldConfig) -> Self { let url = file.url(field_config); diff --git a/src/object/context_value.rs b/src/object/context_value.rs new file mode 100644 index 0000000..62de0c3 --- /dev/null +++ b/src/object/context_value.rs @@ -0,0 +1,274 @@ +//! The value tree a template renders against. +//! +//! Liquid's `Value` holds only liquid's own types, and an object renders as its +//! keys and values run together with no separator. A file field has to render +//! as its url while still answering `.url`, `.name` and the rest, which no +//! `Value` variant can do - so the context is built out of these, whose leaves +//! are `&dyn ValueView` and can therefore carry a [`RenderedFile`]. +//! +//! [`ContextValue::to_value`] is the way back to a plain liquid value, and a +//! file converts to an object there: json consumers see every key, and only +//! rendering differs. + +use crate::fields::RenderedFile; +use liquid::{ + model::{DisplayCow, KString, KStringCow, Object, ScalarCow, State, Value}, + ObjectView, ValueView, +}; +use ordermap::OrderMap; +use std::fmt; + +#[derive(Debug, Clone)] +pub enum ContextValue { + /// Anything liquid can represent on its own. + Liquid(Value), + File(RenderedFile), + Array(Vec), + Object(ContextObject), +} + +impl ContextValue { + pub fn nil() -> Self { + Self::Liquid(Value::Nil) + } + pub fn array(values: impl IntoIterator) -> Self { + Self::Array(values.into_iter().collect()) + } + pub fn as_object(&self) -> Option<&ContextObject> { + match self { + Self::Object(o) => Some(o), + _ => None, + } + } +} + +impl From for ContextValue { + fn from(value: Value) -> Self { + Self::Liquid(value) + } +} + +impl From for ContextValue { + fn from(object: ContextObject) -> Self { + Self::Object(object) + } +} + +impl ValueView for ContextValue { + fn as_debug(&self) -> &dyn fmt::Debug { + self + } + fn render(&self) -> DisplayCow<'_> { + match self { + Self::Liquid(v) => v.render(), + Self::File(f) => f.render(), + Self::Array(a) => a.render(), + Self::Object(o) => o.render(), + } + } + fn source(&self) -> DisplayCow<'_> { + match self { + Self::Liquid(v) => v.source(), + Self::File(f) => f.source(), + Self::Array(a) => a.source(), + Self::Object(o) => o.source(), + } + } + fn type_name(&self) -> &'static str { + match self { + Self::Liquid(v) => v.type_name(), + Self::File(f) => f.type_name(), + Self::Array(a) => a.type_name(), + Self::Object(o) => o.type_name(), + } + } + fn query_state(&self, state: State) -> bool { + match self { + Self::Liquid(v) => v.query_state(state), + Self::File(f) => f.query_state(state), + Self::Array(a) => a.query_state(state), + Self::Object(o) => o.query_state(state), + } + } + fn to_kstr(&self) -> KStringCow<'_> { + match self { + Self::Liquid(v) => v.to_kstr(), + Self::File(f) => f.to_kstr(), + Self::Array(a) => a.to_kstr(), + Self::Object(o) => o.to_kstr(), + } + } + fn as_scalar(&self) -> Option> { + match self { + Self::Liquid(v) => v.as_scalar(), + Self::File(f) => f.as_scalar(), + Self::Array(_) | Self::Object(_) => None, + } + } + fn is_scalar(&self) -> bool { + self.as_scalar().is_some() + } + fn as_array(&self) -> Option<&dyn liquid::model::ArrayView> { + match self { + Self::Liquid(v) => v.as_array(), + Self::Array(a) => Some(a), + Self::File(_) | Self::Object(_) => None, + } + } + fn as_object(&self) -> Option<&dyn ObjectView> { + match self { + Self::Liquid(v) => v.as_object(), + Self::File(f) => f.as_object(), + Self::Array(_) => None, + Self::Object(o) => Some(o), + } + } + fn as_state(&self) -> Option { + match self { + Self::Liquid(v) => v.as_state(), + _ => None, + } + } + fn is_nil(&self) -> bool { + matches!(self, Self::Liquid(Value::Nil)) + } + fn to_value(&self) -> Value { + match self { + Self::Liquid(v) => v.clone(), + Self::File(f) => f.to_value(), + Self::Array(a) => Value::Array(a.iter().map(|v| v.to_value()).collect()), + Self::Object(o) => o.to_value(), + } + } +} + +/// An insertion-ordered map of context values. Ordered because a definition +/// declares its fields in an order and `{% for %}` over an object should follow +/// it; liquid's own object is a hash map. +#[derive(Debug, Clone, Default)] +pub struct ContextObject(OrderMap); + +impl ContextObject { + pub fn new() -> Self { + Self::default() + } + pub fn insert(&mut self, key: KString, value: ContextValue) -> Option { + self.0.insert(key, value) + } + pub fn get(&self, key: &str) -> Option<&ContextValue> { + self.0.get(key) + } + pub fn contains_key(&self, key: &str) -> bool { + self.0.contains_key(key) + } + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn entries(&self) -> impl Iterator { + self.0.iter() + } + pub fn key_strs(&self) -> impl Iterator { + self.0.keys().map(|k| k.as_str()) + } + pub fn extend(&mut self, other: impl IntoIterator) { + self.0.extend(other); + } + pub fn to_object(&self) -> Object { + self.0 + .iter() + .map(|(k, v)| (k.clone(), v.to_value())) + .collect() + } +} + +impl FromIterator<(KString, ContextValue)> for ContextObject { + fn from_iter>(iter: I) -> Self { + Self(iter.into_iter().collect()) + } +} + +/// Liquid's object rendering: every key immediately followed by its value. +struct ObjectRender<'a>(&'a ContextObject); +impl fmt::Display for ObjectRender<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (key, value) in self.0.entries() { + write!(f, "{}{}", key, value.render())?; + } + Ok(()) + } +} + +struct ObjectSource<'a>(&'a ContextObject); +impl fmt::Display for ObjectSource<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{{")?; + for (i, (key, value)) in self.0.entries().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "\"{}\": {}", key, value.source())?; + } + write!(f, "}}") + } +} + +impl ValueView for ContextObject { + fn as_debug(&self) -> &dyn fmt::Debug { + self + } + fn render(&self) -> DisplayCow<'_> { + DisplayCow::Owned(Box::new(ObjectRender(self))) + } + fn source(&self) -> DisplayCow<'_> { + DisplayCow::Owned(Box::new(ObjectSource(self))) + } + fn type_name(&self) -> &'static str { + "object" + } + fn query_state(&self, state: State) -> bool { + match state { + State::Truthy => true, + State::DefaultValue | State::Empty | State::Blank => self.is_empty(), + } + } + fn to_kstr(&self) -> KStringCow<'_> { + KStringCow::from_string(self.render().to_string()) + } + fn to_value(&self) -> Value { + Value::Object(self.to_object()) + } + fn as_object(&self) -> Option<&dyn ObjectView> { + Some(self) + } +} + +impl ObjectView for ContextObject { + fn as_value(&self) -> &dyn ValueView { + self + } + fn size(&self) -> i64 { + self.0.len() as i64 + } + fn keys<'k>(&'k self) -> Box> + 'k> { + Box::new(self.0.keys().map(|k| k.as_ref().into())) + } + fn values<'k>(&'k self) -> Box + 'k> { + Box::new(self.0.values().map(|v| v as &dyn ValueView)) + } + fn iter<'k>(&'k self) -> Box, &'k dyn ValueView)> + 'k> { + Box::new( + self.0 + .iter() + .map(|(k, v)| (k.as_ref().into(), v as &dyn ValueView)), + ) + } + fn contains_key(&self, index: &str) -> bool { + self.0.contains_key(index) + } + fn get<'s>(&'s self, index: &str) -> Option<&'s dyn ValueView> { + self.0.get(index).map(|v| v as &dyn ValueView) + } +} diff --git a/src/object/mod.rs b/src/object/mod.rs index 1be617d..b6d5ffb 100644 --- a/src/object/mod.rs +++ b/src/object/mod.rs @@ -9,10 +9,8 @@ use crate::{ FieldConfig, }; use anyhow::Result; -use liquid::{ - model::{KString, Value}, - ObjectView, ValueView, -}; +use context_value::ContextObject; +use liquid::{model::KString, ObjectView, ValueView}; use ordermap::OrderMap; use serde::{Deserialize, Serialize}; use std::{ @@ -23,6 +21,7 @@ use std::{ use to_liquid::{object_to_liquid_with, ToLiquidOptions}; use toml::Table; use tracing::{instrument, warn}; +pub(crate) mod context_value; mod object_entry; pub(crate) mod to_liquid; pub use object_entry::{ObjectEntry, RenderedObjectEntry}; @@ -284,7 +283,7 @@ impl Object { &self, definition: &ObjectDefinition, field_config: &FieldConfig, - ) -> Value { + ) -> ContextObject { self.liquid_object_with(definition, field_config, ToLiquidOptions::default()) } @@ -293,7 +292,7 @@ impl Object { definition: &ObjectDefinition, field_config: &FieldConfig, options: ToLiquidOptions, - ) -> Value { + ) -> ContextObject { let mut values = object_to_liquid_with(&self.values, definition, field_config, options); // Reserved/special if values.contains_key("path") { @@ -302,9 +301,9 @@ impl Object { if values.contains_key("order") { panic!("Objects may not define order key."); } - values.insert(KString::from_ref("path"), self.url_path().to_value()); - values.insert(KString::from_ref("order"), self.order.to_value()); - Value::Object(values) + values.insert(KString::from_ref("path"), self.url_path().to_value().into()); + values.insert(KString::from_ref("order"), self.order.to_value().into()); + values } } diff --git a/src/object/to_liquid.rs b/src/object/to_liquid.rs index ba5ae97..40e5271 100644 --- a/src/object/to_liquid.rs +++ b/src/object/to_liquid.rs @@ -1,5 +1,6 @@ use crate::{ fields::{File, ObjectValues}, + object::context_value::{ContextObject, ContextValue}, FieldConfig, FieldValue, ObjectDefinition, }; use liquid::model::{KString, ObjectIndex}; @@ -15,8 +16,8 @@ pub fn object_to_liquid_with( definition: &ObjectDefinition, field_config: &FieldConfig, options: ToLiquidOptions, -) -> liquid::model::Object { - let mut values: Vec<(KString, Value)> = definition +) -> ContextObject { + let mut values: Vec<(KString, ContextValue)> = definition .fields .iter() // Secret fields are never added to template contexts. @@ -27,11 +28,11 @@ pub fn object_to_liquid_with( object_values .get(k) .map(|v| v.to_liquid_with(field_config, options)) - .unwrap_or_else(|| Value::Nil), + .unwrap_or_else(ContextValue::nil), ) }) .collect(); - let mut child_values: Vec<(KString, Value)> = definition + let mut child_values: Vec<(KString, ContextValue)> = definition .children .iter() .map(|(k, child_def)| { @@ -40,15 +41,18 @@ pub fn object_to_liquid_with( object_values .get(k) .map(|v| v.typed_objects_with(child_def, field_config, options)) - .unwrap_or_else(|| Value::Array(vec![])), + .unwrap_or_else(|| ContextValue::Array(vec![])), ) }) .collect(); - let mut meta_values: Vec<(KString, Value)> = object_values + let mut meta_values: Vec<(KString, ContextValue)> = object_values .iter() .filter_map(|(k, v)| { if let FieldValue::Meta(meta) = v { - Some((KString::from_ref(k.as_index()), meta.to_liquid())) + Some(( + KString::from_ref(k.as_index()), + ContextValue::from(meta.to_liquid()), + )) } else { None } @@ -60,7 +64,7 @@ pub fn object_to_liquid_with( } impl FieldValue { - pub fn to_liquid(&self, field_config: &FieldConfig) -> liquid::model::Value { + pub fn to_liquid(&self, field_config: &FieldConfig) -> ContextValue { self.to_liquid_with(field_config, ToLiquidOptions::default()) } @@ -68,38 +72,46 @@ impl FieldValue { &self, field_config: &FieldConfig, options: ToLiquidOptions, - ) -> liquid::model::Value { + ) -> ContextValue { match self { // Belt and braces: secret-typed fields are dropped from contexts // above, but a secret value can never render regardless. FieldValue::Secret(s) => { if options.include_secrets { - liquid::model::Value::scalar(s.to_owned()) + ContextValue::from(Value::scalar(s.to_owned())) } else { - liquid::model::Value::Nil + ContextValue::nil() } } FieldValue::File(file) => file.to_liquid(field_config), FieldValue::Oneof((t, v)) => match v.as_ref() { - Some(v) => liquid::object!({ - "type": t, - "value": v.to_liquid_with(field_config, options) - }) - .into(), - None => liquid::model::Value::Nil, + Some(v) => ContextValue::Object( + [ + ( + KString::from_static("type"), + ContextValue::from(Value::scalar(t.to_owned())), + ), + ( + KString::from_static("value"), + v.to_liquid_with(field_config, options), + ), + ] + .into_iter() + .collect(), + ), + None => ContextValue::nil(), }, - _ => self.to_value(), + _ => ContextValue::from(self.to_value()), } } } impl File { - pub fn to_liquid(&self, field_config: &FieldConfig) -> liquid::model::Value { - let mut m = liquid::model::Object::new(); - for (k, v) in self.clone().into_map(Some(field_config)) { - m.insert(k.into(), liquid::model::Value::scalar(v)); - } - liquid_core::Value::Object(m) + pub fn to_liquid(&self, field_config: &FieldConfig) -> ContextValue { + ContextValue::File(crate::object::Renderable::rendered( + self.clone(), + field_config, + )) } } @@ -187,7 +199,7 @@ mod secret_tests { assert_eq!(reparsed.values, object.values); } - fn liquid_values(options: ToLiquidOptions) -> liquid::model::Object { + fn liquid_values(options: ToLiquidOptions) -> ContextObject { let (definition, object) = artist(); object_to_liquid_with( &object.values, diff --git a/src/page.rs b/src/page.rs index e905b47..71a1ee7 100644 --- a/src/page.rs +++ b/src/page.rs @@ -1,32 +1,24 @@ use crate::{ - object::{Object, ObjectEntry}, + object::{ + context_value::{ContextObject, ContextValue}, + Object, ObjectEntry, + }, object_definition::ObjectDefinition, tags::render::RenderContext, FieldConfig, ObjectDefinitions, ObjectMap, }; use anyhow::Result; -use liquid::{model::ScalarCow, ValueView}; +use liquid::ValueView; use liquid_core::Value; use once_cell::sync::Lazy; use pluralizer::pluralize; use regex::Regex; use std::{ borrow::Cow, - env, - error::Error, - fmt, + env, fmt, path::{Path, PathBuf}, }; -#[derive(Debug, Clone)] -struct InvalidPageError; -impl Error for InvalidPageError {} -impl fmt::Display for InvalidPageError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "invalid page") - } -} - static TEMPLATE_FILE_NAME_RE: Lazy = Lazy::new(|| Regex::new(r"^(.+?)(\.\w+)?\.liquid").unwrap()); @@ -112,8 +104,11 @@ pub struct RenderGlobals<'a> { } impl RenderGlobals<'_> { - fn inject(&self, object: &mut liquid::Object) { - object.insert("site_url".into(), Value::scalar(self.site_url.to_string())); + fn inject(&self, object: &mut ContextObject) { + object.insert( + "site_url".into(), + Value::scalar(self.site_url.to_string()).into(), + ); } } @@ -137,19 +132,22 @@ pub fn build_context( definitions: &ObjectDefinitions, field_config: &FieldConfig, globals: &RenderGlobals, -) -> liquid::Object { +) -> ContextObject { let _span = tracing::trace_span!("build_context").entered(); - let mut context = liquid::Object::new(); - let mut objects = liquid::Object::new(); + let mut context = ContextObject::new(); + let mut objects = ContextObject::new(); for (name, obj_entry) in objects_map { let definition = definitions .get(name) .unwrap_or_else(|| panic!("missing object definition {}", name)); let values = match obj_entry { - ObjectEntry::List(l) => { - Value::array(l.iter().map(|o| o.liquid_object(definition, field_config))) + ObjectEntry::List(l) => ContextValue::array( + l.iter() + .map(|o| ContextValue::Object(o.liquid_object(definition, field_config))), + ), + ObjectEntry::Object(o) => { + ContextValue::Object(o.liquid_object(definition, field_config)) } - ObjectEntry::Object(o) => o.liquid_object(definition, field_config), }; objects.insert(name.into(), values.clone()); context.insert( @@ -167,14 +165,14 @@ pub fn build_context( /// shared keys without deep-cloning the (large) shared context for every page. #[derive(Debug)] struct LayeredContext<'a> { - overlay: &'a liquid::Object, - base: &'a liquid::Object, + overlay: &'a ContextObject, + base: &'a ContextObject, } impl LayeredContext<'_> { - fn merged(&self) -> liquid::Object { + fn merged(&self) -> ContextObject { let mut merged = self.base.clone(); - merged.extend(self.overlay.iter().map(|(k, v)| (k.clone(), v.clone()))); + merged.extend(self.overlay.entries().map(|(k, v)| (k.clone(), v.clone()))); merged } } @@ -213,7 +211,7 @@ impl ValueView for LayeredContext<'_> { liquid::model::KStringCow::from_string(self.to_string()) } fn to_value(&self) -> Value { - Value::Object(self.merged()) + self.merged().to_value() } fn as_object(&self) -> Option<&dyn liquid::ObjectView> { Some(self) @@ -230,13 +228,13 @@ impl liquid::ObjectView for LayeredContext<'_> { fn keys<'k>(&'k self) -> Box> + 'k> { Box::new( self.overlay - .keys() + .key_strs() .chain( self.base - .keys() - .filter(|k| !self.overlay.contains_key(k.as_str())), + .key_strs() + .filter(|k| !self.overlay.contains_key(k)), ) - .map(|k| k.as_ref().into()), + .map(|k| k.into()), ) } fn values<'k>(&'k self) -> Box + 'k> { @@ -247,13 +245,13 @@ impl liquid::ObjectView for LayeredContext<'_> { ) -> Box, &'k dyn ValueView)> + 'k> { Box::new( self.overlay - .iter() + .entries() .chain( self.base - .iter() + .entries() .filter(|(k, _)| !self.overlay.contains_key(k.as_str())), ) - .map(|(k, v)| (k.as_ref().into(), v.as_view())), + .map(|(k, v)| (k.as_ref().into(), v as &dyn ValueView)), ) } fn contains_key(&self, index: &str) -> bool { @@ -263,17 +261,18 @@ impl liquid::ObjectView for LayeredContext<'_> { self.overlay .get(index) .or_else(|| self.base.get(index)) - .map(|v| v.as_view()) + .map(|v| v as &dyn ValueView) } } -pub(crate) fn debug_context(object: &liquid::Object, lp: usize) -> String { +pub(crate) fn debug_context(object: &ContextObject, lp: usize) -> String { let mut debug_str = String::default(); - fn to_str(val: &Value, lp: usize) -> String { + fn to_str(val: &ContextValue, lp: usize) -> String { let include_values = env::var("ARCHIVAL_CONTEXT_VALUES").is_ok(); match val { - Value::Object(o) => debug_context(o, lp + 1), - Value::Array(a) => { + ContextValue::Object(o) => debug_context(o, lp + 1), + ContextValue::File(f) => format!(" = {}", f.url), + ContextValue::Array(a) => { if include_values { format!( "\n{}↘︎[{} items]{}\n{}⎼⎼⎼", @@ -298,8 +297,8 @@ pub(crate) fn debug_context(object: &liquid::Object, lp: usize) -> String { ) } } - Value::Nil => " (nil)".to_string(), - Value::Scalar(s) => { + ContextValue::Liquid(Value::Nil) => " (nil)".to_string(), + ContextValue::Liquid(Value::Scalar(s)) => { if include_values { format!(" ({}: {:?})", val.type_name(), s.as_view()) } else { @@ -309,10 +308,8 @@ pub(crate) fn debug_context(object: &liquid::Object, lp: usize) -> String { _ => format!(": ({})", val.type_name()), } } - for k in object.keys() { + for (k, val) in object.entries() { debug_str += &format!("\n{}⌗{}", " ".repeat(lp), k); - let ev = liquid_core::Value::Scalar(ScalarCow::new("empty")); - let val = object.get(k).unwrap_or(&ev); debug_str += &to_str(val, lp + 1); } debug_str @@ -408,12 +405,13 @@ impl<'a> Page<'a> { pub fn render( &self, parser: &liquid::Parser, - base_context: &liquid::Object, + base_context: &ContextObject, field_config: &FieldConfig, ) -> Result { #[cfg(feature = "verbose-logging")] tracing::debug!("rendering {}", self.name); - let mut overlay = liquid::object!({ "page": self.name }); + let mut overlay = ContextObject::new(); + overlay.insert("page".into(), Value::scalar(self.name.clone()).into()); if let Some(template_info) = &self.template { let parsed; let template = match template_info.parsed { @@ -424,21 +422,23 @@ impl<'a> Page<'a> { &parsed } }; - let mut object_vals = match template_info + let mut object_vals = template_info .object - .liquid_object(template_info.definition, field_config) - { - liquid::model::Value::Object(v) => Ok(v), - _ => Err(InvalidPageError), - }?; - object_vals.extend(liquid::object!({ - "object_name": template_info.object.object_name, - "order": template_info.object.order, - "path": template_info.object.url_path(), - })); + .liquid_object(template_info.definition, field_config); + object_vals.extend([ + ( + "object_name".into(), + Value::scalar(template_info.object.object_name.clone()).into(), + ), + ("order".into(), template_info.object.order.to_value().into()), + ( + "path".into(), + template_info.object.url_path().to_value().into(), + ), + ]); overlay.insert( template_info.definition.name.to_owned().into(), - Value::Object(object_vals), + object_vals.into(), ); let context = LayeredContext { overlay: &overlay, diff --git a/src/site.rs b/src/site.rs index 8447fff..2eaf0e1 100644 --- a/src/site.rs +++ b/src/site.rs @@ -5,7 +5,10 @@ use crate::{ constants::MANIFEST_FILE_NAME, liquid_parser::{self, PARTIAL_FILE_NAME_RE}, manifest::Manifest, - object::{Object, ObjectEntry, Renderable, RenderedObject, RenderedObjectMap}, + object::{ + context_value::ContextObject, Object, ObjectEntry, Renderable, RenderedObject, + RenderedObjectMap, + }, object_definition::{ObjectDefinition, ObjectDefinitions}, page::{build_context, Page, RenderGlobals, TemplateType}, read_toml::read_toml, @@ -826,7 +829,7 @@ impl Site { template_path: &PathBuf, build_dir: &PathBuf, field_config: &FieldConfig, - base_context: &liquid::Object, + base_context: &ContextObject, fs: &mut T, liquid_parser: &liquid::Parser, build_cache: &RwLock>, @@ -876,7 +879,7 @@ impl Site { page_name: &str, page_type: TemplateType, build_dir: &PathBuf, - base_context: &liquid::Object, + base_context: &ContextObject, fs: &mut T, liquid_parser: &liquid::Parser, ) -> Result<(Option, u64)> { diff --git a/src/tags/output.rs b/src/tags/output.rs index 7292c78..80be5bb 100644 --- a/src/tags/output.rs +++ b/src/tags/output.rs @@ -291,6 +291,123 @@ mod tests { } } + /// A file field is its url everywhere a template asks for one value. + /// Depends on the context tree keeping `&dyn ValueView` leaves and on + /// liquid borrowing them out of a frame rather than owning them. + mod files { + use crate::fields::{DisplayType, FieldConfig, File}; + use crate::object::context_value::{ContextObject, ContextValue}; + use liquid::ValueView; + + fn photo() -> File { + File::new( + "abc123", + Some("A photo"), + None, + "IMG_1122.jpeg", + "image/jpeg", + DisplayType::Image, + ) + } + + fn render_ctx(template: &str, context: &ContextObject) -> String { + let parser = crate::liquid_parser::build_with_partials(Default::default()).unwrap(); + crate::liquid_parser::parse(&parser, template) + .unwrap() + .render(context) + .unwrap() + } + + fn photo_context() -> (ContextObject, String) { + let config = FieldConfig::default(); + let file = photo(); + let url = file.url(&config); + let mut context = ContextObject::new(); + context.insert("photo".into(), file.to_liquid(&config)); + (context, url) + } + + #[test] + fn a_file_renders_as_its_url() { + let (context, url) = photo_context(); + assert_eq!(render_ctx("{{ photo }}", &context), url); + assert_eq!( + render_ctx(r#""#, &context), + format!(r#""#) + ); + } + + #[test] + fn a_files_keys_are_still_reachable() { + let (context, url) = photo_context(); + assert_eq!(render_ctx("{{ photo.url }}", &context), url); + assert_eq!(render_ctx("{{ photo.name }}", &context), "A photo"); + assert_eq!(render_ctx("{{ photo.mime }}", &context), "image/jpeg"); + assert_eq!( + render_ctx("{{ photo.filename }}", &context), + "IMG_1122.jpeg" + ); + } + + /// A url is a string, so the filters and comparisons a template reaches + /// for read it as one. + #[test] + fn a_file_behaves_as_its_url_string() { + let (context, url) = photo_context(); + assert_eq!( + render_ctx(r#"{{ photo | append: "?w=100" }}"#, &context), + format!("{url}?w=100") + ); + assert_eq!( + render_ctx( + "{% if photo contains 'IMG_1122' %}yes{% else %}no{% endif %}", + &context + ), + "yes" + ); + } + + /// Files reach a template nested in child objects and lists, and the + /// loop that walks them still iterates. + #[test] + fn a_nested_file_renders_as_its_url() { + let config = FieldConfig::default(); + let url = photo().url(&config); + let mut child = ContextObject::new(); + child.insert("photo".into(), photo().to_liquid(&config)); + let mut context = ContextObject::new(); + context.insert( + "gallery".into(), + ContextValue::array([ContextValue::Object(child)]), + ); + assert_eq!( + render_ctx( + "{% for item in gallery %}{{ item.photo }}{% endfor %}", + &context + ), + url + ); + assert_eq!( + render_ctx("{{ gallery[0].photo.name }}", &context), + "A photo" + ); + assert_eq!(render_ctx("{{ gallery.size }}", &context), "1"); + } + + /// Only rendering changed: everything reading the tree as data - the + /// carrier payload, the objects command, json pages - still sees the + /// whole file object. + #[test] + fn a_file_is_still_an_object_as_data() { + let (context, url) = photo_context(); + let value = context.get("photo").unwrap().to_value(); + let object = value.as_object().expect("a file is an object as data"); + assert_eq!(object.get("url").unwrap().to_kstr(), url); + assert_eq!(object.get("sha").unwrap().to_kstr(), "abc123"); + assert_eq!(object.get("mime").unwrap().to_kstr(), "image/jpeg"); + } + } + #[test] fn liquid_in_a_value_is_rendered() { let globals = liquid::object!({ "body": "hello {{ name }}", "name": "world" });