diff --git a/Cargo.lock b/Cargo.lock index 99a1839bef..2d34be212c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5763,6 +5763,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" name = "utils" version = "0.23.0" dependencies = [ + "ahash 0.8.12", "errors", "filetime", "fs-err", diff --git a/components/config/src/config/markup.rs b/components/config/src/config/markup.rs index 79a589921b..3549175d03 100644 --- a/components/config/src/config/markup.rs +++ b/components/config/src/config/markup.rs @@ -166,6 +166,8 @@ pub struct Markdown { pub insert_anchor_links: InsertAnchor, /// Whether to enable GitHub-style alerts pub github_alerts: bool, + /// Whether to enable Wikilinks style links, eg [[title]] like Obsidian + pub wikilinks: bool, } impl Markdown { @@ -237,6 +239,7 @@ impl Default for Markdown { lazy_async_image: false, insert_anchor_links: InsertAnchor::None, github_alerts: false, + wikilinks: false, } } } diff --git a/components/markdown/benches/all.rs b/components/markdown/benches/all.rs index 27192b454b..6c79dd186f 100644 --- a/components/markdown/benches/all.rs +++ b/components/markdown/benches/all.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use ahash::AHashMap; use config::{Config, Highlighting}; use criterion::{Criterion, criterion_group, criterion_main}; @@ -73,7 +71,8 @@ The end. fn bench_without_highlighting(c: &mut Criterion) { let mut tera = ZOLA_TERA.clone(); tera.set_fallback_prefixes(vec!["__zola_builtins/".to_string()]); - let permalinks = HashMap::new(); + let permalinks = AHashMap::new(); + let wikilinks = AHashMap::new(); let config = Config::default_for_test(); let colocated_assets = AHashMap::new(); @@ -82,6 +81,7 @@ fn bench_without_highlighting(c: &mut Criterion) { config: &config, permalinks: &permalinks, colocated_assets: &colocated_assets, + wikilinks: &wikilinks, lang: &config.default_language, current_permalink: "https://www.example.com/bench/", current_path: "bench.md", @@ -96,7 +96,8 @@ fn bench_without_highlighting(c: &mut Criterion) { fn bench_with_highlighting(c: &mut Criterion) { let mut tera = ZOLA_TERA.clone(); tera.set_fallback_prefixes(vec!["__zola_builtins/".to_string()]); - let permalinks = HashMap::new(); + let permalinks = AHashMap::new(); + let wikilinks = AHashMap::new(); let mut config = Config::default_for_test(); let mut highlighting = Highlighting { @@ -120,6 +121,7 @@ fn bench_with_highlighting(c: &mut Criterion) { config: &config, permalinks: &permalinks, colocated_assets: &colocated_assets, + wikilinks: &wikilinks, lang: &config.default_language, current_permalink: "https://www.example.com/bench/", current_path: "bench.md", diff --git a/components/markdown/src/context.rs b/components/markdown/src/context.rs index 7ac312fe0c..9ddc0dbc59 100644 --- a/components/markdown/src/context.rs +++ b/components/markdown/src/context.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use ahash::AHashMap; use config::Config; use pulldown_cmark::Options; @@ -10,8 +8,9 @@ use utils::types::InsertAnchor; pub struct MarkdownContext<'a> { pub tera: &'a Tera, pub config: &'a Config, - pub permalinks: &'a HashMap, + pub permalinks: &'a AHashMap, pub colocated_assets: &'a AHashMap, + pub wikilinks: &'a AHashMap, pub lang: &'a str, pub current_permalink: &'a str, pub current_path: &'a str, @@ -26,8 +25,6 @@ impl<'a> MarkdownContext<'a> { opts.insert(Options::ENABLE_STRIKETHROUGH); opts.insert(Options::ENABLE_TASKLISTS); opts.insert(Options::ENABLE_HEADING_ATTRIBUTES); - // TODO: enable it later - // opts.insert(Options::ENABLE_WIKILINKS); if self.config.markdown.smart_punctuation { opts.insert(Options::ENABLE_SMART_PUNCTUATION); @@ -38,6 +35,9 @@ impl<'a> MarkdownContext<'a> { if self.config.markdown.github_alerts { opts.insert(Options::ENABLE_GFM); } + if self.config.markdown.wikilinks { + opts.insert(Options::ENABLE_WIKILINKS); + } opts } } diff --git a/components/markdown/src/markdown.rs b/components/markdown/src/markdown.rs index 3a3e727987..b64ca18927 100644 --- a/components/markdown/src/markdown.rs +++ b/components/markdown/src/markdown.rs @@ -1,6 +1,6 @@ -use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; +use ahash::{AHashMap, AHashSet}; use gh_emoji::Replacer as EmojiReplacer; use giallo::{ExtraHtmlContent, HtmlRenderer, ParsedFence, parse_markdown_fence}; use pulldown_cmark::{CodeBlockKind, CowStr, Event, LinkType, Parser, Tag, TagEnd}; @@ -269,11 +269,11 @@ pub struct State<'a> { footnote: Option, complete_footnotes: Vec, /// name -> (number, count) - footnote_numbers: HashMap, + footnote_numbers: AHashMap, /// All heading IDs we've generated so far, for collision detection. anchors: Vec, /// Explicit heading IDs we've already processed (for collision detection) - seen_explicit_ids: HashSet, + seen_explicit_ids: AHashSet, toc: Vec, /// At which event we've seen summary_index: Option, @@ -416,7 +416,29 @@ impl<'a> State<'a> { return Ok(link.to_string()); } - let result = if link.starts_with("@/") { + let result = if matches!(link_type, LinkType::WikiLink { .. }) { + let (key, anchor) = match link.split_once('#') { + Some((k, a)) => (k, Some(a.to_string())), + None => (link, None), + }; + if let Some(md_path) = ctx.wikilinks.get(key) { + let permalink = &ctx.permalinks[md_path]; + self.internal_links.push((md_path.clone(), anchor.clone())); + match anchor { + Some(a) => format!("{}#{}", permalink, a), + None => permalink.clone(), + } + } else { + let msg = format!("Broken wikilink `[[{}]]` in {}", link, ctx.current_path); + match ctx.config.link_checker.internal_level { + config::LinkCheckerLevel::Error => bail!(msg), + config::LinkCheckerLevel::Warn => { + log::warn!("{msg}"); + link.to_string() + } + } + } + } else if link.starts_with("@/") { if let Some(url) = resolve_colocated_asset(link, ctx) { url } else { @@ -728,13 +750,15 @@ mod tests { fn make_context<'a>( config: &'a Config, tera: &'a tera::Tera, - permalinks: &'a HashMap, + permalinks: &'a AHashMap, + wikilinks: &'a AHashMap, ) -> MarkdownContext<'a> { MarkdownContext { tera, config, permalinks, colocated_assets: &EMPTY_ASSETS, + wikilinks, lang: &config.default_language, current_permalink: "", current_path: "", @@ -762,7 +786,7 @@ mod tests { fn colocated_asset_falls_back_to_default_language() { let config = Config::default(); let tera = ZOLA_TERA.clone(); - let mut permalinks = HashMap::new(); + let mut permalinks = AHashMap::new(); permalinks.insert( "blog/english-only/index.md".to_string(), "https://example.com/blog/english-only/".to_string(), @@ -772,7 +796,8 @@ mod tests { "blog/english-only/img.png".to_string(), ("blog/english-only/index.md".to_string(), "img.png".to_string()), ); - let mut context = make_context(&config, &tera, &permalinks); + let wikilinks = AHashMap::new(); + let mut context = make_context(&config, &tera, &permalinks, &wikilinks); context.colocated_assets = &colocated_assets; context.lang = "fr"; @@ -792,8 +817,9 @@ mod tests { ["", "", "", "", ""]; let config = Config::default(); let tera = ZOLA_TERA.clone(); - let permalinks = HashMap::new(); - let context = make_context(&config, &tera, &permalinks); + let permalinks = AHashMap::new(); + let wikilinks = AHashMap::new(); + let context = make_context(&config, &tera, &permalinks, &wikilinks); for more in mores { let content = format!("{top}\n\n{more}\n\n{bottom}"); let rendered = State::default().render(&content, &context).unwrap(); @@ -814,8 +840,9 @@ mod tests { let mut config = Config::default(); config.markdown.bottom_footnotes = true; let tera = ZOLA_TERA.clone(); - let permalinks = HashMap::new(); - let context = make_context(&config, &tera, &permalinks); + let permalinks = AHashMap::new(); + let wikilinks = AHashMap::new(); + let context = make_context(&config, &tera, &permalinks, &wikilinks); let content = "Some text *without* footnotes.\n\nOnly ~~fancy~~ formatting."; let rendered = State::default().render(content, &context).unwrap(); @@ -827,8 +854,9 @@ mod tests { let mut config = Config::default(); config.markdown.bottom_footnotes = true; let tera = ZOLA_TERA.clone(); - let permalinks = HashMap::new(); - let context = make_context(&config, &tera, &permalinks); + let permalinks = AHashMap::new(); + let wikilinks = AHashMap::new(); + let context = make_context(&config, &tera, &permalinks, &wikilinks); let content = "This text has a footnote[^1]\n [^1]:But it is meaningless."; let rendered = State::default().render(content, &context).unwrap(); @@ -840,8 +868,9 @@ mod tests { let mut config = Config::default(); config.markdown.bottom_footnotes = true; let tera = ZOLA_TERA.clone(); - let permalinks = HashMap::new(); - let context = make_context(&config, &tera, &permalinks); + let permalinks = AHashMap::new(); + let wikilinks = AHashMap::new(); + let context = make_context(&config, &tera, &permalinks, &wikilinks); let content = "This text has two[^2] footnotes[^1]\n[^1]: not sorted.\n[^2]: But they are"; let rendered = State::default().render(content, &context).unwrap(); @@ -853,8 +882,9 @@ mod tests { let mut config = Config::default(); config.markdown.bottom_footnotes = true; let tera = ZOLA_TERA.clone(); - let permalinks = HashMap::new(); - let context = make_context(&config, &tera, &permalinks); + let permalinks = AHashMap::new(); + let wikilinks = AHashMap::new(); + let context = make_context(&config, &tera, &permalinks, &wikilinks); let content = "[^1]:It's before the reference.\n\n There is footnote definition?[^1]"; let rendered = State::default().render(content, &context).unwrap(); @@ -866,8 +896,9 @@ mod tests { let mut config = Config::default(); config.markdown.bottom_footnotes = true; let tera = ZOLA_TERA.clone(); - let permalinks = HashMap::new(); - let context = make_context(&config, &tera, &permalinks); + let permalinks = AHashMap::new(); + let wikilinks = AHashMap::new(); + let context = make_context(&config, &tera, &permalinks, &wikilinks); let content = "This text has two[^1] identical footnotes[^1]\n[^1]: So one is present.\n[^2]: But another in not."; let rendered = State::default().render(content, &context).unwrap(); @@ -879,8 +910,9 @@ mod tests { let mut config = Config::default(); config.markdown.bottom_footnotes = true; let tera = ZOLA_TERA.clone(); - let permalinks = HashMap::new(); - let context = make_context(&config, &tera, &permalinks); + let permalinks = AHashMap::new(); + let wikilinks = AHashMap::new(); + let context = make_context(&config, &tera, &permalinks, &wikilinks); let content = "This text has a footnote[^1]\n[^1]: But the footnote has another footnote[^2].\n[^2]: That's it."; let rendered = State::default().render(content, &context).unwrap(); diff --git a/components/markdown/tests/common.rs b/components/markdown/tests/common.rs index 03d097b5ba..b158d1002d 100644 --- a/components/markdown/tests/common.rs +++ b/components/markdown/tests/common.rs @@ -1,7 +1,5 @@ #![allow(dead_code)] -use std::collections::HashMap; - use ahash::AHashMap; use config::Config; use errors::Result; @@ -16,8 +14,17 @@ fn configurable_render( ) -> Result { let mut tera = ZOLA_TERA.clone(); - let mut permalinks = HashMap::new(); - permalinks.insert("pages/about.md".to_owned(), "https://getzola.org/about/".to_owned()); + let permalinks = AHashMap::from_iter([ + ("pages/about.md".to_owned(), "https://getzola.org/about/".to_owned()), + ("guides/quickstart.md".to_owned(), "https://getzola.org/guides/quickstart/".to_owned()), + ("about.md".to_owned(), "https://getzola.org/about/".to_owned()), + ]); + + let wikilinks = AHashMap::from_iter([ + ("guides/quickstart".to_owned(), "guides/quickstart.md".to_owned()), + ("quickstart".to_owned(), "guides/quickstart.md".to_owned()), + ("about".to_owned(), "about.md".to_owned()), + ]); tera.register_filter( "markdown", @@ -25,6 +32,7 @@ fn configurable_render( config.clone(), permalinks.clone(), AHashMap::new(), + wikilinks.clone(), tera.clone(), ), ); @@ -34,6 +42,7 @@ fn configurable_render( config: &config, permalinks: &permalinks, colocated_assets: &colocated_assets, + wikilinks: &wikilinks, lang: &config.default_language, current_permalink: "https://www.getzola.org/test/", current_path: "my_page.md", diff --git a/components/markdown/tests/markdown.rs b/components/markdown/tests/markdown.rs index 2e608e26cc..31a5ec0bf7 100644 --- a/components/markdown/tests/markdown.rs +++ b/components/markdown/tests/markdown.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use ahash::AHashMap; use config::Config; use markdown::{MarkdownContext, render_content}; @@ -109,14 +107,16 @@ fn can_insert_anchors() { fn can_customise_anchor_template() { let mut tera = ZOLA_TERA.clone(); tera.add_raw_template("anchor-link.html", " (in {{ lang }})").unwrap(); - let permalinks_ctx = HashMap::new(); + let permalinks_ctx = AHashMap::new(); let config = Config::default_for_test(); let colocated_assets = AHashMap::new(); + let wikilinks_ctx = AHashMap::new(); let context = MarkdownContext { tera: &tera, config: &config, permalinks: &permalinks_ctx, colocated_assets: &colocated_assets, + wikilinks: &wikilinks_ctx, lang: &config.default_language, current_permalink: "", current_path: "", @@ -130,7 +130,8 @@ fn can_customise_anchor_template() { fn can_customise_summary_template() { let mut tera = ZOLA_TERA.clone(); tera.add_raw_template("summary-cutoff.html", " (in {{ lang }})").unwrap(); - let permalinks_ctx = HashMap::new(); + let permalinks_ctx = AHashMap::new(); + let wikilinks_ctx = AHashMap::new(); let config = Config::default_for_test(); let colocated_assets = AHashMap::new(); let context = MarkdownContext { @@ -138,6 +139,7 @@ fn can_customise_summary_template() { config: &config, permalinks: &permalinks_ctx, colocated_assets: &colocated_assets, + wikilinks: &wikilinks_ctx, lang: &config.default_language, current_permalink: "", current_path: "", @@ -486,3 +488,36 @@ fn github_alerts() { let body = common::render_with_config(&markdown, config).unwrap().body; insta::assert_snapshot!(body); } + +#[test] +fn wikilink_resolution() { + let mut config = Config::default_for_test(); + config.markdown.wikilinks = true; + + let cases = vec![ + "[[quickstart]]", + "[[guides/quickstart]]", + "[[quickstart#install]]", + "[[quickstart|Get Started]]", + ]; + + let res = common::render_with_config(&cases.join("\n"), config).unwrap(); + insta::assert_snapshot!(res.body); + assert!(res.internal_links.contains(&("guides/quickstart.md".into(), Some("install".into())))); +} + +#[test] +fn wikilink_nonexistent() { + // warn level: renders with raw link + let mut config = Config::default_for_test(); + config.markdown.wikilinks = true; + config.link_checker.internal_level = config::LinkCheckerLevel::Warn; + let body = common::render_with_config("[[nope]]", config).unwrap().body; + assert!(body.contains("href=\"nope\""), "body was: {body}"); + + // error level: returns Err + let mut config = Config::default_for_test(); + config.markdown.wikilinks = true; + config.link_checker.internal_level = config::LinkCheckerLevel::Error; + assert!(common::render_with_config("[[nope]]", config).is_err()); +} diff --git a/components/markdown/tests/snapshots/markdown__wikilink_resolution.snap b/components/markdown/tests/snapshots/markdown__wikilink_resolution.snap new file mode 100644 index 0000000000..d7c6df64ac --- /dev/null +++ b/components/markdown/tests/snapshots/markdown__wikilink_resolution.snap @@ -0,0 +1,8 @@ +--- +source: components/markdown/tests/markdown.rs +expression: res.body +--- +

quickstart +guides/quickstart +quickstart#install +Get Started

diff --git a/components/site/Cargo.toml b/components/site/Cargo.toml index 729d9b0a37..2876af6dfc 100644 --- a/components/site/Cargo.toml +++ b/components/site/Cargo.toml @@ -17,6 +17,7 @@ relative-path = { workspace = true } tera = { workspace = true } url = { workspace = true } walkdir = { workspace = true } +ahash = { workspace = true } errors = { workspace = true } config = { workspace = true } @@ -29,7 +30,6 @@ content = { workspace = true } render = { workspace = true } markdown = { workspace = true } memchr = { workspace = true } -ahash = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/components/site/src/lib.rs b/components/site/src/lib.rs index b1ae794102..f730a3681a 100644 --- a/components/site/src/lib.rs +++ b/components/site/src/lib.rs @@ -6,19 +6,21 @@ mod queue; pub mod sass; pub mod sitemap; pub mod tpls; +mod wikilinks; -use std::collections::{HashMap, HashSet}; use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use std::sync::{Arc, Mutex, RwLock}; use std::time::Instant; +use ahash::{AHashMap, AHashSet}; use rayon::prelude::*; use tera::Tera; use walkdir::{DirEntry, WalkDir}; use crate::queue::Queue; +use crate::wikilinks::build_wikilinks; use config::{Config, IndexFormat, get_config}; use content::{Library, Page, Section, Taxonomy}; use errors::{Result, anyhow, bail}; @@ -31,8 +33,8 @@ use utils::fs::{ use utils::net::get_available_port; use utils::types::InsertAnchor; -pub static SITE_CONTENT: LazyLock>>> = - LazyLock::new(|| Arc::new(RwLock::new(HashMap::new()))); +pub static SITE_CONTENT: LazyLock>>> = + LazyLock::new(|| Arc::new(RwLock::new(AHashMap::new()))); /// Where are we building the site #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -63,7 +65,11 @@ pub struct Site { pub taxonomies: Vec, /// A map of all .md files (section and pages) and their permalink /// We need that if there are relative links in the content that need to be resolved - pub permalinks: HashMap, + pub permalinks: AHashMap, + /// A map of filename stems (with the full relative path and also without when possible) + /// to relative path for wikilink resolution + /// Built from the permalinks field + pub wikilinks: AHashMap, /// Contains all pages and sections of the site pub library: Arc, /// Pre-serialized render cache @@ -109,7 +115,8 @@ impl Site { static_path, templates_path, taxonomies: Vec::new(), - permalinks: HashMap::new(), + permalinks: AHashMap::new(), + wikilinks: AHashMap::new(), include_drafts: false, // We will allocate it properly later on library: Arc::new(Library::default()), @@ -193,7 +200,7 @@ impl Site { // so it's kinda necessary let mut dir_walker = WalkDir::new(self.base_path.join("content")).follow_links(true).into_iter(); - let mut allowed_index_filenames: HashSet<_> = self + let mut allowed_index_filenames: AHashSet<_> = self .config .other_languages() .keys() @@ -205,7 +212,7 @@ impl Site { // at the end to detect pages that are actually errors: // when there is both a _index.md and index.md in the same folder let mut page_paths = Vec::new(); - let mut sections = HashSet::new(); + let mut sections = AHashSet::new(); loop { let entry: DirEntry = match dir_walker.next() { @@ -343,6 +350,7 @@ impl Site { // taxonomy Tera fns are loaded in `register_early_global_fns` // so we do need to populate it first. self.populate_taxonomies()?; + self.build_wikilinks(); tpls::register_early_global_fns(self); self.render_markdown()?; Arc::make_mut(&mut self.library).fill_backlinks(); @@ -459,12 +467,13 @@ impl Site { // Another silly thing needed to not borrow &self in parallel and // make the borrow checker happy let permalinks = &self.permalinks; + let wikilinks = &self.wikilinks; let tera = &self.tera; let config = &self.config; let colocated_assets = self.library.colocated_assets.clone(); // This is needed in the first place because of silly borrow checker - let mut pages_insert_anchors = HashMap::new(); + let mut pages_insert_anchors = AHashMap::new(); for (_, p) in &self.library.pages { pages_insert_anchors.insert( p.file.path.clone(), @@ -489,6 +498,7 @@ impl Site { renderer.clone(), permalinks, &colocated_assets, + wikilinks, tera, config, insert_anchor, @@ -507,6 +517,7 @@ impl Site { renderer.clone(), permalinks, &colocated_assets, + wikilinks, tera, config, ) @@ -517,7 +528,7 @@ impl Site { } /// Add a page to the site - /// The `render` parameter is used in the serve command with --fast, when rebuilding a page. + /// The `render_md` parameter is used in the serve command with --fast, when rebuilding a page. pub fn add_page(&mut self, mut page: Page, render_md: bool) -> Result<()> { for taxa_name in page.meta.taxonomies.keys() { if !self.config.has_taxonomy(taxa_name, &page.lang) { @@ -538,6 +549,7 @@ impl Site { self.renderer(), &self.permalinks, &self.library.colocated_assets, + &self.wikilinks, &self.tera, &self.config, insert_anchor, @@ -561,7 +573,7 @@ impl Site { } /// Add a section to the site - /// The `render` parameter is used in the serve command with --fast, when rebuilding a page. + /// The `render_md` parameter is used in the serve command with --fast, when rebuilding a page. pub fn add_section(&mut self, mut section: Section, render_md: bool) -> Result<()> { self.permalinks.insert(section.file.relative.clone(), section.permalink.clone()); if render_md { @@ -570,6 +582,7 @@ impl Site { self.renderer(), &self.permalinks, &self.library.colocated_assets, + &self.wikilinks, &self.tera, &self.config, )?; @@ -619,6 +632,14 @@ impl Site { library.populate_sections(&self.config, &self.content_path); } + fn build_wikilinks(&mut self) { + if self.config.markdown.wikilinks { + self.wikilinks = build_wikilinks(&self.permalinks); + } else { + self.wikilinks.clear(); + } + } + /// Find all the tags and categories if it's asked in the config pub fn populate_taxonomies(&mut self) -> Result<()> { self.taxonomies = self.library.find_taxonomies(&self.config); diff --git a/components/site/src/md_render.rs b/components/site/src/md_render.rs index 082660b2d6..3a27f84d78 100644 --- a/components/site/src/md_render.rs +++ b/components/site/src/md_render.rs @@ -1,7 +1,6 @@ //! This is here to avoid content depending on the markdown subcrate use std::borrow::Cow; -use std::collections::HashMap; use ahash::AHashMap; use tera::Tera; @@ -26,8 +25,9 @@ fn needs_templating(s: &str) -> bool { pub fn render_page( page: &mut Page, renderer: Renderer, - permalinks: &HashMap, + permalinks: &AHashMap, colocated_assets: &AHashMap, + wikilinks: &AHashMap, tera: &Tera, config: &Config, insert_anchor: InsertAnchor, @@ -48,6 +48,7 @@ pub fn render_page( config, permalinks, colocated_assets, + wikilinks, lang: &page.lang, current_permalink: &page.permalink, current_path: &page.file.relative, @@ -68,8 +69,9 @@ pub fn render_page( pub fn render_section( section: &mut Section, renderer: Renderer, - permalinks: &HashMap, + permalinks: &AHashMap, colocated_assets: &AHashMap, + wikilinks: &AHashMap, tera: &Tera, config: &Config, ) -> Result<()> { @@ -88,6 +90,7 @@ pub fn render_section( config, permalinks, colocated_assets, + wikilinks, lang: §ion.lang, current_permalink: §ion.permalink, current_path: §ion.file.relative, @@ -114,15 +117,12 @@ pub fn render_section( #[cfg(test)] mod tests { - use std::collections::HashMap; - use std::path::Path; - use std::path::PathBuf; - use ahash::AHashMap; - use config::Config; use content::{Library, Page}; use render::{RenderCache, Renderer}; + use std::path::Path; + use std::path::PathBuf; use templates::ZOLA_TERA; use utils::types::InsertAnchor; @@ -154,7 +154,8 @@ Hello world render_page( &mut page, renderer, - &HashMap::default(), + &AHashMap::default(), + &AHashMap::default(), &AHashMap::default(), &ZOLA_TERA, &config, @@ -193,7 +194,8 @@ And here's another. [^3] render_page( &mut page, renderer, - &HashMap::default(), + &AHashMap::default(), + &AHashMap::default(), &AHashMap::default(), &ZOLA_TERA, &config, @@ -230,7 +232,8 @@ And here's another. [^3] render_page( &mut page, renderer, - &HashMap::default(), + &AHashMap::default(), + &AHashMap::default(), &AHashMap::default(), &ZOLA_TERA, &config, diff --git a/components/site/src/tpls.rs b/components/site/src/tpls.rs index a56aef884e..14f742fb01 100644 --- a/components/site/src/tpls.rs +++ b/components/site/src/tpls.rs @@ -61,10 +61,10 @@ pub fn register_early_global_fns(site: &mut Site) { site.config.clone(), site.permalinks.clone(), site.library.colocated_assets.clone(), + site.wikilinks.clone(), site.tera.clone(), ), ); - register_tera_global_fns(site); } diff --git a/components/site/src/wikilinks.rs b/components/site/src/wikilinks.rs new file mode 100644 index 0000000000..28a7ca85b6 --- /dev/null +++ b/components/site/src/wikilinks.rs @@ -0,0 +1,74 @@ +use std::path::Path; + +use ahash::AHashMap; + +/// Build a lookup map from permalinks for wikilink resolution. +/// +/// For each entry in `permalinks` (relative_path -> permalink), we insert 2 things pointing to the full relative path.: +/// 1. Full path without extension (eg `docs/overview`) +/// 2. Bare stem (eg `overview`) if different from full path +/// +/// If a stem is the same as the full path, the stem is ignored +/// If a stem collides (multiple pages share it, eg _index in Zola), it won't be inserted and users +/// can't refer to that stem in links. +pub fn build_wikilinks(permalinks: &AHashMap) -> AHashMap { + let mut wikilinks = AHashMap::new(); + let mut stems: AHashMap> = AHashMap::new(); + + for relative_path in permalinks.keys() { + let without_ext = relative_path.trim_end_matches(".md"); + wikilinks.insert(without_ext.to_owned(), relative_path.clone()); + + let stem = + Path::new(without_ext).file_name().unwrap_or_default().to_string_lossy().into_owned(); + if stem != without_ext { + stems.entry(stem).or_default().push(relative_path); + } + } + + for (stem, md_paths) in &stems { + // Don't overwrite a full-path entry with a bare stem + if wikilinks.contains_key(stem) { + continue; + } + if md_paths.len() == 1 { + wikilinks.insert(stem.clone(), md_paths[0].to_owned()); + } else { + log::warn!("Multiple files with the name `{stem}`, use the full path to link to them"); + } + } + + wikilinks +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_wikilinks_lookups() { + let permalinks = AHashMap::from_iter([ + ("blog/overview.md".to_string(), "/blog/overview/".to_string()), + ("docs/overview.md".to_string(), "/docs/overview/".to_string()), + ("about.md".to_string(), "/about/".to_string()), + ("blog/_index.md".to_string(), "/blog/".to_string()), + ("_index.md".to_string(), "/".to_string()), + ("guides/quickstart.md".to_string(), "/guides/quickstart/".to_string()), + ]); + let wl = build_wikilinks(&permalinks); + + // Full paths always resolve + assert_eq!(wl.get("blog/overview"), Some(&"blog/overview.md".to_string())); + assert_eq!(wl.get("docs/overview"), Some(&"docs/overview.md".to_string())); + assert_eq!(wl.get("about"), Some(&"about.md".to_string())); + assert_eq!(wl.get("blog/_index"), Some(&"blog/_index.md".to_string())); + assert_eq!(wl.get("_index"), Some(&"_index.md".to_string())); + assert_eq!(wl.get("guides/quickstart"), Some(&"guides/quickstart.md".to_string())); + assert_eq!(wl.get("quickstart"), Some(&"guides/quickstart.md".to_string())); + assert_eq!(wl.get("overview"), None); + // not blog/_index.md, relative path has precedence over stem + assert_eq!(wl.get("_index"), Some(&"_index.md".to_string())); + // Relative path and stem being equal should only be inserted once + assert_eq!(wl.values().filter(|v| *v == "about.md").count(), 1); + } +} diff --git a/components/templates/src/filters/markdown.rs b/components/templates/src/filters/markdown.rs index f524cc298e..c8be406c60 100644 --- a/components/templates/src/filters/markdown.rs +++ b/components/templates/src/filters/markdown.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use ahash::AHashMap; use config::Config; use markdown::{MarkdownContext, render_content}; @@ -9,19 +7,21 @@ use utils::types::InsertAnchor; #[derive(Debug, Default)] pub struct MarkdownFilter { config: Config, - permalinks: HashMap, + permalinks: AHashMap, colocated_assets: AHashMap, + wikilinks: AHashMap, tera: tera::Tera, } impl MarkdownFilter { pub fn new( config: Config, - permalinks: HashMap, + permalinks: AHashMap, colocated_assets: AHashMap, + wikilinks: AHashMap, tera: tera::Tera, ) -> Self { - Self { config, permalinks, colocated_assets, tera } + Self { config, permalinks, colocated_assets, wikilinks, tera } } } @@ -63,6 +63,7 @@ impl Filter<&str, TeraResult> for MarkdownFilter { config: &self.config, permalinks: &self.permalinks, colocated_assets: &self.colocated_assets, + wikilinks: &self.wikilinks, lang: &lang, current_permalink: ¤t_permalink, current_path: ¤t_path, @@ -87,8 +88,6 @@ impl Filter<&str, TeraResult> for MarkdownFilter { #[cfg(test)] mod tests { - use std::collections::HashMap; - use ahash::AHashMap; use config::{Config, HighlightStyle, Highlighting, Registry}; use giallo::DataAttrPosition; @@ -111,7 +110,8 @@ mod tests { let result = MarkdownFilter::new( Config::default(), - HashMap::new(), + AHashMap::new(), + AHashMap::new(), AHashMap::new(), tera::Tera::default(), ) @@ -128,7 +128,8 @@ mod tests { let result = MarkdownFilter::new( Config::default(), - HashMap::new(), + AHashMap::new(), + AHashMap::new(), AHashMap::new(), tera::Tera::default(), ) @@ -149,7 +150,8 @@ mod tests { let result = MarkdownFilter::new( Config::default(), - HashMap::new(), + AHashMap::new(), + AHashMap::new(), AHashMap::new(), tera::Tera::default(), ) @@ -193,7 +195,8 @@ mod tests { let kwargs = Kwargs::from([]); let result = MarkdownFilter::new( config.clone(), - HashMap::new(), + AHashMap::new(), + AHashMap::new(), AHashMap::new(), tera::Tera::default(), ) @@ -206,16 +209,21 @@ mod tests { let md = "```py\ni=0\n```"; let kwargs = Kwargs::from([]); - let result = - MarkdownFilter::new(config, HashMap::new(), AHashMap::new(), tera::Tera::default()) - .call(md, kwargs, &state); + let result = MarkdownFilter::new( + config, + AHashMap::new(), + AHashMap::new(), + AHashMap::new(), + tera::Tera::default(), + ) + .call(md, kwargs, &state); assert!(result.is_ok()); assert!(result.unwrap().contains("style")); } #[test] fn markdown_filter_can_use_internal_links() { - let mut permalinks = HashMap::new(); + let mut permalinks = AHashMap::new(); permalinks.insert("blog/_index.md".to_string(), "/foo/blog/".to_string()); let mut colocated_assets = AHashMap::new(); colocated_assets.insert( @@ -232,6 +240,7 @@ mod tests { Config::default(), permalinks, colocated_assets.clone(), + AHashMap::new(), tera::Tera::default(), ) .call(md, kwargs, &state); diff --git a/components/templates/src/functions/files.rs b/components/templates/src/functions/files.rs index bc5b3f7464..1aa1e40a07 100644 --- a/components/templates/src/functions/files.rs +++ b/components/templates/src/functions/files.rs @@ -1,11 +1,10 @@ -use ahash::AHashMap; -use fs_err as fs; -use std::collections::HashMap; use std::fmt::Write as _; use std::io::Read; use std::path::PathBuf; +use ahash::AHashMap; use base64::engine::{Engine, general_purpose::STANDARD as standard_b64}; +use fs_err as fs; use sha2::{Sha256, Sha384, Sha512, digest}; use config::Config; @@ -38,7 +37,7 @@ where pub struct GetUrl { base_path: PathBuf, config: Config, - permalinks: HashMap, + permalinks: AHashMap, output_path: PathBuf, colocated_assets: AHashMap, } @@ -47,7 +46,7 @@ impl GetUrl { pub fn new( base_path: PathBuf, config: Config, - permalinks: HashMap, + permalinks: AHashMap, output_path: PathBuf, colocated_assets: AHashMap, ) -> Self { @@ -241,7 +240,6 @@ mod tests { use ahash::AHashMap; use fs_err as fs; - use std::collections::HashMap; use std::path::PathBuf; use tempfile::{TempDir, tempdir}; use tera::{Context, Function, Kwargs, State}; @@ -273,7 +271,7 @@ title = "A title" let get_url = GetUrl::new( dir.path().to_path_buf(), Config::default(), - HashMap::new(), + AHashMap::new(), PathBuf::new(), AHashMap::new(), ); @@ -307,7 +305,7 @@ title = "A title" let get_url = GetUrl::new( dir.path().to_path_buf(), Config::default(), - HashMap::new(), + AHashMap::new(), PathBuf::new(), AHashMap::new(), ); @@ -329,7 +327,7 @@ title = "A title" let get_url = GetUrl::new( dir.path().to_path_buf(), Config::default(), - HashMap::new(), + AHashMap::new(), PathBuf::new(), AHashMap::new(), ); @@ -352,7 +350,7 @@ title = "A title" let get_url = GetUrl::new( dir.path().to_path_buf(), Config::default(), - HashMap::new(), + AHashMap::new(), PathBuf::new(), AHashMap::new(), ); @@ -383,7 +381,7 @@ title = "A title" let get_url = GetUrl::new( dir.path().to_path_buf(), Config::default(), - HashMap::new(), + AHashMap::new(), public, AHashMap::new(), ); @@ -403,7 +401,7 @@ title = "A title" let get_url = GetUrl::new( dir.path().to_path_buf(), config, - HashMap::new(), + AHashMap::new(), PathBuf::new(), AHashMap::new(), ); @@ -419,7 +417,7 @@ title = "A title" #[test] fn can_get_url_with_default_language() { - let mut permalinks = HashMap::new(); + let mut permalinks = AHashMap::new(); permalinks.insert( "a_section/a_page.md".to_string(), "https://remplace-par-ton-url.fr/a_section/a_page/".to_string(), @@ -452,7 +450,7 @@ title = "A title" #[test] fn can_get_url_with_other_language() { let config = Config::parse(CONFIG_DATA).unwrap(); - let mut permalinks = HashMap::new(); + let mut permalinks = AHashMap::new(); permalinks.insert( "a_section/a_page.md".to_string(), "https://remplace-par-ton-url.fr/a_section/a_page/".to_string(), @@ -486,7 +484,7 @@ title = "A title" #[test] fn can_get_colocated_asset_url() { let config = Config::parse(CONFIG_DATA).unwrap(); - let mut permalinks = HashMap::new(); + let mut permalinks = AHashMap::new(); permalinks.insert( "a_section/an_article/index.md".to_string(), "https://remplace-par-ton-url.fr/a_section/mon_article/".to_string(), @@ -534,7 +532,7 @@ title = "A title" #[test] fn does_not_duplicate_lang() { let config = Config::parse(CONFIG_DATA).unwrap(); - let mut permalinks = HashMap::new(); + let mut permalinks = AHashMap::new(); permalinks.insert( "a_section/a_page.md".to_string(), "https://remplace-par-ton-url.fr/a_section/a_page/".to_string(), @@ -570,7 +568,7 @@ title = "A title" let get_url = GetUrl::new( dir.path().to_path_buf(), config.clone(), - HashMap::new(), + AHashMap::new(), PathBuf::new(), AHashMap::new(), ); @@ -595,7 +593,7 @@ title = "A title" let get_url = GetUrl::new( dir.path().to_path_buf(), config.clone(), - HashMap::new(), + AHashMap::new(), PathBuf::new(), AHashMap::new(), ); @@ -812,7 +810,7 @@ title = "A title" let get_url = GetUrl::new( dir.path().to_path_buf(), config, - HashMap::new(), + AHashMap::new(), PathBuf::new(), AHashMap::new(), ); diff --git a/components/utils/Cargo.toml b/components/utils/Cargo.toml index 4aba4e3b0e..5b6dd7e099 100644 --- a/components/utils/Cargo.toml +++ b/components/utils/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true include = ["src/**/*"] [dependencies] +ahash = { workspace = true } serde = { workspace = true } filetime = { workspace = true } fs-err = { workspace = true } diff --git a/components/utils/src/site.rs b/components/utils/src/site.rs index ef3c1e6da9..c405e9f8a8 100644 --- a/components/utils/src/site.rs +++ b/components/utils/src/site.rs @@ -1,5 +1,5 @@ +use ahash::AHashMap; use percent_encoding::percent_decode; -use std::collections::HashMap; use errors::{Result, anyhow}; @@ -19,7 +19,7 @@ pub struct ResolvedInternalLink { /// returns the path + anchor as well pub fn resolve_internal_link( link: &str, - permalinks: &HashMap, + permalinks: &AHashMap, ) -> Result { // First we remove the @/ since that's zola specific let clean_link = link.replacen("@/", "", 1); @@ -44,13 +44,13 @@ pub fn resolve_internal_link( #[cfg(test)] mod tests { - use std::collections::HashMap; + use super::*; use super::resolve_internal_link; #[test] fn can_resolve_valid_internal_link() { - let mut permalinks = HashMap::new(); + let mut permalinks = AHashMap::new(); permalinks.insert("pages/about.md".to_string(), "https://vincent.is/about".to_string()); let res = resolve_internal_link("@/pages/about.md", &permalinks).unwrap(); assert_eq!(res.permalink, "https://vincent.is/about"); @@ -58,7 +58,7 @@ mod tests { #[test] fn can_resolve_valid_root_internal_link() { - let mut permalinks = HashMap::new(); + let mut permalinks = AHashMap::new(); permalinks.insert("about.md".to_string(), "https://vincent.is/about".to_string()); let res = resolve_internal_link("@/about.md", &permalinks).unwrap(); assert_eq!(res.permalink, "https://vincent.is/about"); @@ -66,7 +66,7 @@ mod tests { #[test] fn can_resolve_internal_links_with_anchors() { - let mut permalinks = HashMap::new(); + let mut permalinks = AHashMap::new(); permalinks.insert("pages/about.md".to_string(), "https://vincent.is/about".to_string()); let res = resolve_internal_link("@/pages/about.md#hello", &permalinks).unwrap(); assert_eq!(res.permalink, "https://vincent.is/about#hello"); @@ -76,7 +76,7 @@ mod tests { #[test] fn can_resolve_escaped_internal_links() { - let mut permalinks = HashMap::new(); + let mut permalinks = AHashMap::new(); permalinks.insert( "pages/about space.md".to_string(), "https://vincent.is/about%20space/".to_string(), @@ -89,7 +89,7 @@ mod tests { #[test] fn errors_resolve_inexistent_internal_link() { - let res = resolve_internal_link("@/pages/about.md#hello", &HashMap::new()); + let res = resolve_internal_link("@/pages/about.md#hello", &AHashMap::new()); assert!(res.is_err()); } }