New: support postprocessors running on embedded notes
This introduces support for postprocessors that are run on the result of a note that is being embedded into another note. This differs from the existing postprocessors (which remain unchanged) that run once all embeds have been processed and merged with the final note. These "embed postprocessors" may be set through the new `Exporter::add_embed_postprocessor` method.
This commit is contained in:
parent
6afcd75f07
commit
8dc7e59a79
56
src/lib.rs
56
src/lib.rs
@ -45,6 +45,26 @@ pub type MarkdownEvents<'a> = Vec<Event<'a>>;
|
|||||||
/// 3. Prevent later postprocessors from running ([PostprocessorResult::StopHere]) or cause a note
|
/// 3. Prevent later postprocessors from running ([PostprocessorResult::StopHere]) or cause a note
|
||||||
/// to be skipped entirely ([PostprocessorResult::StopAndSkipNote]).
|
/// to be skipped entirely ([PostprocessorResult::StopAndSkipNote]).
|
||||||
///
|
///
|
||||||
|
/// # Postprocessors and embeds
|
||||||
|
///
|
||||||
|
/// Postprocessors normally run at the end of the export phase, once notes have been fully parsed.
|
||||||
|
/// This means that any embedded notes have been resolved and merged into the final note already.
|
||||||
|
///
|
||||||
|
/// In some cases it may be desirable to change the contents of these embedded notes *before* they
|
||||||
|
/// are inserted into the final document. This is possible through the use of
|
||||||
|
/// [Exporter::add_embed_postprocessor].
|
||||||
|
/// These "embed postprocessors" run much the same way as regular postprocessors, but they're run on
|
||||||
|
/// the note that is about to be embedded in another note. In addition:
|
||||||
|
///
|
||||||
|
/// - Changes to context carry over to later embed postprocessors, but are then discarded. This
|
||||||
|
/// means that changes to frontmatter do not propagate to the root note for example.
|
||||||
|
/// - [PostprocessorResult::StopAndSkipNote] prevents the embedded note from being included (it's
|
||||||
|
/// replaced with a blank document) but doesn't affect the root note.
|
||||||
|
///
|
||||||
|
/// It's possible to pass the same functions to [Exporter::add_postprocessor] and
|
||||||
|
/// [Exporter::add_embed_postprocessor]. The [Context::note_depth] method may be used to determine
|
||||||
|
/// whether a note is a root note or an embedded note in this situation.
|
||||||
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ## Update frontmatter
|
/// ## Update frontmatter
|
||||||
@ -217,6 +237,7 @@ pub struct Exporter<'a> {
|
|||||||
walk_options: WalkOptions<'a>,
|
walk_options: WalkOptions<'a>,
|
||||||
process_embeds_recursively: bool,
|
process_embeds_recursively: bool,
|
||||||
postprocessors: Vec<&'a Postprocessor>,
|
postprocessors: Vec<&'a Postprocessor>,
|
||||||
|
embed_postprocessors: Vec<&'a Postprocessor>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> fmt::Debug for Exporter<'a> {
|
impl<'a> fmt::Debug for Exporter<'a> {
|
||||||
@ -235,6 +256,13 @@ impl<'a> fmt::Debug for Exporter<'a> {
|
|||||||
"postprocessors",
|
"postprocessors",
|
||||||
&format!("<{} postprocessors active>", self.postprocessors.len()),
|
&format!("<{} postprocessors active>", self.postprocessors.len()),
|
||||||
)
|
)
|
||||||
|
.field(
|
||||||
|
"embed_postprocessors",
|
||||||
|
&format!(
|
||||||
|
"<{} postprocessors active>",
|
||||||
|
self.embed_postprocessors.len()
|
||||||
|
),
|
||||||
|
)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -252,6 +280,7 @@ impl<'a> Exporter<'a> {
|
|||||||
process_embeds_recursively: true,
|
process_embeds_recursively: true,
|
||||||
vault_contents: None,
|
vault_contents: None,
|
||||||
postprocessors: vec![],
|
postprocessors: vec![],
|
||||||
|
embed_postprocessors: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -295,6 +324,12 @@ impl<'a> Exporter<'a> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append a function to the chain of [postprocessors][Postprocessor] for embeds.
|
||||||
|
pub fn add_embed_postprocessor(&mut self, processor: &'a Postprocessor) -> &mut Exporter<'a> {
|
||||||
|
self.embed_postprocessors.push(processor);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Export notes using the settings configured on this exporter.
|
/// Export notes using the settings configured on this exporter.
|
||||||
pub fn run(&mut self) -> Result<()> {
|
pub fn run(&mut self) -> Result<()> {
|
||||||
if !self.root.exists() {
|
if !self.root.exists() {
|
||||||
@ -377,7 +412,7 @@ impl<'a> Exporter<'a> {
|
|||||||
match res.2 {
|
match res.2 {
|
||||||
PostprocessorResult::StopHere => break,
|
PostprocessorResult::StopHere => break,
|
||||||
PostprocessorResult::StopAndSkipNote => return Ok(()),
|
PostprocessorResult::StopAndSkipNote => return Ok(()),
|
||||||
_ => (),
|
PostprocessorResult::Continue => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -558,7 +593,7 @@ impl<'a> Exporter<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let path = path.unwrap();
|
let path = path.unwrap();
|
||||||
let child_context = Context::from_parent(context, path);
|
let mut child_context = Context::from_parent(context, path);
|
||||||
let no_ext = OsString::new();
|
let no_ext = OsString::new();
|
||||||
|
|
||||||
if !self.process_embeds_recursively && context.file_tree().contains(path) {
|
if !self.process_embeds_recursively && context.file_tree().contains(path) {
|
||||||
@ -571,10 +606,25 @@ impl<'a> Exporter<'a> {
|
|||||||
|
|
||||||
let events = match path.extension().unwrap_or(&no_ext).to_str() {
|
let events = match path.extension().unwrap_or(&no_ext).to_str() {
|
||||||
Some("md") => {
|
Some("md") => {
|
||||||
let (_frontmatter, mut events) = self.parse_obsidian_note(path, &child_context)?;
|
let (frontmatter, mut events) = self.parse_obsidian_note(path, &child_context)?;
|
||||||
|
child_context.frontmatter = frontmatter;
|
||||||
if let Some(section) = note_ref.section {
|
if let Some(section) = note_ref.section {
|
||||||
events = reduce_to_section(events, section);
|
events = reduce_to_section(events, section);
|
||||||
}
|
}
|
||||||
|
for func in &self.embed_postprocessors {
|
||||||
|
// Postprocessors running on embeds shouldn't be able to change frontmatter (or
|
||||||
|
// any other metadata), so we give them a clone of the context.
|
||||||
|
let res = func(child_context, events);
|
||||||
|
child_context = res.0;
|
||||||
|
events = res.1;
|
||||||
|
match res.2 {
|
||||||
|
PostprocessorResult::StopHere => break,
|
||||||
|
PostprocessorResult::StopAndSkipNote => {
|
||||||
|
events = vec![];
|
||||||
|
}
|
||||||
|
PostprocessorResult::Continue => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("webp") => {
|
Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("webp") => {
|
||||||
|
@ -62,7 +62,10 @@ fn test_postprocessor_stophere() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
exporter.add_postprocessor(&|ctx, mdevents| (ctx, mdevents, PostprocessorResult::StopHere));
|
exporter.add_postprocessor(&|ctx, mdevents| (ctx, mdevents, PostprocessorResult::StopHere));
|
||||||
|
exporter
|
||||||
|
.add_embed_postprocessor(&|ctx, mdevents| (ctx, mdevents, PostprocessorResult::StopHere));
|
||||||
exporter.add_postprocessor(&|_, _| panic!("should not be called due to above processor"));
|
exporter.add_postprocessor(&|_, _| panic!("should not be called due to above processor"));
|
||||||
|
exporter.add_embed_postprocessor(&|_, _| panic!("should not be called due to above processor"));
|
||||||
exporter.run().unwrap();
|
exporter.run().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,3 +113,98 @@ fn test_postprocessor_change_destination() {
|
|||||||
assert!(!original_note_path.exists());
|
assert!(!original_note_path.exists());
|
||||||
assert!(new_note_path.exists());
|
assert!(new_note_path.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The purpose of this test to verify the `append_frontmatter` postprocessor is called to extend
|
||||||
|
// the frontmatter, and the `foo_to_bar` postprocessor is called to replace instances of "foo" with
|
||||||
|
// "bar" (only in the note body).
|
||||||
|
#[test]
|
||||||
|
fn test_embed_postprocessors() {
|
||||||
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
||||||
|
let mut exporter = Exporter::new(
|
||||||
|
PathBuf::from("tests/testdata/input/postprocessors"),
|
||||||
|
tmp_dir.path().to_path_buf(),
|
||||||
|
);
|
||||||
|
exporter.add_embed_postprocessor(&foo_to_bar);
|
||||||
|
// Should have no effect with embeds:
|
||||||
|
exporter.add_embed_postprocessor(&append_frontmatter);
|
||||||
|
|
||||||
|
exporter.run().unwrap();
|
||||||
|
|
||||||
|
let expected =
|
||||||
|
read_to_string("tests/testdata/expected/postprocessors/Note_embed_postprocess_only.md")
|
||||||
|
.unwrap();
|
||||||
|
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from("Note.md"))).unwrap();
|
||||||
|
assert_eq!(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When StopAndSkipNote is used with an embed_preprocessor, it should skip the embedded note but
|
||||||
|
// continue with the rest of the note.
|
||||||
|
#[test]
|
||||||
|
fn test_embed_postprocessors_stop_and_skip() {
|
||||||
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
||||||
|
let mut exporter = Exporter::new(
|
||||||
|
PathBuf::from("tests/testdata/input/postprocessors"),
|
||||||
|
tmp_dir.path().to_path_buf(),
|
||||||
|
);
|
||||||
|
exporter.add_embed_postprocessor(&|ctx, mdevents| {
|
||||||
|
(ctx, mdevents, PostprocessorResult::StopAndSkipNote)
|
||||||
|
});
|
||||||
|
|
||||||
|
exporter.run().unwrap();
|
||||||
|
|
||||||
|
let expected =
|
||||||
|
read_to_string("tests/testdata/expected/postprocessors/Note_embed_stop_and_skip.md")
|
||||||
|
.unwrap();
|
||||||
|
let actual = read_to_string(tmp_dir.path().clone().join(PathBuf::from("Note.md"))).unwrap();
|
||||||
|
assert_eq!(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This test verifies that the context which is passed to an embed postprocessor is actually
|
||||||
|
// correct. Primarily, this means the frontmatter should reflect that of the note being embedded as
|
||||||
|
// opposed to the frontmatter of the root note.
|
||||||
|
#[test]
|
||||||
|
fn test_embed_postprocessors_context() {
|
||||||
|
let tmp_dir = TempDir::new().expect("failed to make tempdir");
|
||||||
|
let mut exporter = Exporter::new(
|
||||||
|
PathBuf::from("tests/testdata/input/postprocessors"),
|
||||||
|
tmp_dir.path().to_path_buf(),
|
||||||
|
);
|
||||||
|
|
||||||
|
exporter.add_postprocessor(&|ctx, mdevents| {
|
||||||
|
if ctx.current_file() != &PathBuf::from("Note.md") {
|
||||||
|
return (ctx, mdevents, PostprocessorResult::Continue);
|
||||||
|
}
|
||||||
|
let is_root_note = ctx
|
||||||
|
.frontmatter
|
||||||
|
.get(&Value::String("is_root_note".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
if is_root_note != &Value::Bool(true) {
|
||||||
|
// NOTE: Test failure may not give output consistently because the test binary affects
|
||||||
|
// how output is captured and printed in the thread running this postprocessor. Just
|
||||||
|
// run the test a couple times until the error shows up.
|
||||||
|
panic!(
|
||||||
|
"postprocessor: expected is_root_note in {} to be true, got false",
|
||||||
|
&ctx.current_file().display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(ctx, mdevents, PostprocessorResult::Continue)
|
||||||
|
});
|
||||||
|
exporter.add_embed_postprocessor(&|ctx, mdevents| {
|
||||||
|
let is_root_note = ctx
|
||||||
|
.frontmatter
|
||||||
|
.get(&Value::String("is_root_note".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
if is_root_note == &Value::Bool(true) {
|
||||||
|
// NOTE: Test failure may not give output consistently because the test binary affects
|
||||||
|
// how output is captured and printed in the thread running this postprocessor. Just
|
||||||
|
// run the test a couple times until the error shows up.
|
||||||
|
panic!(
|
||||||
|
"embed_postprocessor: expected is_root_note in {} to be false, got true",
|
||||||
|
&ctx.current_file().display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(ctx, mdevents, PostprocessorResult::Continue)
|
||||||
|
});
|
||||||
|
|
||||||
|
exporter.run().unwrap();
|
||||||
|
}
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
---
|
---
|
||||||
foo: bar
|
foo: bar
|
||||||
|
is_root_note: true
|
||||||
bar: baz
|
bar: baz
|
||||||
---
|
---
|
||||||
|
|
||||||
# Title
|
# Title
|
||||||
|
|
||||||
|
This note is embedded. It mentions the word bar.
|
||||||
|
|
||||||
Sentence containing bar.
|
Sentence containing bar.
|
||||||
|
10
tests/testdata/expected/postprocessors/Note_embed_postprocess_only.md
vendored
Normal file
10
tests/testdata/expected/postprocessors/Note_embed_postprocess_only.md
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
foo: bar
|
||||||
|
is_root_note: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Title
|
||||||
|
|
||||||
|
This note is embedded. It mentions the word bar.
|
||||||
|
|
||||||
|
Sentence containing foo.
|
10
tests/testdata/expected/postprocessors/Note_embed_stop_and_skip.md
vendored
Normal file
10
tests/testdata/expected/postprocessors/Note_embed_stop_and_skip.md
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
foo: bar
|
||||||
|
is_root_note: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Title
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Sentence containing foo.
|
3
tests/testdata/input/postprocessors/Note.md
vendored
3
tests/testdata/input/postprocessors/Note.md
vendored
@ -1,7 +1,10 @@
|
|||||||
---
|
---
|
||||||
foo: bar
|
foo: bar
|
||||||
|
is_root_note: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Title
|
# Title
|
||||||
|
|
||||||
|
![[_embed]]
|
||||||
|
|
||||||
Sentence containing foo.
|
Sentence containing foo.
|
||||||
|
5
tests/testdata/input/postprocessors/_embed.md
vendored
Normal file
5
tests/testdata/input/postprocessors/_embed.md
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
is_root_note: false
|
||||||
|
---
|
||||||
|
|
||||||
|
This note is embedded. It mentions the word foo.
|
Loading…
Reference in New Issue
Block a user