diff --git a/components/config/src/config/mod.rs b/components/config/src/config/mod.rs index e0674276dd..a01a967dd5 100644 --- a/components/config/src/config/mod.rs +++ b/components/config/src/config/mod.rs @@ -111,6 +111,13 @@ pub struct SerializedConfig<'a> { markdown: &'a markup::Markdown, } +#[derive(Serialize)] +pub struct SassConfig<'a> { + base_url: &'a str, + theme: &'a Option, + extra: &'a HashMap, +} + impl Config { // any extra syntax and highlight themes have been loaded and validated already by the from_file method before parsing the config /// Parses a string containing TOML to our Config struct @@ -333,6 +340,10 @@ impl Config { markdown: &self.markdown, } } + + pub fn sass_config(&self) -> SassConfig { + SassConfig { base_url: &self.base_url, theme: &self.theme, extra: &self.extra } + } } // merge TOML data that can be a table, or anything else diff --git a/components/site/Cargo.toml b/components/site/Cargo.toml index 3388f2dc88..9474265599 100644 --- a/components/site/Cargo.toml +++ b/components/site/Cargo.toml @@ -7,6 +7,7 @@ include = ["src/**/*"] [dependencies] serde = { version = "1.0", features = ["derive"] } +tempfile = "3" errors = { path = "../errors" } config = { path = "../config" } @@ -20,5 +21,4 @@ libs = { path = "../libs" } content = { path = "../content" } [dev-dependencies] -tempfile = "3" path-slash = "0.2" diff --git a/components/site/src/lib.rs b/components/site/src/lib.rs index b40e0b5c76..5f8d91a1ad 100644 --- a/components/site/src/lib.rs +++ b/components/site/src/lib.rs @@ -708,13 +708,13 @@ impl Site { if let Some(ref theme) = self.config.theme { let theme_path = self.base_path.join("themes").join(theme); if theme_path.join("sass").exists() { - sass::compile_sass(&theme_path, &self.output_path)?; + sass::compile_sass(&theme_path, &self.output_path, &self.config)?; start = log_time(start, "Compiled theme Sass"); } } if self.config.compile_sass { - sass::compile_sass(&self.base_path, &self.output_path)?; + sass::compile_sass(&self.base_path, &self.output_path, &self.config)?; start = log_time(start, "Compiled own Sass"); } diff --git a/components/site/src/sass/mod.rs b/components/site/src/sass/mod.rs index 87f5c7dd8c..4a6c846ef4 100644 --- a/components/site/src/sass/mod.rs +++ b/components/site/src/sass/mod.rs @@ -1,24 +1,27 @@ use std::fs::create_dir_all; use std::path::{Path, PathBuf}; +use config::Config; use libs::globset::Glob; use libs::grass::{from_path as compile_file, Options, OutputStyle}; use libs::walkdir::{DirEntry, WalkDir}; +use tempfile::{tempdir, TempDir}; use crate::anyhow; -use errors::{bail, Result}; +use errors::{bail, Context, Result}; use utils::fs::{create_file, ensure_directory_exists}; -pub fn compile_sass(base_path: &Path, output_path: &Path) -> Result<()> { +mod serde; + +pub fn compile_sass(base_path: &Path, output_path: &Path, config: &Config) -> Result<()> { ensure_directory_exists(output_path)?; - let sass_path = { - let mut sass_path = PathBuf::from(base_path); - sass_path.push("sass"); - sass_path - }; + let sass_path = PathBuf::from(base_path).join("sass"); + + let dependencies_dir = build_dependencies_dir_from_config(config)?; - let options = Options::default().style(OutputStyle::Compressed); + let options = + Options::default().style(OutputStyle::Compressed).load_path(dependencies_dir.path()); let files = get_non_partial_scss(&sass_path); let mut compiled_paths = Vec::new(); @@ -52,6 +55,24 @@ pub fn compile_sass(base_path: &Path, output_path: &Path) -> Result<()> { Ok(()) } +/// write out a subset of the Zola config document to a temporary SCSS file +/// as an SCSS map variable literal. this will allow parts of the site's +/// config to be usable during Sass compilation. this enables theme configuration +/// like allowing the site owner to change header color. this function returns +/// a tempdir holding a single `.scss` file. the tempdir should then be used as +/// a load directory above when compiling the site's Sass files. the tempdir +/// and contained `.scss` file will be deleted on drop of the returned `TempDir` +/// struct, which should happen after Sass compilation finishes. +fn build_dependencies_dir_from_config(config: &Config) -> Result { + let dir = tempdir().context("failed to create tempdir for SASS dependencies")?; + + let config_serialized = serde::serialize_config(config)?; + + std::fs::write(dir.path().join("zola.scss"), format!("$config: {}", config_serialized))?; + + Ok(dir) +} + fn is_partial_scss(entry: &DirEntry) -> bool { entry.file_name().to_str().map(|s| s.starts_with('_')).unwrap_or(false) } diff --git a/components/site/src/sass/serde.rs b/components/site/src/sass/serde.rs new file mode 100644 index 0000000000..fb1e6df5f7 --- /dev/null +++ b/components/site/src/sass/serde.rs @@ -0,0 +1,481 @@ +use config::Config; +use errors::{Context, Result}; + +use serde::{Serialize, Serializer}; + +use std::fmt::Display; + +/// write the site's `Config` struct out as a SCSS map literal. this +/// first converts the `Config` to a `SassConfig` to only retain and +/// serialize the keys in the config document that we want to end up +/// in the resulting SCSS map. +pub(crate) fn serialize_config(config: &Config) -> Result { + let sass_config = config.sass_config(); + let mut ser = SassMapSerializer::default(); + + serde::Serialize::serialize(&sass_config, &mut ser) + .context("failed to serialize Zola config document")?; + + Ok(ser.output) +} + +/// custom serde `Serializer` that serializes a structure as an SCSS +/// map literal. the primary difference between an SCSS map literal +/// and JSON is that SCSS uses parentheses like `("key": "value")` +/// instead of `{"key": "value"}` to express maps/dictionaries. +#[derive(Default)] +struct SassMapSerializer { + output: String, +} + +#[derive(Debug)] +struct SassMapSerializerError(String); + +type SassMapSerializerResult = std::result::Result<(), SassMapSerializerError>; + +impl serde::ser::Error for SassMapSerializerError { + fn custom(msg: T) -> Self { + SassMapSerializerError(msg.to_string()) + } +} + +impl Display for SassMapSerializerError { + fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_fmt(format_args!("SassMapSerializerError({})", self.0)) + } +} + +impl std::error::Error for SassMapSerializerError {} + +impl<'a> Serializer for &'a mut SassMapSerializer { + type Ok = (); + type Error = SassMapSerializerError; + + type SerializeSeq = Self; + type SerializeTuple = Self; + type SerializeTupleStruct = Self; + type SerializeTupleVariant = Self; + type SerializeMap = Self; + type SerializeStruct = Self; + type SerializeStructVariant = Self; + + fn serialize_bool(self, v: bool) -> std::result::Result { + self.output += if v { "true" } else { "false" }; + Ok(()) + } + + fn serialize_i8(self, v: i8) -> std::result::Result { + self.serialize_i64(i64::from(v)) + } + + fn serialize_i16(self, v: i16) -> std::result::Result { + self.serialize_i64(i64::from(v)) + } + + fn serialize_i32(self, v: i32) -> std::result::Result { + self.serialize_i64(i64::from(v)) + } + + fn serialize_i64(self, v: i64) -> std::result::Result { + self.output += &v.to_string(); + Ok(()) + } + + fn serialize_u8(self, v: u8) -> std::result::Result { + self.serialize_u64(u64::from(v)) + } + + fn serialize_u16(self, v: u16) -> std::result::Result { + self.serialize_u64(u64::from(v)) + } + + fn serialize_u32(self, v: u32) -> std::result::Result { + self.serialize_u64(u64::from(v)) + } + + fn serialize_u64(self, v: u64) -> std::result::Result { + self.output += &v.to_string(); + Ok(()) + } + + fn serialize_f32(self, v: f32) -> std::result::Result { + self.serialize_f64(f64::from(v)) + } + + fn serialize_f64(self, v: f64) -> std::result::Result { + self.output += &v.to_string(); + Ok(()) + } + + /// serialize any chars as if they were single-character strings + fn serialize_char(self, v: char) -> std::result::Result { + self.serialize_str(&v.to_string()) + } + + fn serialize_str(self, v: &str) -> std::result::Result { + self.output += "\""; + self.output += v; + self.output += "\""; + Ok(()) + } + + /// not implemented, as the type being serialized here is TOML-based, which + /// has no native byte type + fn serialize_bytes(self, _v: &[u8]) -> std::result::Result { + unimplemented!() + } + + /// treat None as null + fn serialize_none(self) -> std::result::Result { + self.output += "null"; + Ok(()) + } + + /// treat Some(T) just as an instance of T itself + fn serialize_some(self, value: &T) -> std::result::Result + where + T: serde::Serialize, + { + value.serialize(self) + } + + /// treat the unit struct `()` as None/null + fn serialize_unit(self) -> std::result::Result { + self.serialize_none() + } + + /// treat the unit struct `()` as None/null + fn serialize_unit_struct( + self, + _name: &'static str, + ) -> std::result::Result { + self.serialize_none() + } + + /// for a unit variant like `MyEnum::A`, just serialize the variant name + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> std::result::Result { + self.serialize_str(variant) + } + + /// for a newtype struct like `Dollars(u8)`, just serialize as + /// the wrapped type + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> std::result::Result + where + T: serde::Serialize, + { + value.serialize(self) + } + + /// for a newtype variant like `Currency::Dollars(u8)`, just serialize as + /// the wrapped type + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + value: &T, + ) -> std::result::Result + where + T: serde::Serialize, + { + value.serialize(self) + } + + /// arrays/sequences are serialized as in JSON + fn serialize_seq( + self, + _len: Option, + ) -> std::result::Result { + self.output += "["; + Ok(self) + } + + /// treat a tuple the same way we treat an array/sequence + fn serialize_tuple(self, len: usize) -> std::result::Result { + self.serialize_seq(Some(len)) + } + + /// treat a tuple struct like `Rgb(u8, u8, u8)` the same way we treat + /// an array/sequence + fn serialize_tuple_struct( + self, + _name: &'static str, + len: usize, + ) -> std::result::Result { + self.serialize_seq(Some(len)) + } + + /// treat a tuple variant like `Color::Rgb(u8, u8, u8)` the same way + /// we treat an array/sequence + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + len: usize, + ) -> std::result::Result { + self.serialize_seq(Some(len)) + } + + /// serialize maps as `("key": "value")` + fn serialize_map( + self, + _len: Option, + ) -> std::result::Result { + self.output += "("; + Ok(self) + } + + /// treat a struct with named members as a map + fn serialize_struct( + self, + _name: &'static str, + len: usize, + ) -> std::result::Result { + self.serialize_map(Some(len)) + } + + /// treat a struct variant like `Color::Rgb { r: u8, g: u8, b: u8}` + /// as a map + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + len: usize, + ) -> std::result::Result { + self.serialize_map(Some(len)) + } +} + +impl<'a> serde::ser::SerializeSeq for &'a mut SassMapSerializer { + // Must match the `Ok` type of the serializer. + type Ok = (); + // Must match the `Error` type of the serializer. + type Error = SassMapSerializerError; + + // Serialize a single element of the sequence. + fn serialize_element(&mut self, value: &T) -> SassMapSerializerResult + where + T: ?Sized + Serialize, + { + // output a comma for all but the first element + if !self.output.ends_with('[') { + self.output += ","; + } + value.serialize(&mut **self) + } + + // Close the sequence. + fn end(self) -> SassMapSerializerResult { + self.output += "]"; + Ok(()) + } +} + +// Same thing but for tuples. +impl<'a> serde::ser::SerializeTuple for &'a mut SassMapSerializer { + type Ok = (); + type Error = SassMapSerializerError; + + fn serialize_element(&mut self, value: &T) -> SassMapSerializerResult + where + T: ?Sized + Serialize, + { + // output a comma for all but the first element + if !self.output.ends_with('[') { + self.output += ","; + } + value.serialize(&mut **self) + } + + fn end(self) -> SassMapSerializerResult { + self.output += "]"; + Ok(()) + } +} + +// Same thing but for tuple structs. +impl<'a> serde::ser::SerializeTupleStruct for &'a mut SassMapSerializer { + type Ok = (); + type Error = SassMapSerializerError; + + fn serialize_field(&mut self, value: &T) -> SassMapSerializerResult + where + T: ?Sized + Serialize, + { + // output a comma for all but the first element + if !self.output.ends_with('[') { + self.output += ","; + } + value.serialize(&mut **self) + } + + fn end(self) -> SassMapSerializerResult { + self.output += "]"; + Ok(()) + } +} + +// Same thing but for tuple variants. +impl<'a> serde::ser::SerializeTupleVariant for &'a mut SassMapSerializer { + type Ok = (); + type Error = SassMapSerializerError; + + fn serialize_field(&mut self, value: &T) -> SassMapSerializerResult + where + T: ?Sized + Serialize, + { + // output a comma for all but the first element + if !self.output.ends_with('[') { + self.output += ","; + } + value.serialize(&mut **self) + } + + fn end(self) -> SassMapSerializerResult { + self.output += "]"; + Ok(()) + } +} + +impl<'a> serde::ser::SerializeMap for &'a mut SassMapSerializer { + type Ok = (); + type Error = SassMapSerializerError; + + /// The Serde data model allows map keys to be any serializable type. SCSS + /// only allows string keys so the implementation below will produce an invalid + /// SCSS map if the key serializes as something other than a string. That + /// should be fine here as the source TOML only allows string keys as well. + fn serialize_key(&mut self, key: &T) -> SassMapSerializerResult + where + T: ?Sized + Serialize, + { + if !self.output.ends_with('(') { + self.output += ","; + } + key.serialize(&mut **self) + } + + fn serialize_value(&mut self, value: &T) -> SassMapSerializerResult + where + T: ?Sized + Serialize, + { + self.output += ":"; + value.serialize(&mut **self) + } + + fn end(self) -> SassMapSerializerResult { + self.output += ")"; + Ok(()) + } +} + +// Same thing but for structs, where the keys are static string field names. +impl<'a> serde::ser::SerializeStruct for &'a mut SassMapSerializer { + type Ok = (); + type Error = SassMapSerializerError; + + fn serialize_field(&mut self, key: &'static str, value: &T) -> SassMapSerializerResult + where + T: ?Sized + Serialize, + { + if !self.output.ends_with('(') { + self.output += ","; + } + key.serialize(&mut **self)?; + self.output += ":"; + value.serialize(&mut **self) + } + + fn end(self) -> SassMapSerializerResult { + self.output += ")"; + Ok(()) + } +} + +// Same thing but for struct variants, where we ignore the variant name. +impl<'a> serde::ser::SerializeStructVariant for &'a mut SassMapSerializer { + type Ok = (); + type Error = SassMapSerializerError; + + fn serialize_field(&mut self, key: &'static str, value: &T) -> SassMapSerializerResult + where + T: ?Sized + Serialize, + { + if !self.output.ends_with('(') { + self.output += ","; + } + key.serialize(&mut **self)?; + self.output += ":"; + value.serialize(&mut **self) + } + + fn end(self) -> SassMapSerializerResult { + self.output += ")"; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_primitives() { + verify_serialization(true, "true"); + verify_serialization(64, "64"); + verify_serialization(-123, "-123"); + verify_serialization(567.89, "567.89"); + verify_serialization('t', "\"t\""); + verify_serialization("abc", "\"abc\""); + verify_serialization(Option::::None, "null"); + verify_serialization((), "null"); + } + + #[test] + fn test_arrays() { + verify_serialization(&[123, 456, 789], "[123,456,789]"); + verify_serialization((123, 456, 789), "[123,456,789]"); + } + + #[test] + fn test_struct() { + #[derive(Serialize)] + struct Inner { + a: Vec, + b: (String, f64), + } + + #[derive(Serialize)] + struct Outer { + a: i32, + b: String, + c: Inner, + } + + let val = Outer { + a: 42, + b: "abc".to_string(), + c: Inner { a: vec![6, 7, 8], b: ("def".to_string(), 123.45) }, + }; + + let expected = "(\"a\":42,\"b\":\"abc\",\"c\":(\"a\":[6,7,8],\"b\":[\"def\",123.45]))"; + verify_serialization(val, expected); + } + + fn verify_serialization(val: T, expected: &str) { + let mut ser = SassMapSerializer::default(); + val.serialize(&mut ser).unwrap(); + assert_eq!(ser.output, expected); + } +} diff --git a/src/cmd/serve.rs b/src/cmd/serve.rs index 52b1d585b9..1b050eae78 100644 --- a/src/cmd/serve.rs +++ b/src/cmd/serve.rs @@ -465,7 +465,7 @@ pub fn serve( console::info(&msg); rebuild_done_handling( &broadcaster, - compile_sass(&site.base_path, &site.output_path), + compile_sass(&site.base_path, &site.output_path, &site.config), &partial_path.to_string_lossy(), ); }; diff --git a/test_site/config.toml b/test_site/config.toml index e3fcbfd64d..77268e0535 100644 --- a/test_site/config.toml +++ b/test_site/config.toml @@ -33,5 +33,8 @@ skip_anchor_prefixes = [ "https://github.com/rust-lang/rust/blob/", ] +[extra.sass] +background_color = "red" + [extra.author] name = "Vincent Prouillet" diff --git a/test_site/sass/blog.scss b/test_site/sass/blog.scss index 5a0036151d..7325a9a952 100644 --- a/test_site/sass/blog.scss +++ b/test_site/sass/blog.scss @@ -1,5 +1,21 @@ +@use 'zola'; +@use 'sass:map'; + body { - background: red; + background: + // once we are using a more contemporary version of Dart/Sass we + // can use notation like: + // map.get(zola.$config, "extra", "sass", "background_color") + map.get( + map.get( + map.get( + zola.$config, + "extra" + ), + "sass" + ), + "background_color" + ); .container { background: blue;