From 603340726642610a5ffd7c46d6d86d8916f4e0f4 Mon Sep 17 00:00:00 2001 From: Nick Groenen Date: Mon, 4 Jan 2021 21:17:46 +0100 Subject: [PATCH] new: support links referencing headings Previously, links referencing a heading (`[[note#heading]]`) would just link to the file name without including an anchor in the link target. Now, such references will include an appropriate `#anchor` attribute. Note that neither the original Markdown specification, nor the more recent CommonMark standard, specify how anchors should be constructed for a given heading. There are also some differences between the various Markdown rendering implementations. Obsidian-export uses the [slug] crate to generate anchors which should be compatible with most implementations, however your mileage may vary. (For example, GitHub may leave a trailing `-` on anchors when headings end with a smiley. The slug library, and thus obsidian-export, will avoid such dangling dashes). [slug]: https://crates.io/crates/slug --- Cargo.lock | 16 +++++ Cargo.toml | 1 + src/lib.rs | 68 ++++++++++++------- .../main-samples/obsidian-wikilinks.md | 4 ++ .../input/main-samples/obsidian-wikilinks.md | 4 ++ 5 files changed, 70 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 982181b..ce8efa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,12 @@ dependencies = [ "syn", ] +[[package]] +name = "deunicode" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850878694b7933ca4c9569d30a34b55031b9b139ee1fc7b94a527c4ef960d690" + [[package]] name = "difference" version = "2.0.0" @@ -305,6 +311,7 @@ dependencies = [ "pulldown-cmark-to-cmark", "rayon", "regex", + "slug", "snafu", "tempfile", "walkdir", @@ -508,6 +515,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "slug" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bc762e6a4b6c6fcaade73e77f9ebc6991b676f88bb2358bddb56560f073373" +dependencies = [ + "deunicode", +] + [[package]] name = "snafu" version = "0.6.10" diff --git a/Cargo.toml b/Cargo.toml index 0ff3dd0..b6fbe85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ pulldown-cmark = "0.8.0" pulldown-cmark-to-cmark = "6.0.0" rayon = "1.5.0" regex = "1.4.2" +slug = "0.1.4" snafu = "0.6.10" [dev-dependencies] diff --git a/src/lib.rs b/src/lib.rs index 8bf2202..08a6d6b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,8 +11,10 @@ use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag}; use pulldown_cmark_to_cmark::cmark_with_options; use rayon::prelude::*; use regex::Regex; +use slug::slugify; use snafu::{ResultExt, Snafu}; use std::ffi::OsString; +use std::fmt; use std::fs::{self, File}; use std::io::prelude::*; use std::io::ErrorKind; @@ -196,6 +198,24 @@ impl<'a> ObsidianNoteReference<'a> { section, } } + + fn display(&self) -> String { + format!("{}", self) + } +} + +impl<'a> fmt::Display for ObsidianNoteReference<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = self + .label + .map(|v| v.to_string()) + .unwrap_or_else(|| match self.section { + Some(section) => format!("{} > {}", self.file, section), + None => self.file.to_string(), + }) + .to_string(); + write!(f, "{}", label) + } } impl<'a> Exporter<'a> { @@ -384,9 +404,11 @@ impl<'a> Exporter<'a> { if let Event::Text(CowStr::Borrowed(text)) = buffer[2] { match buffer[0] { Event::Text(CowStr::Borrowed("[")) => { - let mut link_events = - self.obsidian_note_link_to_markdown(&text, context); - tree.append(&mut link_events); + let mut elements = self.make_link_to_file( + ObsidianNoteReference::from_str(&text), + context, + ); + tree.append(&mut elements); buffer.clear(); continue; } @@ -445,7 +467,7 @@ impl<'a> Exporter<'a> { tree } Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("webp") => { - self.make_link_to_file(¬e_ref.file, ¬e_ref.file, &context) + self.make_link_to_file(note_ref, &context) .into_iter() .map(|event| match event { // make_link_to_file returns a link to a file. With this we turn the link @@ -470,34 +492,28 @@ impl<'a> Exporter<'a> { }) .collect() } - _ => self.make_link_to_file(¬e_ref.file, ¬e_ref.file, &context), + _ => self.make_link_to_file(note_ref, &context), }; Ok(tree) } - fn obsidian_note_link_to_markdown(&self, content: &'a str, context: &Context) -> MarkdownTree { - let note_ref = ObsidianNoteReference::from_str(content); - let label = note_ref.label.unwrap_or(note_ref.file); - self.make_link_to_file(note_ref.file, label, context) - } - fn make_link_to_file<'b>( &self, - file: &'b str, - label: &'b str, + reference: ObsidianNoteReference<'b>, context: &Context, ) -> MarkdownTree<'b> { - let target_file = lookup_filename_in_vault(file, &self.vault_contents.as_ref().unwrap()); + let target_file = + lookup_filename_in_vault(reference.file, &self.vault_contents.as_ref().unwrap()); if target_file.is_none() { // TODO: Extract into configurable function. println!( "Warning: Unable to find referenced note\n\tReference: '{}'\n\tSource: '{}'\n", - file, + reference.file, context.current_file().display(), ); return vec![ Event::Start(Tag::Emphasis), - Event::Text(CowStr::from(String::from(label))), + Event::Text(CowStr::from(reference.display())), Event::End(Tag::Emphasis), ]; } @@ -513,19 +529,25 @@ impl<'a> Exporter<'a> { .expect("obsidian content files should always have a parent"), ) .expect("should be able to build relative path when target file is found in vault"); - let rel_link = rel_link.to_string_lossy(); - let encoded_link = utf8_percent_encode(&rel_link, PERCENTENCODE_CHARS); - let link = pulldown_cmark::Tag::Link( + let rel_link = rel_link.to_string_lossy(); + let mut link = utf8_percent_encode(&rel_link, PERCENTENCODE_CHARS).to_string(); + + if let Some(section) = reference.section { + link.push('#'); + link.push_str(&slugify(section)); + } + + let link_tag = pulldown_cmark::Tag::Link( pulldown_cmark::LinkType::Inline, - CowStr::from(encoded_link.to_string()), + CowStr::from(link.to_string()), CowStr::from(""), ); vec![ - Event::Start(link.clone()), - Event::Text(CowStr::from(label)), - Event::End(link.clone()), + Event::Start(link_tag.clone()), + Event::Text(CowStr::from(reference.display())), + Event::End(link_tag.clone()), ] } } diff --git a/tests/testdata/expected/main-samples/obsidian-wikilinks.md b/tests/testdata/expected/main-samples/obsidian-wikilinks.md index e575862..a77822d 100644 --- a/tests/testdata/expected/main-samples/obsidian-wikilinks.md +++ b/tests/testdata/expected/main-samples/obsidian-wikilinks.md @@ -2,6 +2,10 @@ Link to [pure-markdown-examples](pure-markdown-examples.md) and the same [Pure-M Link to [pure markdown examples](pure-markdown-examples.md). +Link to [pure-markdown-examples > Heading 1](pure-markdown-examples.md#heading-1). + +Link to [pure markdown examples](pure-markdown-examples.md#heading-1). + Link within backticks: `[[pure-markdown-examples]]` ```` diff --git a/tests/testdata/input/main-samples/obsidian-wikilinks.md b/tests/testdata/input/main-samples/obsidian-wikilinks.md index f75e86a..23d0970 100644 --- a/tests/testdata/input/main-samples/obsidian-wikilinks.md +++ b/tests/testdata/input/main-samples/obsidian-wikilinks.md @@ -2,6 +2,10 @@ Link to [[pure-markdown-examples]] and the same [[Pure-Markdown-Examples]]. Link to [[pure-markdown-examples|pure markdown examples]]. +Link to [[pure-markdown-examples#Heading 1]]. + +Link to [[pure-markdown-examples#Heading 1|pure markdown examples]]. + Link within backticks: `[[pure-markdown-examples]]` ```