From e3a92037eb83331b59106d9fee696b5776d94bdb Mon Sep 17 00:00:00 2001 From: "@crashkeys.dev" Date: Tue, 11 Nov 2025 13:48:57 +0100 Subject: [PATCH] blockquote: basic implementation. This feels like something of a terrible hack, but it (maybe?) works. It passes my one test, at least. And that's enough for what I need. Signed-off-by: @crashkeys.dev --- src/construct/block_quote.rs | 107 +++++++++++++++++++++++++++++++++++ src/event.rs | 3 + src/state.rs | 7 +++ src/to_html.rs | 26 +++++++++ tests/block_quote.rs | 2 +- 5 files changed, 144 insertions(+), 1 deletion(-) diff --git a/src/construct/block_quote.rs b/src/construct/block_quote.rs index b559cd8..2819761 100644 --- a/src/construct/block_quote.rs +++ b/src/construct/block_quote.rs @@ -61,6 +61,7 @@ use crate::util::constant::TAB_SIZE; pub fn start(tokenizer: &mut Tokenizer) -> State { if tokenizer.parse_state.options.constructs.block_quote { tokenizer.enter(Name::BlockQuote); + State::Retry(StateName::BlockQuoteContStart) } else { State::Nok @@ -109,6 +110,36 @@ pub fn cont_before(tokenizer: &mut Tokenizer) -> State { tokenizer.enter(Name::BlockQuoteMarker); tokenizer.consume(); tokenizer.exit(Name::BlockQuoteMarker); + + // If we are looking for obsidian block quote metadata... + if tokenizer.parse_state.options.constructs.obs_block_quote { + // Clean up our flags + tokenizer.tokenize_state.seen = false; + + // Start looking for the metadata! + tokenizer.attempt( + State::Next(StateName::BlockQuoteContBeforeNoCallout), + State::Next(StateName::BlockQuoteContAfter), + ); + return State::Next(StateName::ObsidianBlockQuoteCalloutStart); + } + + State::Next(StateName::BlockQuoteContAfter) + } + _ => State::Nok, + } +} + +// Just the original version of the previous function, without looking for +// Obsidian callout blocks... +pub fn cont_before_no_callout(tokenizer: &mut Tokenizer) -> State { + match tokenizer.current { + Some(b'>') => { + tokenizer.enter(Name::BlockQuotePrefix); + tokenizer.enter(Name::BlockQuoteMarker); + tokenizer.consume(); + tokenizer.exit(Name::BlockQuoteMarker); + State::Next(StateName::BlockQuoteContAfter) } _ => State::Nok, @@ -133,3 +164,79 @@ pub fn cont_after(tokenizer: &mut Tokenizer) -> State { tokenizer.exit(Name::BlockQuotePrefix); State::Ok } + +pub fn obs_callout_start(tokenizer: &mut Tokenizer) -> State { + match tokenizer.current { + // Eat all whitespace before a callout + Some(b' ' | b'\t') => { + tokenizer.attempt( + State::Next(StateName::ObsidianBlockQuoteCalloutStart), + // State::Next(StateName::BlockQuoteContAfter), + State::Nok, + ); + State::Retry(space_or_tab_min_max( + tokenizer, + 1, + if tokenizer.parse_state.options.constructs.code_indented { + TAB_SIZE - 1 + } else { + usize::MAX + }, + )) + } + Some(b'[') => { + // We reuse state to mark that we've seen a possible callout start + tokenizer.tokenize_state.seen = true; + tokenizer.consume(); + State::Next(StateName::ObsidianBlockQuoteCalloutStart) + } + Some(b'!') if tokenizer.tokenize_state.seen => { + // We're inside a callout now + tokenizer.enter(Name::ObsidianBlockQuoteCallout); + tokenizer.enter(Name::ObsidianBlockQuoteCalloutType); + tokenizer.consume(); + + // We reuse this state variable until we've found a closing ] + tokenizer.tokenize_state.seen = true; + State::Next(StateName::ObsidianBlockQuoteCalloutInner) + } + _ => State::Nok, + } +} + +/// ```markdown +/// > [!blahblah] +/// ^ +/// ``` +pub fn obs_callout_inner(tokenizer: &mut Tokenizer) -> State { + match tokenizer.current { + Some(b']') if tokenizer.tokenize_state.seen => { + // We've seen the end of the callout declaration + tokenizer.tokenize_state.seen = false; + + // TODO: Optionally check for - character + tokenizer.consume(); + tokenizer.exit(Name::ObsidianBlockQuoteCalloutType); + tokenizer.enter(Name::ObsidianText); + State::Next(StateName::ObsidianBlockQuoteCalloutInner) + } + Some(b'\n') => { + // Resume normal flow! + tokenizer.consume(); + tokenizer.exit(Name::ObsidianText); + tokenizer.exit(Name::ObsidianBlockQuoteCallout); + tokenizer.tokenize_state.seen = false; + // State::Next(StateName::BlockQuoteContBefore) + State::Ok + } + _ if tokenizer.tokenize_state.seen => { + tokenizer.consume(); + State::Next(StateName::ObsidianBlockQuoteCalloutInner) + } + _ => { + // We just save whatever this is as text + tokenizer.consume(); + State::Next(StateName::ObsidianBlockQuoteCalloutInner) + } + } +} diff --git a/src/event.rs b/src/event.rs index 3b310f4..a8c8fc3 100644 --- a/src/event.rs +++ b/src/event.rs @@ -122,6 +122,9 @@ pub enum Name { /// > | b /// ^ /// ``` + ObsidianBlockQuoteCallout, + ObsidianBlockQuoteCalloutType, + ObsidianText, BlockQuote, /// Block quote marker. /// diff --git a/src/state.rs b/src/state.rs index ef5cb8d..82d5b88 100644 --- a/src/state.rs +++ b/src/state.rs @@ -62,8 +62,12 @@ pub enum Name { BlockQuoteStart, BlockQuoteContStart, BlockQuoteContBefore, + BlockQuoteContBeforeNoCallout, BlockQuoteContAfter, + ObsidianBlockQuoteCalloutStart, + ObsidianBlockQuoteCalloutInner, + BomStart, BomInside, @@ -504,6 +508,7 @@ pub fn call(tokenizer: &mut Tokenizer, name: Name) -> State { Name::BlockQuoteStart => construct::block_quote::start, Name::BlockQuoteContStart => construct::block_quote::cont_start, Name::BlockQuoteContBefore => construct::block_quote::cont_before, + Name::BlockQuoteContBeforeNoCallout => construct::block_quote::cont_before_no_callout, Name::BlockQuoteContAfter => construct::block_quote::cont_after, Name::BomStart => construct::partial_bom::start, Name::BomInside => construct::partial_bom::inside, @@ -950,6 +955,8 @@ pub fn call(tokenizer: &mut Tokenizer, name: Name) -> State { Name::WikilinkLabelEscape => construct::wikilink_label::escape, Name::WikilinkLabelInside => construct::wikilink_label::inside, Name::WikilinkLabelNok => construct::wikilink_label::nok, + Name::ObsidianBlockQuoteCalloutStart => construct::block_quote::obs_callout_start, + Name::ObsidianBlockQuoteCalloutInner => construct::block_quote::obs_callout_inner, }; func(tokenizer) diff --git a/src/to_html.rs b/src/to_html.rs index b563c71..a2a4248 100644 --- a/src/to_html.rs +++ b/src/to_html.rs @@ -330,6 +330,8 @@ fn enter(context: &mut CompileContext) { | Name::ResourceTitleString => on_enter_buffer(context), Name::BlockQuote => on_enter_block_quote(context), + Name::ObsidianBlockQuoteCallout => on_enter_obs_callout(context), + Name::ObsidianText => on_enter_obs_text(context), Name::CodeIndented => on_enter_code_indented(context), Name::CodeFenced | Name::MathFlow => on_enter_raw_flow(context), Name::CodeText | Name::MathText => on_enter_raw_text(context), @@ -378,6 +380,8 @@ fn exit(context: &mut CompileContext) { Name::AutolinkEmail => on_exit_autolink_email(context), Name::AutolinkProtocol => on_exit_autolink_protocol(context), Name::BlankLineEnding => on_exit_blank_line_ending(context), + Name::ObsidianBlockQuoteCallout => on_exit_obs_callout(context), + Name::ObsidianText => on_exit_obs_text(context), Name::BlockQuote => on_exit_block_quote(context), Name::CharacterReferenceMarker => on_exit_character_reference_marker(context), Name::CharacterReferenceMarkerNumeric => { @@ -457,6 +461,28 @@ fn on_enter_block_quote(context: &mut CompileContext) { context.push("
"); } +fn on_enter_obs_callout(context: &mut CompileContext) { + context.tight_stack.push(true); + context.line_ending_if_needed(); + context.push("

"); +} + +fn on_exit_obs_callout(context: &mut CompileContext) { + context.tight_stack.pop(); + context.push("

"); + context.line_ending_if_needed(); +} + +fn on_enter_obs_text(_context: &mut CompileContext) {} + +fn on_exit_obs_text(context: &mut CompileContext) { + let text = Slice::from_position( + context.bytes, + &Position::from_exit_event(context.events, context.index), + ); + context.push(text.as_str().trim()); +} + /// Handle [`Enter`][Kind::Enter]:[`CodeIndented`][Name::CodeIndented]. fn on_enter_code_indented(context: &mut CompileContext) { context.raw_flow_seen_data = Some(false); diff --git a/tests/block_quote.rs b/tests/block_quote.rs index 046d7b3..c769ac1 100644 --- a/tests/block_quote.rs +++ b/tests/block_quote.rs @@ -21,7 +21,7 @@ fn obsidian_extension() -> Result<(), message::Message> { }; assert_eq!( - to_html_with_options("> [!cite]- This is a test\n> b\n> c", &obsidian)?, + to_html_with_options("> [!cite] This is a test\n> b\n> c", &obsidian)?, "
\n

This is a test

\n

b\nc

\n
", "should support block quotes" ); -- 2.51.2