Define new Postprocess and EmbedPostprocess traits

These are like the Postprocessor callback function type, but they can
be implemented on types for more ergonomic stateful postprocessing.

Fixes zoni/obsidian-export#175
This commit is contained in:
Robert Sesek 2023-09-18 00:06:51 -04:00
parent a791273d12
commit 7988b21f5d
2 changed files with 123 additions and 12 deletions

View File

@ -39,8 +39,8 @@ pub type MarkdownEvents<'a> = Vec<Event<'a>>;
/// converted to regular markdown syntax.
///
/// Postprocessors are called in the order they've been added through [Exporter::add_postprocessor]
/// just before notes are written out to their final destination.
/// They may be used to achieve the following:
/// or [Exporter::add_postprocessor_impl] just before notes are written out to their final
/// destination. They may be used to achieve the following:
///
/// 1. Modify a note's [Context], for example to change the destination filename or update its [Frontmatter] (see [Context::frontmatter]).
/// 2. Change a note's contents by altering [MarkdownEvents].
@ -54,7 +54,7 @@ pub type MarkdownEvents<'a> = Vec<Event<'a>>;
///
/// 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].
/// [Exporter::add_embed_postprocessor] or [Exporter::add_embed_postprocessor_impl].
/// 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:
///
@ -137,6 +137,22 @@ pub type Postprocessor<'f> =
dyn Fn(&mut Context, &mut MarkdownEvents) -> PostprocessorResult + Send + Sync + 'f;
type Result<T, E = ExportError> = std::result::Result<T, E>;
/// Postprocess is a trait form of the [Postprocessor] callback that can be passed to
/// [Exporter::add_postprocessor_impl].
pub trait Postprocess: Send + Sync {
fn postprocess(&self, ctx: &mut Context, events: &mut MarkdownEvents) -> PostprocessorResult;
}
/// EmbedPostprocess is a trait form of the [Postprocessor] callback that can be
/// passed to [Exporter::add_embed_postprocessor_impl].
pub trait EmbedPostprocess: Send + Sync {
fn embed_postprocess(
&self,
ctx: &mut Context,
events: &mut MarkdownEvents,
) -> PostprocessorResult;
}
const PERCENTENCODE_CHARS: &AsciiSet = &CONTROLS.add(b' ').add(b'(').add(b')').add(b'%').add(b'?');
const NOTE_RECURSION_LIMIT: usize = 10;
@ -216,6 +232,23 @@ pub enum PostprocessorResult {
StopAndSkipNote,
}
#[derive(Clone)]
enum PostprocessorRef<'p> {
Function(&'p Postprocessor<'p>),
Trait(&'p dyn Postprocess),
EmbedTrait(&'p dyn EmbedPostprocess),
}
impl<'p> PostprocessorRef<'p> {
fn call(&'p self, ctx: &mut Context, events: &mut MarkdownEvents) -> PostprocessorResult {
match self {
PostprocessorRef::Function(f) => f(ctx, events),
PostprocessorRef::Trait(t) => t.postprocess(ctx, events),
PostprocessorRef::EmbedTrait(t) => t.embed_postprocess(ctx, events),
}
}
}
#[derive(Clone)]
/// Exporter provides the main interface to this library.
///
@ -231,8 +264,8 @@ pub struct Exporter<'a> {
vault_contents: Option<Vec<PathBuf>>,
walk_options: WalkOptions<'a>,
process_embeds_recursively: bool,
postprocessors: Vec<&'a Postprocessor<'a>>,
embed_postprocessors: Vec<&'a Postprocessor<'a>>,
postprocessors: Vec<PostprocessorRef<'a>>,
embed_postprocessors: Vec<PostprocessorRef<'a>>,
}
impl<'a> fmt::Debug for Exporter<'a> {
@ -315,13 +348,33 @@ impl<'a> Exporter<'a> {
/// Append a function to the chain of [postprocessors][Postprocessor] to run on exported Obsidian Markdown notes.
pub fn add_postprocessor(&mut self, processor: &'a Postprocessor) -> &mut Exporter<'a> {
self.postprocessors.push(processor);
self.postprocessors
.push(PostprocessorRef::Function(processor));
self
}
/// Append a trait implementation of [Postprocess] to the chain of [postprocessors] to run on
/// Obsidian Markdown notes.
pub fn add_postprocessor_impl(&mut self, processor: &'a dyn Postprocess) -> &mut Exporter<'a> {
self.postprocessors.push(PostprocessorRef::Trait(processor));
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.embed_postprocessors
.push(PostprocessorRef::Function(processor));
self
}
/// Append a trait implementation of [EmbedPostprocess] to the chain of [postprocessors] for
/// embeds.
pub fn add_embed_postprocessor_impl(
&mut self,
processor: &'a dyn EmbedPostprocess,
) -> &mut Exporter<'a> {
self.embed_postprocessors
.push(PostprocessorRef::EmbedTrait(processor));
self
}
@ -400,8 +453,8 @@ impl<'a> Exporter<'a> {
let (frontmatter, mut markdown_events) = self.parse_obsidian_note(src, &context)?;
context.frontmatter = frontmatter;
for func in &self.postprocessors {
match func(&mut context, &mut markdown_events) {
for processor in &self.postprocessors {
match processor.call(&mut context, &mut markdown_events) {
PostprocessorResult::StopHere => break,
PostprocessorResult::StopAndSkipNote => return Ok(()),
PostprocessorResult::Continue => (),
@ -603,10 +656,10 @@ impl<'a> Exporter<'a> {
if let Some(section) = note_ref.section {
events = reduce_to_section(events, section);
}
for func in &self.embed_postprocessors {
for processor 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.
match func(&mut child_context, &mut events) {
match processor.call(&mut child_context, &mut events) {
PostprocessorResult::StopHere => break,
PostprocessorResult::StopAndSkipNote => {
events = vec![];

View File

@ -1,5 +1,7 @@
use obsidian_export::postprocessors::softbreaks_to_hardbreaks;
use obsidian_export::{Context, Exporter, MarkdownEvents, PostprocessorResult};
use obsidian_export::{
Context, EmbedPostprocess, Exporter, MarkdownEvents, Postprocess, PostprocessorResult,
};
use pretty_assertions::assert_eq;
use pulldown_cmark::{CowStr, Event};
use serde_yaml::Value;
@ -139,6 +141,62 @@ fn test_postprocessor_stateful_callback() {
assert!(parents.contains(expected));
}
#[test]
fn test_postprocessor_impl() {
#[derive(Default)]
struct Impl {
parents: Mutex<HashSet<PathBuf>>,
embeds: Mutex<u32>,
}
impl Postprocess for Impl {
fn postprocess(
&self,
ctx: &mut Context,
_events: &mut MarkdownEvents,
) -> PostprocessorResult {
self.parents
.lock()
.unwrap()
.insert(ctx.destination.parent().unwrap().to_path_buf());
PostprocessorResult::Continue
}
}
impl EmbedPostprocess for Impl {
fn embed_postprocess(
&self,
_ctx: &mut Context,
_events: &mut MarkdownEvents,
) -> PostprocessorResult {
let mut embeds = self.embeds.lock().unwrap();
*embeds += 1;
PostprocessorResult::Continue
}
}
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(),
);
let postprocessor = Impl {
..Default::default()
};
exporter.add_postprocessor_impl(&postprocessor);
exporter.add_embed_postprocessor_impl(&postprocessor);
exporter.run().unwrap();
let expected = tmp_dir.path().clone();
let parents = postprocessor.parents.lock().unwrap();
println!("{:?}", parents);
assert_eq!(1, parents.len());
assert!(parents.contains(expected));
assert_eq!(1, *postprocessor.embeds.lock().unwrap());
}
// 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).