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
This commit is contained in:
Nick Groenen 2021-01-04 21:17:46 +01:00
parent fcb4cd9dec
commit 6033407266
No known key found for this signature in database
GPG Key ID: 4F0AD019928AE098
5 changed files with 70 additions and 23 deletions

16
Cargo.lock generated
View File

@ -113,6 +113,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "deunicode"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "850878694b7933ca4c9569d30a34b55031b9b139ee1fc7b94a527c4ef960d690"
[[package]] [[package]]
name = "difference" name = "difference"
version = "2.0.0" version = "2.0.0"
@ -305,6 +311,7 @@ dependencies = [
"pulldown-cmark-to-cmark", "pulldown-cmark-to-cmark",
"rayon", "rayon",
"regex", "regex",
"slug",
"snafu", "snafu",
"tempfile", "tempfile",
"walkdir", "walkdir",
@ -508,6 +515,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "slug"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3bc762e6a4b6c6fcaade73e77f9ebc6991b676f88bb2358bddb56560f073373"
dependencies = [
"deunicode",
]
[[package]] [[package]]
name = "snafu" name = "snafu"
version = "0.6.10" version = "0.6.10"

View File

@ -35,6 +35,7 @@ pulldown-cmark = "0.8.0"
pulldown-cmark-to-cmark = "6.0.0" pulldown-cmark-to-cmark = "6.0.0"
rayon = "1.5.0" rayon = "1.5.0"
regex = "1.4.2" regex = "1.4.2"
slug = "0.1.4"
snafu = "0.6.10" snafu = "0.6.10"
[dev-dependencies] [dev-dependencies]

View File

@ -11,8 +11,10 @@ use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag};
use pulldown_cmark_to_cmark::cmark_with_options; use pulldown_cmark_to_cmark::cmark_with_options;
use rayon::prelude::*; use rayon::prelude::*;
use regex::Regex; use regex::Regex;
use slug::slugify;
use snafu::{ResultExt, Snafu}; use snafu::{ResultExt, Snafu};
use std::ffi::OsString; use std::ffi::OsString;
use std::fmt;
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::prelude::*; use std::io::prelude::*;
use std::io::ErrorKind; use std::io::ErrorKind;
@ -196,6 +198,24 @@ impl<'a> ObsidianNoteReference<'a> {
section, 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> { impl<'a> Exporter<'a> {
@ -384,9 +404,11 @@ impl<'a> Exporter<'a> {
if let Event::Text(CowStr::Borrowed(text)) = buffer[2] { if let Event::Text(CowStr::Borrowed(text)) = buffer[2] {
match buffer[0] { match buffer[0] {
Event::Text(CowStr::Borrowed("[")) => { Event::Text(CowStr::Borrowed("[")) => {
let mut link_events = let mut elements = self.make_link_to_file(
self.obsidian_note_link_to_markdown(&text, context); ObsidianNoteReference::from_str(&text),
tree.append(&mut link_events); context,
);
tree.append(&mut elements);
buffer.clear(); buffer.clear();
continue; continue;
} }
@ -445,7 +467,7 @@ impl<'a> Exporter<'a> {
tree tree
} }
Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("webp") => { Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("webp") => {
self.make_link_to_file(&note_ref.file, &note_ref.file, &context) self.make_link_to_file(note_ref, &context)
.into_iter() .into_iter()
.map(|event| match event { .map(|event| match event {
// make_link_to_file returns a link to a file. With this we turn the link // 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() .collect()
} }
_ => self.make_link_to_file(&note_ref.file, &note_ref.file, &context), _ => self.make_link_to_file(note_ref, &context),
}; };
Ok(tree) 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>( fn make_link_to_file<'b>(
&self, &self,
file: &'b str, reference: ObsidianNoteReference<'b>,
label: &'b str,
context: &Context, context: &Context,
) -> MarkdownTree<'b> { ) -> 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() { if target_file.is_none() {
// TODO: Extract into configurable function. // TODO: Extract into configurable function.
println!( println!(
"Warning: Unable to find referenced note\n\tReference: '{}'\n\tSource: '{}'\n", "Warning: Unable to find referenced note\n\tReference: '{}'\n\tSource: '{}'\n",
file, reference.file,
context.current_file().display(), context.current_file().display(),
); );
return vec![ return vec![
Event::Start(Tag::Emphasis), Event::Start(Tag::Emphasis),
Event::Text(CowStr::from(String::from(label))), Event::Text(CowStr::from(reference.display())),
Event::End(Tag::Emphasis), Event::End(Tag::Emphasis),
]; ];
} }
@ -513,19 +529,25 @@ impl<'a> Exporter<'a> {
.expect("obsidian content files should always have a parent"), .expect("obsidian content files should always have a parent"),
) )
.expect("should be able to build relative path when target file is found in vault"); .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, pulldown_cmark::LinkType::Inline,
CowStr::from(encoded_link.to_string()), CowStr::from(link.to_string()),
CowStr::from(""), CowStr::from(""),
); );
vec![ vec![
Event::Start(link.clone()), Event::Start(link_tag.clone()),
Event::Text(CowStr::from(label)), Event::Text(CowStr::from(reference.display())),
Event::End(link.clone()), Event::End(link_tag.clone()),
] ]
} }
} }

View File

@ -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](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]]` Link within backticks: `[[pure-markdown-examples]]`
```` ````

View File

@ -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|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]]` Link within backticks: `[[pure-markdown-examples]]`
``` ```