From 8d16d1701ce7a8ec38508326bd7889f2cd8dc202 Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Sun, 19 Mar 2023 20:37:13 +0100 Subject: [PATCH 01/12] Next version --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb28810d3..9891777789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.18.0 (unreleased) + ## 0.17.2 (2023-03-19) - Fix one more invalid error with colocated directories From b00ae4261bfb2d613583d51a1ce877b157c8740b Mon Sep 17 00:00:00 2001 From: Ever Date: Fri, 7 Apr 2023 03:28:20 +0800 Subject: [PATCH 02/12] print error message when no config file found (#2168) fixed #2195 --- src/main.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6f3fcdfeb7..983f7b2aa7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use std::time::Instant; use cli::{Cli, Command}; +use errors::anyhow; use utils::net::{get_available_port, port_is_available}; use clap::{CommandFactory, Parser}; @@ -13,10 +14,17 @@ mod messages; mod prompt; fn get_config_file_path(dir: &Path, config_path: &Path) -> (PathBuf, PathBuf) { - let root_dir = dir - .ancestors() - .find(|a| a.join(config_path).exists()) - .unwrap_or_else(|| panic!("could not find directory containing config file")); + let root_dir = dir.ancestors().find(|a| a.join(config_path).exists()).unwrap_or_else(|| { + messages::unravel_errors( + "", + &anyhow!( + "{} not found in current directory or ancestors, current_dir is {}", + config_path.display(), + dir.display() + ), + ); + std::process::exit(1); + }); // if we got here we found root_dir so config file should exist so we can unwrap safely let config_file = root_dir From 1ed722c076e801c7ec3d62209d40315b4353b811 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 6 Apr 2023 23:21:01 +0200 Subject: [PATCH 03/12] Speedup "zola check" command by reusing the Client (#2171) * Reuse Client when checking urls and add timeout for requests --- components/link_checker/src/lib.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/components/link_checker/src/lib.rs b/components/link_checker/src/lib.rs index 3b3551afd6..ab44259217 100644 --- a/components/link_checker/src/lib.rs +++ b/components/link_checker/src/lib.rs @@ -30,6 +30,13 @@ pub fn message(res: &Result) -> String { // Keep history of link checks so a rebuild doesn't have to check again static LINKS: Lazy>>> = Lazy::new(|| Arc::new(RwLock::new(HashMap::new()))); +// Make sure to create only a single Client so that we can reuse the connections +static CLIENT: Lazy = Lazy::new(|| { + Client::builder() + .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) + .build() + .expect("reqwest client build") +}); pub fn check_url(url: &str, config: &LinkChecker) -> Result { { @@ -44,15 +51,11 @@ pub fn check_url(url: &str, config: &LinkChecker) -> Result { headers.append(ACCEPT, "*/*".parse().unwrap()); // TODO: pass the client to the check_url, do not pass the config - let client = Client::builder() - .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) - .build() - .expect("reqwest client build"); let check_anchor = !config.skip_anchor_prefixes.iter().any(|prefix| url.starts_with(prefix)); // Need to actually do the link checking - let res = match client.get(url).headers(headers).send() { + let res = match CLIENT.get(url).headers(headers).send() { Ok(ref mut response) if check_anchor && has_anchor(url) => { let body = { let mut buf: Vec = vec![]; From 6a5c241152fead78bc6f08db18739d4aa0070ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=8F=E2=80=8FDave?= <47663767+cydave@users.noreply.github.com> Date: Thu, 20 Apr 2023 17:44:19 +0000 Subject: [PATCH 04/12] Implement replace_re filter (#2163) * Implement replace_re filter * Cargo fmt * add regex caching * cargo fmt * update docs, update unit test * rename replace_re -> regex_replace --- components/templates/src/filters.rs | 70 ++++++++++++++++++- components/templates/src/lib.rs | 1 + .../documentation/templates/overview.md | 8 +++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/components/templates/src/filters.rs b/components/templates/src/filters.rs index b9b53d3767..432f2b3d22 100644 --- a/components/templates/src/filters.rs +++ b/components/templates/src/filters.rs @@ -1,9 +1,12 @@ use std::borrow::Cow; use std::collections::HashMap; use std::hash::BuildHasher; +use std::sync::{Arc, Mutex}; use config::Config; + use libs::base64::engine::{general_purpose::STANDARD as standard_b64, Engine}; +use libs::regex::Regex; use libs::tera::{ to_value, try_get_value, Error as TeraError, Filter as TeraFilter, Result as TeraResult, Tera, Value, @@ -78,6 +81,53 @@ pub fn base64_decode( Ok(to_value(as_str).unwrap()) } +#[derive(Debug)] +pub struct RegexReplaceFilter { + re_cache: Arc>>, +} + +impl RegexReplaceFilter { + pub fn new() -> Self { + return Self { re_cache: Arc::new(Mutex::new(HashMap::new())) }; + } +} + +impl TeraFilter for RegexReplaceFilter { + fn filter(&self, value: &Value, args: &HashMap) -> TeraResult { + let text = try_get_value!("regex_replace", "value", String, value); + let pattern = match args.get("pattern") { + Some(val) => try_get_value!("regex_replace", "pattern", String, val), + None => { + return Err(TeraError::msg( + "Filter `regex_replace` expected an arg called `pattern`", + )) + } + }; + let rep = match args.get("rep") { + Some(val) => try_get_value!("regex_replace", "rep", String, val), + None => { + return Err(TeraError::msg("Filter `regex_replace` expected an arg called `rep`")) + } + }; + + let mut cache = self.re_cache.lock().expect("re_cache lock"); + let replaced = { + match cache.get(&pattern) { + Some(pat) => pat.replace_all(&text, &rep), + None => { + let pat = Regex::new(&pattern) + .map_err(|e| format!("`regex_replace`: failed to compile regex: {}", e))?; + let replaced = pat.replace_all(&text, &rep); + cache.insert(pattern, pat); + replaced + } + } + }; + + Ok(to_value(replaced).unwrap()) + } +} + #[derive(Debug)] pub struct NumFormatFilter { default_language: String, @@ -114,7 +164,9 @@ mod tests { use libs::tera::{to_value, Filter, Tera}; - use super::{base64_decode, base64_encode, MarkdownFilter, NumFormatFilter}; + use super::{ + base64_decode, base64_encode, MarkdownFilter, NumFormatFilter, RegexReplaceFilter, + }; use config::Config; #[test] @@ -251,6 +303,22 @@ mod tests { } } + #[test] + fn regex_replace_filter() { + let value = "Springsteen, Bruce"; + let expected = "Bruce Springsteen"; + let pattern = r"(?P[^,\s]+),\s+(?P\S+)"; + let rep = "$first $last"; + let mut args = HashMap::new(); + args.insert("pattern".to_string(), to_value(pattern).unwrap()); + args.insert("rep".to_string(), to_value(rep).unwrap()); + let regex_replace = RegexReplaceFilter::new(); + let result = regex_replace.filter(&to_value(value).unwrap(), &args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), to_value(expected).unwrap()); + assert!(regex_replace.re_cache.lock().unwrap().contains_key(pattern)); + } + #[test] fn num_format_filter() { let tests = vec![ diff --git a/components/templates/src/lib.rs b/components/templates/src/lib.rs index 6441ff4999..59ab2abaf9 100644 --- a/components/templates/src/lib.rs +++ b/components/templates/src/lib.rs @@ -28,6 +28,7 @@ pub static ZOLA_TERA: Lazy = Lazy::new(|| { .unwrap(); tera.register_filter("base64_encode", filters::base64_encode); tera.register_filter("base64_decode", filters::base64_decode); + tera.register_filter("regex_replace", filters::RegexReplaceFilter::new()); tera }); diff --git a/docs/content/documentation/templates/overview.md b/docs/content/documentation/templates/overview.md index 20c823070d..7eca41ecb1 100644 --- a/docs/content/documentation/templates/overview.md +++ b/docs/content/documentation/templates/overview.md @@ -86,6 +86,14 @@ Encode the variable to base64. ### base64_decode Decode the variable from base64. +### regex_replace +Replace text via regular expressions. + +```jinja2 +{{ "World Hello" | regex_replace(pattern=`(?P\w+), (?P\w+)`, rep=`$greeting $subject`) }} + +``` + ### num_format Format a number into its string representation. From c48b61ad72b73bd94e6c3a361a66a36900f40898 Mon Sep 17 00:00:00 2001 From: Jeremy Kerr Date: Mon, 24 Apr 2023 05:56:30 +0800 Subject: [PATCH 05/12] templates: add base URL for feed content (#2190) Relative links in the entry content do not currently have a base URI, so will be resolved relative to the feed URI: Given an entry with the content: And URIS of: * entry: https://example.org/blog/some-entry/ * feed: https://example.org/atom.xml The link URI will end up as: https://example.org/some-resource.bin rather than the URI that ends up resolved in the rendered page: https://example.org/blog/some-entry/some-resource.bin The atom and RSS formats allow for an xml:base attribute (itself specified in [1]) to provide a base URI of a subset of a document. This change adds xml:base attributes to each entry, using the page permalink. This gives us something equivalent to: ]]> [1]: https://www.w3.org/TR/xmlbase/ Signed-off-by: Jeremy Kerr --- components/templates/src/builtins/atom.xml | 2 +- components/templates/src/builtins/rss.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/templates/src/builtins/atom.xml b/components/templates/src/builtins/atom.xml index 8b2d331e5c..2f16095563 100644 --- a/components/templates/src/builtins/atom.xml +++ b/components/templates/src/builtins/atom.xml @@ -40,7 +40,7 @@ {% if page.summary %} {{ page.summary }} {% else %} - {{ page.content }} + {{ page.content }} {% endif %} {%- endfor %} diff --git a/components/templates/src/builtins/rss.xml b/components/templates/src/builtins/rss.xml index 1c09e4cf4d..a684a48af9 100644 --- a/components/templates/src/builtins/rss.xml +++ b/components/templates/src/builtins/rss.xml @@ -33,7 +33,7 @@ {{ page.permalink | escape_xml | safe }} {{ page.permalink | escape_xml | safe }} - {% if page.summary %}{{ page.summary }}{% else %}{{ page.content }}{% endif %} + {% if page.summary %}{{ page.summary }}{% else %}{{ page.content }}{% endif %} {%- endfor %} From 7edf92bc8f2b45a2da7a68ae117325eacc76d451 Mon Sep 17 00:00:00 2001 From: Jake G <106644+Jieiku@users.noreply.github.com> Date: Fri, 28 Apr 2023 12:50:54 -0700 Subject: [PATCH 06/12] Fix multi-lingual json index (#2197) * Fix multi-ligual json index * multi-lingual search Fix cargo fmt --- components/site/src/lib.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/components/site/src/lib.rs b/components/site/src/lib.rs index b40e0b5c76..f2cb1951f5 100644 --- a/components/site/src/lib.rs +++ b/components/site/src/lib.rs @@ -787,11 +787,7 @@ impl Site { } fn index_for_lang(&self, lang: &str) -> Result<()> { - let index_json = search::build_index( - &self.config.default_language, - &self.library.read().unwrap(), - &self.config, - )?; + let index_json = search::build_index(lang, &self.library.read().unwrap(), &self.config)?; let (path, content) = match &self.config.search.index_format { IndexFormat::ElasticlunrJson => { let path = self.output_path.join(format!("search_index.{}.json", lang)); From 74483096ca266408d7c8dca8105518048ef17b38 Mon Sep 17 00:00:00 2001 From: Andrew Langmeier Date: Mon, 1 May 2023 15:40:29 -0400 Subject: [PATCH 07/12] Add search.index_format into the serialized config (#2165) (#2196) * Add search into the serialized config (#2165) * Only expose index_format * Create config.search struct * cargo fmt --- components/config/src/config/mod.rs | 2 ++ components/config/src/config/search.rs | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/components/config/src/config/mod.rs b/components/config/src/config/mod.rs index e0674276dd..2cda172127 100644 --- a/components/config/src/config/mod.rs +++ b/components/config/src/config/mod.rs @@ -109,6 +109,7 @@ pub struct SerializedConfig<'a> { build_search_index: bool, extra: &'a HashMap, markdown: &'a markup::Markdown, + search: search::SerializedSearch<'a>, } impl Config { @@ -331,6 +332,7 @@ impl Config { build_search_index: options.build_search_index, extra: &self.extra, markdown: &self.markdown, + search: self.search.serialize(), } } } diff --git a/components/config/src/config/search.rs b/components/config/src/config/search.rs index c96a874847..26c78f7464 100644 --- a/components/config/src/config/search.rs +++ b/components/config/src/config/search.rs @@ -44,3 +44,14 @@ impl Default for Search { } } } + +impl Search { + pub fn serialize(&self) -> SerializedSearch { + SerializedSearch { index_format: &self.index_format } + } +} + +#[derive(Serialize)] +pub struct SerializedSearch<'a> { + pub index_format: &'a IndexFormat, +} From 4f779801b44e0b101a9020a5d99ae5026a1b12ee Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 2 May 2023 13:39:13 +0200 Subject: [PATCH 08/12] Fix typo in error message --- components/site/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/site/src/lib.rs b/components/site/src/lib.rs index f2cb1951f5..2d26b1545e 100644 --- a/components/site/src/lib.rs +++ b/components/site/src/lib.rs @@ -370,7 +370,7 @@ impl Site { if self.config.build_search_index && !index_section.meta.in_search_index { bail!( "You have enabled search in the config but disabled it in the index section: \ - either turn off the search in the config or remote `in_search_index = true` from the \ + either turn off the search in the config or remove `in_search_index = true` from the \ section front-matter." ) } From 1321a83e495d3bc6f8cb13cc5823c9a0a1d477b0 Mon Sep 17 00:00:00 2001 From: Andrew Langmeier Date: Sat, 6 May 2023 10:02:29 -0400 Subject: [PATCH 09/12] Hard link serve panic fix (#2210) * Fix hard link panic and add better error info to std:fs errors * cargo fmt * Remove erroneously committed config change * Remove console import; Use with context to provide additional error info * improve error wording --- components/utils/src/fs.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/utils/src/fs.rs b/components/utils/src/fs.rs index 8a53df1480..c29edba689 100644 --- a/components/utils/src/fs.rs +++ b/components/utils/src/fs.rs @@ -86,7 +86,12 @@ pub fn copy_file_if_needed(src: &Path, dest: &Path, hard_link: bool) -> Result<( } if hard_link { - std::fs::hard_link(src, dest)? + if dest.exists() { + std::fs::remove_file(dest) + .with_context(|| format!("Error removing file: {:?}", dest))?; + } + std::fs::hard_link(src, dest) + .with_context(|| format!("Error hard linking file, src: {:?}, dst: {:?}", src, dest))?; } else { let src_metadata = metadata(src) .with_context(|| format!("Failed to get metadata of {}", src.display()))?; From b5a90dba5d12ea6760c3aa18fec40f8af4d7cbc7 Mon Sep 17 00:00:00 2001 From: sinofp Date: Sat, 6 May 2023 22:04:33 +0100 Subject: [PATCH 10/12] Add support for lazy loading images (#2211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add optional decoding="async" loading="lazy" for img In theory, they can make the page load faster and show content faster. There’s one problem: CommonMark allows arbitrary inline elements in alt text. If I want to get the correct alt text, I need to match every inline event. I think most people will only use plain text, so I only match Event::Text. * Add very basic test for img This is the reason why we should use plain text when lazy_async_image is enabled. * Explain lazy_async_image in documentation * Add test with empty alt and special characters I totaly forgot one can leave the alt text empty. I thought I need to eliminate the alt attribute in that case, but actually empty alt text is better than not having an alt attribute at all: https://www.w3.org/TR/WCAG20-TECHS/H67.html https://www.boia.org/blog/images-that-dont-need-alternative-text-still-need-alt-attributes Thus I will leave the empty alt text. Another test is added to ensure alt text is properly escaped. I will remove the redundant escaping code after this commit. * Remove manually escaping alt text After removing the if-else inside the arm of Event::Text(text), the alt text is still escaped. Indeed they are redundant. * Use insta for snapshot testing `cargo insta review` looks cool! I wanted to dedup the cases variable, but my Rust skill is not good enough to declare a global vector. --- components/config/src/config/markup.rs | 3 ++ components/markdown/src/markdown.rs | 32 +++++++++++++++--- components/markdown/tests/img.rs | 33 +++++++++++++++++++ ...n_add_lazy_loading_and_async_decoding.snap | 10 ++++++ .../snapshots/img__can_transform_image.snap | 10 ++++++ .../getting-started/configuration.md | 5 +++ 6 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 components/markdown/tests/img.rs create mode 100644 components/markdown/tests/snapshots/img__can_add_lazy_loading_and_async_decoding.snap create mode 100644 components/markdown/tests/snapshots/img__can_transform_image.snap diff --git a/components/config/src/config/markup.rs b/components/config/src/config/markup.rs index 7cd03520c8..b1ac7fa22f 100644 --- a/components/config/src/config/markup.rs +++ b/components/config/src/config/markup.rs @@ -51,6 +51,8 @@ pub struct Markdown { /// The compiled extra themes into a theme set #[serde(skip_serializing, skip_deserializing)] // not a typo, 2 are need pub extra_theme_set: Arc>, + /// Add loading="lazy" decoding="async" to img tags. When turned on, the alt text must be plain text. Defaults to false + pub lazy_async_image: bool, } impl Markdown { @@ -204,6 +206,7 @@ impl Default for Markdown { extra_syntaxes_and_themes: vec![], extra_syntax_set: None, extra_theme_set: Arc::new(None), + lazy_async_image: false, } } } diff --git a/components/markdown/src/markdown.rs b/components/markdown/src/markdown.rs index d773a21f1f..a6cb3beca6 100644 --- a/components/markdown/src/markdown.rs +++ b/components/markdown/src/markdown.rs @@ -252,6 +252,8 @@ pub fn markdown_to_html( let mut stop_next_end_p = false; + let lazy_async_image = context.config.markdown.lazy_async_image; + let mut opts = Options::empty(); let mut has_summary = false; opts.insert(Options::ENABLE_TABLES); @@ -387,13 +389,35 @@ pub fn markdown_to_html( events.push(Event::Html("\n".into())); } Event::Start(Tag::Image(link_type, src, title)) => { - if is_colocated_asset_link(&src) { + let link = if is_colocated_asset_link(&src) { let link = format!("{}{}", context.current_page_permalink, &*src); - events.push(Event::Start(Tag::Image(link_type, link.into(), title))); + link.into() } else { - events.push(Event::Start(Tag::Image(link_type, src, title))); - } + src + }; + + events.push(if lazy_async_image { + let mut img_before_alt: String = "\"").expect("Could events.push(if lazy_async_image { + Event::Html("\" loading=\"lazy\" decoding=\"async\" />".into()) + } else { + event + }), Event::Start(Tag::Link(link_type, link, title)) if link.is_empty() => { error = Some(Error::msg("There is a link that is missing a URL")); events.push(Event::Start(Tag::Link(link_type, "#".into(), title))); diff --git a/components/markdown/tests/img.rs b/components/markdown/tests/img.rs new file mode 100644 index 0000000000..c34713a9d7 --- /dev/null +++ b/components/markdown/tests/img.rs @@ -0,0 +1,33 @@ +mod common; +use config::Config; + +#[test] +fn can_transform_image() { + let cases = vec![ + "![haha](https://example.com/abc.jpg)", + "![](https://example.com/abc.jpg)", + "![ha\"h>a](https://example.com/abc.jpg)", + "![__ha__*ha*](https://example.com/abc.jpg)", + "![ha[ha](https://example.com)](https://example.com/abc.jpg)", + ]; + + let body = common::render(&cases.join("\n")).unwrap().body; + insta::assert_snapshot!(body); +} + +#[test] +fn can_add_lazy_loading_and_async_decoding() { + let cases = vec![ + "![haha](https://example.com/abc.jpg)", + "![](https://example.com/abc.jpg)", + "![ha\"h>a](https://example.com/abc.jpg)", + "![__ha__*ha*](https://example.com/abc.jpg)", + "![ha[ha](https://example.com)](https://example.com/abc.jpg)", + ]; + + let mut config = Config::default_for_test(); + config.markdown.lazy_async_image = true; + + let body = common::render_with_config(&cases.join("\n"), config).unwrap().body; + insta::assert_snapshot!(body); +} diff --git a/components/markdown/tests/snapshots/img__can_add_lazy_loading_and_async_decoding.snap b/components/markdown/tests/snapshots/img__can_add_lazy_loading_and_async_decoding.snap new file mode 100644 index 0000000000..ed179b4043 --- /dev/null +++ b/components/markdown/tests/snapshots/img__can_add_lazy_loading_and_async_decoding.snap @@ -0,0 +1,10 @@ +--- +source: components/markdown/tests/img.rs +expression: body +--- +

haha + +ha"h>a +<strong>ha</strong><em>ha</em> +ha<a href=ha" loading="lazy" decoding="async" />

+ diff --git a/components/markdown/tests/snapshots/img__can_transform_image.snap b/components/markdown/tests/snapshots/img__can_transform_image.snap new file mode 100644 index 0000000000..5ad51f586a --- /dev/null +++ b/components/markdown/tests/snapshots/img__can_transform_image.snap @@ -0,0 +1,10 @@ +--- +source: components/markdown/tests/img.rs +expression: body +--- +

haha + +ha"h>a +haha +haha

+ diff --git a/docs/content/documentation/getting-started/configuration.md b/docs/content/documentation/getting-started/configuration.md index b2ff781f6a..ac85b72426 100644 --- a/docs/content/documentation/getting-started/configuration.md +++ b/docs/content/documentation/getting-started/configuration.md @@ -122,6 +122,11 @@ external_links_no_referrer = false # For example, `...` into `…`, `"quote"` into `“curly”` etc smart_punctuation = false +# Whether to set decoding="async" and loading="lazy" for all images +# When turned on, the alt text must be plain text. +# For example, `![xx](...)` is ok but `![*x*x](...)` isn’t ok +lazy_async_image = false + # Configuration of the link checker. [link_checker] # Skip link checking for external URLs that start with these prefixes From 961d07d535dab8fa58c882c6e30e1db02c5f0a30 Mon Sep 17 00:00:00 2001 From: Christopher Greenwood Date: Fri, 7 Jul 2023 08:41:12 -0700 Subject: [PATCH 11/12] move sass.rs into sass folder this will improve module structure, in preparation for adding some serde code in another sub-module. --- components/site/src/{sass.rs => sass/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename components/site/src/{sass.rs => sass/mod.rs} (100%) diff --git a/components/site/src/sass.rs b/components/site/src/sass/mod.rs similarity index 100% rename from components/site/src/sass.rs rename to components/site/src/sass/mod.rs From 86532cda896000a1af6f5bcf5841eb08c646f5ad Mon Sep 17 00:00:00 2001 From: Christopher Greenwood Date: Fri, 7 Jul 2023 08:38:34 -0700 Subject: [PATCH 12/12] inject site config during Sass compilation it is helpful to be able to use site config during Sass compilation, for example to allow a site owner to set header color in a Sass template. in this change we serialize a subset of the site's config to a `.scss` file as a Sass map literal, allowing config content to be referenced in site Sass or theme Sass. this commit involves bumping the `grass` dependency to version `1.13.0`. we do this so that we can use nested map retrieval in Sass files, such as `map.get($config, extra, background_color)`. --- Cargo.lock | 8 +- components/config/src/config/mod.rs | 11 + components/libs/Cargo.toml | 2 +- components/site/Cargo.toml | 2 +- components/site/src/lib.rs | 4 +- components/site/src/sass/mod.rs | 37 +- components/site/src/sass/serde.rs | 477 +++++++++++++++++++++ docs/content/documentation/content/sass.md | 30 ++ src/cmd/serve.rs | 2 +- test_site/config.toml | 3 + test_site/sass/blog.scss | 5 +- 11 files changed, 563 insertions(+), 18 deletions(-) create mode 100644 components/site/src/sass/serde.rs diff --git a/Cargo.lock b/Cargo.lock index ebbfb6efb8..eadefc16ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1190,18 +1190,18 @@ dependencies = [ [[package]] name = "grass" -version = "0.12.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4bfa010e6f9fe2f40727b4aedf67aa54e0439c57f855458efb1f43d730a028f" +checksum = "bc543656cb7df951b74b696766d878ab92192df27637839ec3056737950265f2" dependencies = [ "grass_compiler", ] [[package]] name = "grass_compiler" -version = "0.12.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe05b48c9c96f5ec64ad9af20c9016a8d57ec8b979e0f6dbdd9747f32b16df3" +checksum = "187adfc0b34289c7f8f3819453ce9da3177c3d73f40ac74bb17faba578813d45" dependencies = [ "codemap", "indexmap", diff --git a/components/config/src/config/mod.rs b/components/config/src/config/mod.rs index 2cda172127..932ab43ef9 100644 --- a/components/config/src/config/mod.rs +++ b/components/config/src/config/mod.rs @@ -112,6 +112,13 @@ pub struct SerializedConfig<'a> { search: search::SerializedSearch<'a>, } +#[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 @@ -335,6 +342,10 @@ impl Config { search: self.search.serialize(), } } + + 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/libs/Cargo.toml b/components/libs/Cargo.toml index 2eb8bf8afa..0028b5a8e4 100644 --- a/components/libs/Cargo.toml +++ b/components/libs/Cargo.toml @@ -27,7 +27,7 @@ rayon = "1" regex = "1" relative-path = "1" reqwest = { version = "0.11", default-features = false, features = ["blocking"] } -grass = {version = "0.12.1", default-features = false, features = ["random"]} +grass = {version = "0.13.0", default-features = false, features = ["random"]} serde_json = "1" serde_yaml = "0.9" sha2 = "0.10" 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 2d26b1545e..33b11712bc 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..0b75de2c1b --- /dev/null +++ b/components/site/src/sass/serde.rs @@ -0,0 +1,477 @@ +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; + + 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/docs/content/documentation/content/sass.md b/docs/content/documentation/content/sass.md index 1c08bbd742..d258f043d3 100644 --- a/docs/content/documentation/content/sass.md +++ b/docs/content/documentation/content/sass.md @@ -43,3 +43,33 @@ Files with the `scss` extension use "Sassy CSS" syntax, while files with the `sass` extension use the "indented" syntax: . Zola will return an error if `scss` and `sass` files with the same base name exist in the same folder to avoid confusion -- see the example above. + +## Site configuration in Sass + +Zola supports referencing a subset of the site's configuration (defined in its `config.toml`) in Sass files. An example where this might be useful is a theme author allowing the site owner to configure theme colors by defining certain keys in their site `config.toml`. At build time, Zola generates a Sass file containing the appropriate config expressed as a [Sass map literal](https://sass-lang.com/documentation/values/maps/) and makes that Sass file available to theme-defined and user-defined Sass files when they are compiled. The config keys that are currently exposed are: + +1. `base_url` +2. `theme` +3. Everything in `extra` + +### Example + +`config.toml` +``` +title = "My Test Site" +base_url = "https://replace-this-with-your-url.com" +compile_sass = true + +[extra.sass] +background_color = "red" +``` + +`style.scss` +``` +@use 'zola'; +@use 'sass:map'; + +body { + background: map.get(zola.$config, extra, sass, background_color); +} +``` \ No newline at end of file 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..277344a406 100644 --- a/test_site/sass/blog.scss +++ b/test_site/sass/blog.scss @@ -1,5 +1,8 @@ +@use 'zola'; +@use 'sass:map'; + body { - background: red; + background: map.get(zola.$config, extra, sass, background_color); .container { background: blue;