diff --git a/CHANGELOG.md b/CHANGELOG.md index 178f897..5b3b7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ - Fix RSS feed link and description - Renamed `Page::url` and `Section::url` to `Page::path` and `Section::path` - Pass `current_url` and `current_path` to every template +- Add id to headers to allow anchor linking +- Make relative link work with anchors +- Add option to render an anchor link automatically next to headers ## 0.0.3 (2017-04-05) - Add some colours in console diff --git a/README.md b/README.md index 61f793a..99f484e 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,11 @@ to link to. The path to the file starts from the `content` directory. For example, linking to a file located at `content/pages/about.md` would be `[my link](./pages/about.md). +### Anchors +Headers get an automatic id from their content in order to be able to add deep links. By default no links are actually created but +the `insert_anchor_links` option in `config.toml` can be set to `true` to link tags. The default template is very ugly and will need +CSS tweaks in your projet to look decent. The default template can also be easily overwritten by creating a `anchor-link.html` file in +the `templates` directory. ### Shortcodes Gutenberg uses markdown for content but sometimes you want to insert some HTML, for example for a YouTube video. diff --git a/src/config.rs b/src/config.rs index 0f9a737..2d8a3bf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,6 +30,10 @@ pub struct Config { pub generate_tags_pages: Option, /// Whether to generate categories and individual tag categories if some pages have them. Defaults to true pub generate_categories_pages: Option, + /// Whether to insert a link for each header like in Github READMEs. Defaults to false + /// The default template can be overridden by creating a `anchor-link.html` template and CSS will need to be + /// written if you turn that on. + pub insert_anchor_links: Option, /// All user params set in [extra] in the config pub extra: Option>, @@ -67,6 +71,7 @@ impl Config { set_default!(config.generate_rss, false); set_default!(config.generate_tags_pages, true); set_default!(config.generate_categories_pages, true); + set_default!(config.insert_anchor_links, false); Ok(config) } @@ -104,6 +109,7 @@ impl Default for Config { generate_rss: Some(false), generate_tags_pages: Some(true), generate_categories_pages: Some(true), + insert_anchor_links: Some(false), extra: None, } } diff --git a/src/markdown.rs b/src/markdown.rs index 0f75694..f432179 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use pulldown_cmark as cmark; use self::cmark::{Parser, Event, Tag}; use regex::Regex; +use slug::slugify; use syntect::dumps::from_binary; use syntect::easy::HighlightLines; use syntect::parsing::SyntaxSet; @@ -114,8 +115,28 @@ pub fn markdown_to_html(content: &str, permalinks: &HashMap, ter let mut added_shortcode = false; // Don't transform things that look like shortcodes in code blocks let mut in_code_block = false; + // If we get text in header, we need to insert the id and a anchor + let mut in_header = false; // the rendered html let mut html = String::new(); + let mut anchors: Vec = vec![]; + + // We might have cases where the slug is already present in our list of anchor + // for example an article could have several titles named Example + // We add a counter after the slug if the slug is already present, which + // means we will have example, example-1, example-2 etc + fn find_anchor(anchors: &Vec, name: String, level: u8) -> String { + if level == 0 && !anchors.contains(&name) { + return name.to_string(); + } + + let new_anchor = format!("{}-{}", name, level + 1); + if !anchors.contains(&new_anchor) { + return new_anchor; + } + + find_anchor(anchors, name, level + 1) + } { let parser = Parser::new(content).map(|event| match event { @@ -177,6 +198,19 @@ pub fn markdown_to_html(content: &str, permalinks: &HashMap, ter } } + if in_header { + let id = find_anchor(&anchors, slugify(&text), 0); + anchors.push(id.clone()); + let anchor_link = if config.insert_anchor_links.unwrap() { + let mut context = Context::new(); + context.add("id", &id); + tera.render("anchor-link.html", &context).unwrap() + } else { + String::new() + }; + return Event::Html(Owned(format!(r#"id="{}">{}{}"#, id, anchor_link, text))); + } + // Business as usual Event::Text(text) }, @@ -207,14 +241,25 @@ pub fn markdown_to_html(content: &str, permalinks: &HashMap, ter // Need to handle relative links Event::Start(Tag::Link(ref link, ref title)) => { if link.starts_with("./") { - let permalink = match permalinks.get(&link.replacen("./", "", 1)) { - Some(p) => p, + // First we remove the ./ since that's gutenberg specific + let clean_link = link.replacen("./", "", 1); + // Then we remove any potential anchor + // parts[0] will be the file path and parts[1] the anchor if present + let parts = clean_link.split('#').collect::>(); + match permalinks.get(parts[0]) { + Some(p) => { + let url = if parts.len() > 1 { + format!("{}#{}", p, parts[1]) + } else { + p.to_string() + }; + return Event::Start(Tag::Link(Owned(url), title.clone())); + }, None => { error = Some(format!("Relative link {} not found.", link).into()); return Event::Html(Owned("".to_string())); } }; - return Event::Start(Tag::Link(Owned(permalink.clone()), title.clone())); } return Event::Start(Tag::Link(link.clone(), title.clone())); @@ -228,6 +273,15 @@ pub fn markdown_to_html(content: &str, permalinks: &HashMap, ter in_code_block = false; event }, + Event::Start(Tag::Header(num)) => { + in_header = true; + // ugly eh + return Event::Html(Owned(format!(" { + in_header = false; + event + }, // If we added shortcodes, don't close a paragraph since there's none Event::End(Tag::Paragraph) => { if added_shortcode { @@ -294,8 +348,8 @@ mod tests { #[test] fn test_markdown_to_html_simple() { - let res = markdown_to_html("# hello", &HashMap::new(), &Tera::default(), &Config::default()).unwrap(); - assert_eq!(res, "

hello

\n"); + let res = markdown_to_html("hello", &HashMap::new(), &Tera::default(), &Config::default()).unwrap(); + assert_eq!(res, "

hello

\n"); } #[test] @@ -410,10 +464,37 @@ A quote ); } + #[test] + fn test_markdown_to_html_relative_links_with_anchors() { + let mut permalinks = HashMap::new(); + permalinks.insert("pages/about.md".to_string(), "https://vincent.is/about".to_string()); + let res = markdown_to_html( + r#"[rel link](./pages/about.md#cv)"#, + &permalinks, + &GUTENBERG_TERA, + &Config::default() + ).unwrap(); + + assert!( + res.contains(r#"

rel link

"#) + ); + } + #[test] fn test_markdown_to_html_relative_link_inexistant() { let res = markdown_to_html("[rel link](./pages/about.md)", &HashMap::new(), &Tera::default(), &Config::default()); assert!(res.is_err()); } + #[test] + fn test_markdown_to_html_add_id_to_headers() { + let res = markdown_to_html(r#"# Hello"#, &HashMap::new(), &GUTENBERG_TERA, &Config::default()).unwrap(); + assert_eq!(res, "

Hello

\n"); + } + + #[test] + fn test_markdown_to_html_add_id_to_headers_same_slug() { + let res = markdown_to_html("# Hello\n# Hello", &HashMap::new(), &GUTENBERG_TERA, &Config::default()).unwrap(); + assert_eq!(res, "

Hello

\n

Hello

\n"); + } } diff --git a/src/site.rs b/src/site.rs index 5946371..5060212 100644 --- a/src/site.rs +++ b/src/site.rs @@ -23,6 +23,7 @@ lazy_static! { ("rss.xml", include_str!("templates/rss.xml")), ("sitemap.xml", include_str!("templates/sitemap.xml")), ("robots.txt", include_str!("templates/robots.txt")), + ("anchor-link.html", include_str!("templates/anchor-link.html")), ("shortcodes/youtube.html", include_str!("templates/shortcodes/youtube.html")), ("shortcodes/vimeo.html", include_str!("templates/shortcodes/vimeo.html")), diff --git a/src/templates/anchor-link.html b/src/templates/anchor-link.html new file mode 100644 index 0000000..e6f480b --- /dev/null +++ b/src/templates/anchor-link.html @@ -0,0 +1,3 @@ + + 🔗 + diff --git a/test_site/content/posts/fixed-slug.md b/test_site/content/posts/fixed-slug.md index 3f1216f..57f322f 100644 --- a/test_site/content/posts/fixed-slug.md +++ b/test_site/content/posts/fixed-slug.md @@ -6,3 +6,5 @@ date = "2017-01-01" +++ A simple page with a slug defined + +# Title diff --git a/tests/site.rs b/tests/site.rs index e4abc9a..cd2500d 100644 --- a/tests/site.rs +++ b/tests/site.rs @@ -264,3 +264,20 @@ fn test_can_build_site_with_tags() { assert!(file_contains!(public, "sitemap.xml", "https://replace-this-with-your-url.com/tags")); assert!(file_contains!(public, "sitemap.xml", "https://replace-this-with-your-url.com/tags/tag-with-space")); } + +#[test] +fn test_can_build_site_and_insert_anchor_links() { + let mut path = env::current_dir().unwrap().to_path_buf(); + path.push("test_site"); + let mut site = Site::new(&path, "config.toml").unwrap(); + site.config.insert_anchor_links = Some(true); + site.load().unwrap(); + let tmp_dir = TempDir::new("example").expect("create temp dir"); + let public = &tmp_dir.path().join("public"); + site.set_output_path(&public); + site.build().unwrap(); + + assert!(Path::new(&public).exists()); + // anchor link inserted + assert!(file_contains!(public, "posts/something-else/index.html", "