Implement blockquote token

This commit is contained in:
Juno Takano 2026-02-08 17:08:16 -03:00
commit 260610c4a0
11 changed files with 263 additions and 120 deletions

View file

@ -18,7 +18,7 @@ alias r := run
# Build and serve on changes
[group: 'develop']
run-watch:
{{ watch_cmd }} {{ just_cmd }} run
@{{ watch_cmd }} {{ just_cmd }} run
alias w := run-watch

View file

@ -58,7 +58,7 @@ doc_broken_link = "warn"
doc_comment_double_space_linebreaks = "warn"
doc_link_with_quotes = "warn"
doc_markdown = "warn"
empty_enum = "warn"
empty_enums = "warn"
expl_impl_clone_on_copy = "warn"
explicit_deref_methods = "warn"
explicit_into_iter_loop = "warn"

View file

@ -1,20 +1,21 @@
use crate::syntax::content::parser::{
State, Token,
token::{Header, Paragraph, PreFormat, Quote, Verse},
token::{Header, Paragraph, PreFormat, Verse},
};
pub mod block;
pub mod inline;
pub mod anchor;
pub mod list;
pub mod quote;
#[derive(Clone, Debug)]
#[derive(Clone, Default, Debug)]
pub struct Context {
pub block: Block,
pub inline: Inline,
}
#[derive(Clone, Debug)]
#[derive(Clone, Default, Debug)]
pub enum Block {
Paragraph,
Header(u8), // level
@ -22,13 +23,15 @@ pub enum Block {
PreFormat,
Quote,
Verse,
#[default]
None,
}
#[derive(Clone, Debug)]
#[derive(Clone, Default, Debug)]
pub enum Inline {
Anchor,
Code,
#[default]
None,
}
@ -49,7 +52,7 @@ pub fn close(state: &State, tokens: &mut Vec<Token>) {
tokens.push(Token::Header(Header::from_u8(level, false, None)));
},
Block::Quote => {
tokens.push(Token::Quote(Quote::new(false)));
panic!("End of input with open quote")
},
Block::Verse => {
tokens.push(Token::Verse(Verse::new(false)));

View file

@ -50,7 +50,7 @@ pub fn parse(
} else if Quote::probe(lexeme) {
log!(VERBOSE, "Block Context: None -> Quote on {lexeme}");
state.context.block = Block::Quote;
tokens.push(Token::Quote(Quote::new(true)));
iterator.next();
return true;
} else if Verse::probe(lexeme) {
log!(VERBOSE, "Block Context: None -> Verse on {lexeme}");
@ -93,15 +93,7 @@ pub fn parse(
return super::list::parse(lexeme, state, tokens, iterator, graph);
},
Block::Quote => {
if lexeme.match_char_sequence('\n', '>') {
tokens.push(Token::LineBreak(LineBreak::default()));
iterator.next();
return true;
} else if Quote::probe_end(lexeme) {
tokens.push(Token::Quote(Quote::new(false)));
log!(VERBOSE, "Block Context: Quote -> None on {lexeme}");
state.context.block = Block::None;
}
return super::quote::parse(lexeme, state, tokens, iterator, graph);
},
Block::Verse => {
if Verse::probe_end(lexeme) {

View file

@ -30,6 +30,7 @@ pub fn parse(
let candidate = &mut buffer.candidate;
let item_candidate = &mut buffer.item_candidate;
#[allow(clippy::wildcard_enum_match_arm)]
match state.context.block {
Block::List => {
if lexeme.match_char(' ') && item_candidate.depth.is_none() {

View file

@ -0,0 +1,97 @@
use std::{iter::Peekable, slice::Iter};
use crate::{
graph::Graph,
prelude::*,
syntax::content::parser::{
Lexeme, State, Token,
context::Block,
format, state,
token::{Anchor, Quote},
},
};
/// Handles open quote contexts until a quote is fully parsed.
///
/// A return of `true` will trigger a continue in the outer parser,
/// skipping any further parsing of the current lexeme.
///
/// # Panics
/// This parser can handle only the Quote context, and will panic if passed an
/// unrelated context since it has no knowledge on how to handle them.
pub fn parse(
lexeme: &Lexeme,
state: &mut State,
tokens: &mut Vec<Token>,
iterator: &mut Peekable<Iter<'_, Lexeme>>,
graph: &Graph,
) -> bool {
let buffer = &mut state.buffers.quote;
let candidate = &mut buffer.candidate;
#[allow(clippy::wildcard_enum_match_arm)]
match state.context.block {
Block::Quote => {
if Quote::probe_end(lexeme) {
log!("Probed end of quote on {lexeme}");
let (text, text_tokens) = format(&candidate.text, graph);
candidate.text = text;
state.format_tokens.extend_from_slice(&text_tokens);
if let Some(citation) = &candidate.citation {
let (formatted_citation, citation_tokens) =
format(citation, graph);
candidate.citation = Some(formatted_citation);
state.format_tokens.extend_from_slice(&citation_tokens);
let mut first_anchor = Anchor::default();
for token in citation_tokens {
if let Token::Anchor(token_data) = token {
first_anchor = *token_data.clone();
break;
}
}
if first_anchor.external() {
candidate.url = first_anchor.destination();
}
}
tokens.push(Token::Quote(candidate.clone()));
log!(VERBOSE, "Block Context: Quote -> None on {lexeme}");
state.context.block = Block::None;
*buffer = state::QuoteBuffer::default();
} else if !buffer.in_citation
&& lexeme.match_char('\n')
&& lexeme.next() == "--"
{
log!("Matched citation start on {lexeme}");
buffer.in_citation = true;
iterator.next();
iterator.next();
} else if lexeme.match_char_sequence('\n', '>') {
log!("Matched break-aware sequence on {lexeme}");
candidate.text.push_str(" <\n");
iterator.next();
} else {
log!("Entered quote else branch on {lexeme}");
if buffer.in_citation {
log!("Extending citation on {lexeme}");
candidate.extend_citation(&lexeme.text());
if lexeme.match_char('\n') && lexeme.next() == "--" {
candidate.text.push('\n');
iterator.next();
} else if lexeme.match_char('\n') {
buffer.in_citation = false;
}
} else {
log!("Extending quote on {lexeme}");
candidate.text.push_str(&lexeme.text());
}
}
},
_ => {
panic!("Quote context parser called to handle non-quote context")
},
}
true
}

View file

@ -2,11 +2,11 @@ use std::collections::HashMap;
use crate::syntax::content::parser::{
Token,
context::{Block, Context, Inline},
token::{Anchor, Item, List},
context::Context,
token::{Anchor, Item, List, Quote},
};
#[derive(Clone, Debug)]
#[derive(Clone, Default, Debug)]
pub struct State {
pub context: Context,
pub dom_ids: HashMap<String, Vec<String>>,
@ -15,7 +15,7 @@ pub struct State {
pub format_tokens: Vec<Token>,
}
#[derive(Clone, Debug)]
#[derive(Clone, Default, Debug)]
pub struct Switches {
pub bold: bool,
pub oblique: bool,
@ -23,10 +23,11 @@ pub struct Switches {
pub underline: bool,
}
#[derive(Clone, Debug)]
#[derive(Clone, Default, Debug)]
pub struct Buffers {
pub anchor: AnchorBuffer,
pub list: ListBuffer,
pub quote: QuoteBuffer,
}
#[derive(Default, Clone, Debug)]
@ -43,6 +44,12 @@ pub struct AnchorBuffer {
pub destination: String,
}
#[derive(Default, Clone, Debug)]
pub struct QuoteBuffer {
pub candidate: Quote,
pub in_citation: bool,
}
impl std::fmt::Display for AnchorBuffer {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let display_text = if self.text.is_empty() {
@ -67,37 +74,6 @@ impl std::fmt::Display for AnchorBuffer {
}
}
impl Default for State {
fn default() -> State {
State {
context: Context {
inline: Inline::None,
block: Block::None,
},
dom_ids: HashMap::default(),
switches: Switches {
bold: false,
crossout: false,
oblique: false,
underline: false,
},
buffers: Buffers {
anchor: AnchorBuffer {
candidate: Anchor::default(),
text: String::default(),
destination: String::default(),
},
list: ListBuffer {
candidate: List::default(),
item_candidate: Item::default(),
depth: 0,
},
},
format_tokens: vec![],
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -1,18 +1,24 @@
use crate::syntax::content::{Parseable, parser::Lexeme};
#[derive(Debug, Clone, Eq, PartialEq)]
#[derive(Debug, Default, Clone, Eq, PartialEq)]
pub struct Quote {
open: Option<bool>,
pub text: String,
pub citation: Option<String>,
pub url: Option<String>,
}
impl Quote {
pub fn new(open: bool) -> Quote {
Quote { open: Some(open) }
}
pub fn probe_end(lexeme: &Lexeme) -> bool {
lexeme.match_char_sequence('\n', '\n')
}
pub fn extend_citation(&mut self, s: &str) {
if let Some(current) = &self.citation {
self.citation = Some(format!("{current}{s}"));
} else {
self.citation = Some(String::from(s));
}
}
}
impl Parseable for Quote {
@ -21,19 +27,26 @@ impl Parseable for Quote {
}
fn lex(_lexeme: &Lexeme) -> Quote {
Quote { open: None }
Quote::default()
}
fn render(&self) -> String {
if let Some(open) = self.open {
if open {
"<blockquote>".to_owned()
} else {
"</blockquote>".to_owned()
}
let opening = if let Some(url) = &self.url {
format!(r#"<blockquote cite="{url}">"#)
} else {
panic!("Attempt to render a quote tag while open state is unknown")
}
String::from("<blockquote>")
};
let content = if let Some(citation) = &self.citation {
format!(
r#"{}<br/><p class="quote-citation">{citation}</p>"#,
&self.text
)
} else {
String::from(&self.text)
};
format!("\n{opening}\n{content}\n</blockquote>\n")
}
fn flatten(&self) -> String {
@ -43,16 +56,14 @@ impl Parseable for Quote {
impl std::fmt::Display for Quote {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let display_open_state = match self.open {
Some(open_state) => {
if open_state {
"open"
} else {
"closed"
}
},
None => "unknown",
};
write!(f, "Quote [{display_open_state}]")
let mut meta = String::default();
if self.url.is_some() {
meta.push_str("+url ");
}
if self.citation.is_some() {
meta.push_str("+citation ");
}
write!(f, "Quote [{}]", meta.trim())
}
}

View file

@ -188,7 +188,7 @@ If you are familiar with Markdown|https://en.wikipedia.org/wiki/Markdown|, you'l
## Anchors
Anchors have the following basic syntax:
Anchors are the most important and powerful syntactic element you will work with because they can create connections between nodes when you use them. They have the following basic syntax:
`
anchor|destination
@ -197,11 +197,18 @@ anchor|destination
For example:
`
DRC|DemocraticRepublicOfTheCongo
particles|ParticlePhysics
`
This example will render as the word "particles" pointing to a node with ID `ParticlePhysics` because the destination is not an external URL.
An external anchor looks like this:
`
docs|https://en.jutty.dev/node/Documentation
`
As shown above, anchors can point to external addresses. These are identified by the presence of either a `:` or a `/` character in the destination. Otherwise, the anchor will point to a node with an ID matching the destination. This means your anchors to external URLs, special handlers such as `mailto:user@domain.com` and destinations relative to the website root like `/about` will all work as intended without being interpreted as node IDs.
External anchors are identified by the presence of either a `:` or a `/` character in the destination. This works for special handlers, such as `mailto:user@domain.com`, and destinations relative to the website root like `/about`.
If the left side contains spaces, you need a leading `|` character:
@ -223,6 +230,8 @@ For internal anchors, most punctuation is automatically separated from the ancho
This gem|PreciousStone, though green, was not an emerald.
`
> This gem|PreciousStone, though green, was not an emerald.
However, for external anchors, you want to add a third `|` to explicitly set the end because external URLs can have all sorts of arbitrary characters.
### Node anchors
@ -235,14 +244,6 @@ A node ID wrapped in two `|` characters will become an anchor to that node:
|ParticlePhysics|
`
And two words separated by a single anchor allow you to set a display text and destination:
`
particles|ParticlePhysics
`
This example will render as "particles|ParticlePhysics": the word particles pointing to a node with id `ParticlePhysics`.
en can resolve IDs case insensitively (with priority to case-sensitive matches) and will also collapse spaces when trying to resolve an ID, so you can also write:
`
@ -285,6 +286,9 @@ You can use `[ ]` and `[x]` to render checkboxes:
- [x] done
`
- [ ] not done
- [x] done
## Blocks
A block is any group of lines separated by empty lines:
@ -292,12 +296,12 @@ A block is any group of lines separated by empty lines:
`
block A
still block A
still block A
block A's last line
block B starts here
block B ends here
block C starts here
still block C
this is block C
`
By default, a block not starting with any special syntax is a paragraph, such as this very line you are reading.
@ -310,7 +314,7 @@ b
c
`
You still get "a b c" as a result. This is the case for paragraphs and blockquotes, but not for lists, verse blocks and preformatted text.
You still get "a b c" as a result. This is the case for paragraphs, but not for lists, verse blocks and preformatted text. Blockquotes support both modes.
This is useful when editing your text, allowing you to break some thoughts and special syntax without losing control over where your paragraph ends, particularly when handling huge paragraphs.
@ -332,7 +336,7 @@ While useful to break a few lines on demand, if you have a large block of lines
### Verse
Verse blocks are delimited by a `&` character at their first and last line and are useful to avoid precisely this line-joining behavior of most blocks. They will break all lines without need for a trailing `<` character:
Verse blocks are delimited by a `&` character at their first and last line and are useful to avoid precisely this line-joining behavior of paragraphs. They will break all lines without need for a trailing `<` character:
`
&
@ -342,8 +346,6 @@ Verse blocks are delimited by a `&` character at their first and last line and a
&
`
This will be rendered as:
&
these lines
break just fine
@ -352,37 +354,79 @@ This will be rendered as:
### Quotes
A block of lines starting with a `>` character create a quote:
A block of lines starting with a `>` character will render as a quote:
`
> this is a quote
> Who'll change old lamps for new ones?
`
> Who'll change old lamps for new ones?
Quote blocks have two forms. If you prepend all blocks with a `>`, line breaks will be preserved, not collapsing the whole quote into a single line:
> a quote where all lines start with > BR
> still inside BR
> still inside BR
>
> above was a line with just a > plus a break BR
> still inside BR
> next is an empty line BR
`
> When I was alive
> I was dust which was,
> But now I am dust in dust
> I am dust which never was.
`
> When I was alive
> I was dust which was,
> But now I am dust in dust
> I am dust which never was.
If you would like the quote to be collapsed into a single line instead, you can leave just the first `>` and continue until the next empty line:
> this quote starts here
and continues here
until an empty line is found
`
> Dois grandes mitos dominam a história oficial do Brasil:
o mito da índole pacífica do brasileiro e o da "democracia racial".
-- Benedita da Silva, Speech on the Federal Senate, March 3rd, 1995
`
> Dois grandes mitos dominam a história oficial do Brasil:
o mito da índole pacífica do brasileiro e o da "democracia racial".
-- Benedita da Silva, Speech on the Federal Senate, March 3rd, 1995
You can still use `<` characters to force line breaks in this case.
#### Citation
To add a citation to your qutoe block, you can add lines starting with two `-` characters:
To add a citation to your quote block, start a line with two `-` characters:
> a quote with _nested_ formatting *syntax and in* particular an anchor|Roadmap BR
> the quote continuation as it goes on and on
-- quote by |Person Johnson|https://personjohnson.com
`
> with no more communion
> to down as morning pick-me-ups
> to sweeten afternoon naps
> to soothe nightmares
-- Assotto Saint, The Language of Dust
`
> with no more communion
> to down as morning pick-me-ups
> to sweeten afternoon naps
> to soothe nightmares
-- Assotto Saint, The Language of Dust
If you have a more complex citation, you can use multiple lines starting with `--`. All such lines will be joined together in the citation. If you need to break lines, use the `<` character at the end of a line:
`
> Dois grandes mitos dominam a história oficial do Brasil:
o mito da índole pacífica do brasileiro e o da "democracia racial".
-- Benedita da Silva,
-- |Speech on the Federal Senate|https://www25.senado.leg.br/web/atividade/pronunciamentos/-/p/pronunciamento/165765|,
-- March 3rd, 1995, <
-- _Dia Internacional para a Eliminação da Discriminação Racial._
`
> Dois grandes mitos dominam a história oficial do Brasil:
o mito da índole pacífica do brasileiro e o da "democracia racial".
-- Benedita da Silva,
-- |Speech on the Federal Senate|https://www25.senado.leg.br/web/atividade/pronunciamentos/-/p/pronunciamento/165765|,
-- March 3rd, 1995, <
-- Dia Internacional para a Eliminação da Discriminação Racial.
The first URL found in your citation will be used as the blockquote element's `cite` field.
### Lists
@ -394,6 +438,10 @@ A block of lines starting with a `-` character will be rendered as an unordered
- crimson
`
- cyan
- amber
- crimson
Lines starting with a `+` character will create numbered lists instead:
`
@ -402,6 +450,10 @@ Lines starting with a `+` character will create numbered lists instead:
+ san
`
+ ichi
+ ni
+ san
## Rendering unformatted text
The backtick character `\\`` can be used to render unformatted blocks and inline text:
@ -410,6 +462,8 @@ The backtick character `\\`` can be used to render unformatted blocks and inline
The asterisk `*` is special in en markup syntax.
`
> The asterisk `*` is special in en markup syntax.
Using the syntax above, the asterisk won't be interpreted as the start of bold formatting and instead will be shown like this: `*`.
This is useful for code but also for rendering characters with special meaning you wish to mention literally.
@ -437,8 +491,6 @@ If you need some more advanced feature that is not supported directly by en's ma
&lt;/table&gt;
`
Which will render to:
<table>
<tr>
<td> Hi, </td>
@ -653,8 +705,8 @@ en is only possible thanks to a number of projects and people:
- Neovim|https://neovim.io/
- foot|https://codeberg.org/dnkl/foot
- tmux|https://github.com/tmux/tmux/
- |Void Linux|https://voidlinux.org/ and its kernel|https://www.kernel.org/
- LibreWolf|https://librewolf.net/
- |Debian|https://debian.org/ and its kernel|https://www.kernel.org/
- LibreWolf|https://librewolf.net/ and its upstream |Mozilla Firefox|https://www.firefox.com/
- InkScape|https://inkscape.org/
"""

View file

@ -25,7 +25,7 @@ main {
box-sizing: border-box;
}
p, section li {
p:not(.quote-citation), section li {
font-family: prose, sans-serif;
}
@ -337,12 +337,23 @@ blockquote {
font-size: calc(var(--base-font-size) * 1.6);
}
.verse {
p.verse {
font-family: serifed;
font-size: calc(var(--base-font-size) * 1.6);
margin-left: 2em;
}
.quote-citation {
margin: 0.5em 0 0 0;
text-indent: -1.2em;
padding-left: 1em;
font-size: calc(var(--base-font-size) * 0.8);
}
.quote-citation::before {
content: "— ";
}
@media (prefers-color-scheme: dark) {
* {
background: #222222;

View file

@ -44,7 +44,7 @@
{% endif %}
{% if incoming %}
<div class="connections-incoming">
<h3>Incoming</h4>
<h3>Incoming</h3>
<ul>
{% for connection in incoming %}
<li>