feat: adds footnote support

This commit is contained in:
James Liu 2024-11-04 15:08:30 -05:00
parent 5f26c94dce
commit ba373e8697
No known key found for this signature in database
GPG key ID: 4D83DC1EBB6E0DF5
11 changed files with 267 additions and 81 deletions

View file

@ -103,6 +103,16 @@ const nodes = [
{
struct: "FnDef",
kind: ["FN_DEF"],
token: [
["label", "FN_LABEL"],
["description", "FN_CONTENT"],
],
post_blank: true,
affiliated_keywords: true,
},
{
struct: "FnContent",
kind: ["FN_CONTENT"],
post_blank: true,
affiliated_keywords: true,
},
@ -188,6 +198,7 @@ const nodes = [
{
struct: "FnRef",
kind: ["FN_REF"],
token: [["label", "FN_LABEL"]],
},
{
struct: "Macros",

View file

@ -831,6 +831,70 @@ impl AstNode for FnDef {
}
}
impl FnDef {
/// Beginning position of this element
pub fn start(&self) -> TextSize {
self.syntax.text_range().start()
}
/// Ending position of this element
pub fn end(&self) -> TextSize {
self.syntax.text_range().end()
}
/// Range of this element
pub fn text_range(&self) -> TextRange {
self.syntax.text_range()
}
/// Raw text of this element
pub fn raw(&self) -> String {
self.syntax.to_string()
}
pub fn label(&self) -> Option<super::Token> {
super::token(&self.syntax, FN_LABEL)
}
pub fn description(&self) -> Option<super::Token> {
super::token(&self.syntax, FN_CONTENT)
}
pub fn post_blank(&self) -> usize {
super::blank_lines(&self.syntax)
}
pub fn caption(&self) -> Option<AffiliatedKeyword> {
affiliated_keyword(&self.syntax, |k| k == "CAPTION")
}
pub fn header(&self) -> Option<AffiliatedKeyword> {
affiliated_keyword(&self.syntax, |k| k == "HEADER")
}
pub fn name(&self) -> Option<AffiliatedKeyword> {
affiliated_keyword(&self.syntax, |k| k == "NAME")
}
pub fn plot(&self) -> Option<AffiliatedKeyword> {
affiliated_keyword(&self.syntax, |k| k == "PLOT")
}
pub fn results(&self) -> Option<AffiliatedKeyword> {
affiliated_keyword(&self.syntax, |k| k == "RESULTS")
}
pub fn attr(&self, backend: &str) -> Option<AffiliatedKeyword> {
affiliated_keyword(&self.syntax, |k| {
k.starts_with("ATTR_") && &k[5..] == backend
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FnContent {
pub(crate) syntax: SyntaxNode,
}
impl AstNode for FnContent {
type Language = OrgLanguage;
fn can_cast(kind: SyntaxKind) -> bool {
kind == FN_CONTENT
}
fn cast(node: SyntaxNode) -> Option<FnContent> {
Self::can_cast(node.kind()).then(|| FnContent { syntax: node })
}
fn syntax(&self) -> &SyntaxNode {
&self.syntax
}
}
impl FnContent {
/// Beginning position of this element
pub fn start(&self) -> TextSize {
self.syntax.text_range().start()
@ -1680,6 +1744,9 @@ impl FnRef {
pub fn raw(&self) -> String {
self.syntax.to_string()
}
pub fn label(&self) -> Option<super::Token> {
super::token(&self.syntax, FN_LABEL)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]

View file

@ -19,6 +19,7 @@ pub enum Container {
DynBlock(DynBlock),
FnDef(FnDef),
FnContent(FnContent),
Comment(Comment),
FixedWidth(FixedWidth),
SpecialBlock(SpecialBlock),
@ -57,6 +58,7 @@ pub enum Event {
Text(Token),
Macros(Macros),
Cookie(Cookie),
FnLabel(Token),
InlineCall(InlineCall),
InlineSrc(InlineSrc),
Clock(Clock),

View file

@ -1,3 +1,4 @@
use rowan::ast::AstNode;
use rowan::NodeOrToken;
use std::cmp::min;
use std::fmt;
@ -6,6 +7,7 @@ use std::fmt::Write as _;
use super::event::{Container, Event};
use super::TraversalContext;
use super::Traverser;
use crate::ast::token;
use crate::{SyntaxElement, SyntaxKind, SyntaxNode};
/// A wrapper for escaping sensitive characters in html.
@ -51,6 +53,9 @@ impl<S: AsRef<str>> fmt::Display for HtmlEscape<S> {
pub struct HtmlExport {
output: String,
///TODO: track footnotes and citations within the export struct and
/// construct them after the document is fully parsed?
//footnotes: HashMap<String, String>,
in_descriptive_list: Vec<bool>,
table_row: TableRow,
@ -107,6 +112,56 @@ impl Traverser for HtmlExport {
}
Event::Leave(Container::Headline(_)) => {}
Event::Enter(Container::FnRef(t)) => {
if let Some(label) = t.label() {
let _ = write!(
&mut self.output,
"<a href=\"#footnote_{}\" class=\"footnote-reference\">[{}]",
label.syntax().text(),
label.syntax().text()
);
}
self.output += "</a>";
}
Event::Leave(Container::FnRef(_)) => {}
Event::Enter(Container::FnDef(t)) => {
self.output += "<aside ";
self.output += r#"class="footnote-definition" "#;
self.output += ">";
if let Some(label) = t.label() {
self.output += "<a ";
let _ = write!(
&mut self.output,
"href=\"#footnote_{}\" ",
label.syntax().text()
);
self.output += "class=\"footnote-reference\" ";
self.output += ">";
let _ = write!(&mut self.output, "[{}]", label.syntax().text());
self.output += "</a>";
}
}
Event::Leave(Container::FnDef(_)) => {
self.output += "</aside>";
}
Event::Enter(Container::FnContent(c)) => {
self.output += "<span class=\"footnote-content\" ";
if let Some(parent) = c.syntax().parent() {
if parent.kind() == SyntaxKind::FN_REF || parent.kind() == SyntaxKind::FN_DEF {
let label = token(&parent, SyntaxKind::FN_LABEL).unwrap();
dbg!(&label);
let _ = write!(&mut self.output, "id=\"footnote_{}\" ", label);
}
}
self.output += ">";
}
Event::Leave(Container::FnContent(_)) => {
self.output += "</span>";
}
Event::Enter(Container::Paragraph(_)) => self.output += "<p>",
Event::Leave(Container::Paragraph(_)) => self.output += "</p>",
@ -297,6 +352,8 @@ impl Traverser for HtmlExport {
let _ = write!(&mut self.output, "{}", HtmlEscape(text));
}
Event::FnLabel(_) => {}
Event::LineBreak(_) => self.output += "<br/>",
Event::Snippet(snippet) => {

View file

@ -181,6 +181,7 @@ pub trait Traverser {
DYN_BLOCK => walk!(DynBlock),
FN_DEF => walk!(FnDef),
FN_REF => walk!(FnRef),
FN_CONTENT => walk!(FnContent),
MACROS => walk!(@Macros),
SNIPPET => walk!(@Snippet),
TIMESTAMP_ACTIVE | TIMESTAMP_INACTIVE | TIMESTAMP_DIARY => walk!(@Timestamp),
@ -210,12 +211,17 @@ pub trait Traverser {
_ => {}
}
}
SyntaxElement::Token(token) => {
if token.kind() == TEXT {
SyntaxElement::Token(token) => match token.kind() {
TEXT => {
self.event(Event::Text(Token(token)), ctx);
take_control!();
}
}
FN_LABEL => {
self.event(Event::FnLabel(Token(token)), ctx);
take_control!();
}
_ => {}
},
};
}
}

View file

@ -286,11 +286,12 @@ fn affiliated_keywords() {
TEXT@10..25 " a footnote def"
NEW_LINE@25..26 "\n"
L_BRACKET@26..27 "["
TEXT@27..29 "fn"
KEYWORD@27..29 "fn"
COLON@29..30 ":"
TEXT@30..34 "WORD"
FN_LABEL@30..34 "WORD"
R_BRACKET@34..35 "]"
TEXT@35..55 " https://orgmode.org"
FN_CONTENT@35..55
TEXT@35..55 " https://orgmode.org"
"###
);

View file

@ -7,12 +7,13 @@ use nom::{
use super::{
combinator::{
blank_lines, colon_token, l_bracket_token, r_bracket_token, trim_line_end, GreenElement,
NodeBuilder,
blank_lines, colon_token, l_bracket_token, node, r_bracket_token, trim_line_end,
GreenElement, NodeBuilder,
},
input::Input,
keyword::affiliated_keyword_nodes,
SyntaxKind,
object::standard_object_nodes,
SyntaxKind::*,
};
#[cfg_attr(
@ -20,6 +21,10 @@ use super::{
tracing::instrument(level = "debug", skip(input), fields(input = input.s))
)]
pub fn fn_def_node(input: Input) -> IResult<Input, GreenElement, ()> {
crate::lossless_parser!(fn_def_node_base, input)
}
fn fn_def_node_base(input: Input) -> IResult<Input, GreenElement, ()> {
let mut parser = map(
tuple((
affiliated_keyword_nodes,
@ -42,20 +47,25 @@ pub fn fn_def_node(input: Input) -> IResult<Input, GreenElement, ()> {
post_blank,
)| {
let mut b = NodeBuilder::new();
b.children.extend(affiliated_keywords);
b.push(l_bracket);
b.text(fn_);
b.push(fn_.token(KEYWORD));
b.push(colon);
b.text(label);
b.push(label.token(FN_LABEL));
b.push(r_bracket);
b.text(content);
let content_node = node(FN_CONTENT, standard_object_nodes(content));
b.push(content_node);
b.ws(ws_);
b.nl(nl);
b.children.extend(post_blank);
b.finish(SyntaxKind::FN_DEF)
b.finish(FN_DEF)
},
);
crate::lossless_parser!(parser, input)
let (i, fn_def) = parser(input)?;
Ok((i, fn_def))
}
#[test]
@ -66,68 +76,78 @@ fn parse() {
let to_fn_def = to_ast::<FnDef>(fn_def_node);
insta::assert_debug_snapshot!(
to_fn_def("[fn:1] https://orgmode.org").syntax,
@r###"
FN_DEF@0..26
to_fn_def("[fn:1] *bold* - https://orgmode.org").syntax,
@r#"
FN_DEF@0..36
L_BRACKET@0..1 "["
TEXT@1..3 "fn"
KEYWORD@1..3 "fn"
COLON@3..4 ":"
TEXT@4..5 "1"
FN_LABEL@4..5 "1"
R_BRACKET@5..6 "]"
TEXT@6..26 " https://orgmode.org"
"###
FN_CONTENT@6..36
TEXT@6..7 " "
BOLD@7..13
STAR@7..8 "*"
TEXT@8..12 "bold"
STAR@12..13 "*"
TEXT@13..36 " - https://orgmode.org"
"#
);
insta::assert_debug_snapshot!(
to_fn_def("[fn:word_1] https://orgmode.org").syntax,
@r###"
@r#"
FN_DEF@0..31
L_BRACKET@0..1 "["
TEXT@1..3 "fn"
KEYWORD@1..3 "fn"
COLON@3..4 ":"
TEXT@4..10 "word_1"
FN_LABEL@4..10 "word_1"
R_BRACKET@10..11 "]"
TEXT@11..31 " https://orgmode.org"
"###
FN_CONTENT@11..31
TEXT@11..31 " https://orgmode.org"
"#
);
insta::assert_debug_snapshot!(
to_fn_def("[fn:WORD-1] https://orgmode.org").syntax,
@r###"
@r#"
FN_DEF@0..31
L_BRACKET@0..1 "["
TEXT@1..3 "fn"
KEYWORD@1..3 "fn"
COLON@3..4 ":"
TEXT@4..10 "WORD-1"
FN_LABEL@4..10 "WORD-1"
R_BRACKET@10..11 "]"
TEXT@11..31 " https://orgmode.org"
"###
FN_CONTENT@11..31
TEXT@11..31 " https://orgmode.org"
"#
);
insta::assert_debug_snapshot!(
to_fn_def("[fn:WORD]").syntax,
@r###"
@r#"
FN_DEF@0..9
L_BRACKET@0..1 "["
TEXT@1..3 "fn"
KEYWORD@1..3 "fn"
COLON@3..4 ":"
TEXT@4..8 "WORD"
FN_LABEL@4..8 "WORD"
R_BRACKET@8..9 "]"
"###
FN_CONTENT@9..9
"#
);
insta::assert_debug_snapshot!(
to_fn_def("[fn:1] In particular, the parser requires stars at column 0 to be\n").syntax,
@r###"
@r#"
FN_DEF@0..66
L_BRACKET@0..1 "["
TEXT@1..3 "fn"
KEYWORD@1..3 "fn"
COLON@3..4 ":"
TEXT@4..5 "1"
FN_LABEL@4..5 "1"
R_BRACKET@5..6 "]"
TEXT@6..65 " In particular, the p ..."
FN_CONTENT@6..65
TEXT@6..65 " In particular, the p ..."
NEW_LINE@65..66 "\n"
"###
"#
);
let config = &ParseConfig::default();
@ -138,7 +158,7 @@ fn parse() {
insta::assert_debug_snapshot!(
to_fn_def("#+ATTR_poi: 1\n[fn:WORD-1] https://orgmode.org").syntax,
@r###"
@r##"
FN_DEF@0..45
AFFILIATED_KEYWORD@0..14
HASH_PLUS@0..2 "#+"
@ -147,11 +167,12 @@ fn parse() {
TEXT@11..13 " 1"
NEW_LINE@13..14 "\n"
L_BRACKET@14..15 "["
TEXT@15..17 "fn"
KEYWORD@15..17 "fn"
COLON@17..18 ":"
TEXT@18..24 "WORD-1"
FN_LABEL@18..24 "WORD-1"
R_BRACKET@24..25 "]"
TEXT@25..45 " https://orgmode.org"
"###
FN_CONTENT@25..45
TEXT@25..45 " https://orgmode.org"
"##
);
}

View file

@ -31,10 +31,10 @@ fn fn_ref_node_base(input: Input) -> IResult<Input, GreenElement, ()> {
r_bracket_token,
))(input)?;
let mut children = vec![l_bracket, fn_.text_token(), colon, label.text_token()];
let mut children = vec![l_bracket, fn_.token(KEYWORD), colon, label.token(FN_LABEL)];
if let Some((colon, definition)) = definition {
children.push(colon);
children.extend(standard_object_nodes(definition));
children.push(node(FN_CONTENT, standard_object_nodes(definition)));
}
children.push(r_bracket);
@ -64,56 +64,59 @@ fn parse() {
insta::assert_debug_snapshot!(
to_fn_ref("[fn:1]").syntax,
@r###"
@r#"
FN_REF@0..6
L_BRACKET@0..1 "["
TEXT@1..3 "fn"
KEYWORD@1..3 "fn"
COLON@3..4 ":"
TEXT@4..5 "1"
FN_LABEL@4..5 "1"
R_BRACKET@5..6 "]"
"###
"#
);
insta::assert_debug_snapshot!(
to_fn_ref("[fn:1:2]").syntax,
@r###"
@r#"
FN_REF@0..8
L_BRACKET@0..1 "["
TEXT@1..3 "fn"
KEYWORD@1..3 "fn"
COLON@3..4 ":"
TEXT@4..5 "1"
FN_LABEL@4..5 "1"
COLON@5..6 ":"
TEXT@6..7 "2"
FN_CONTENT@6..7
TEXT@6..7 "2"
R_BRACKET@7..8 "]"
"###
"#
);
insta::assert_debug_snapshot!(
to_fn_ref("[fn::2]").syntax,
@r###"
@r#"
FN_REF@0..7
L_BRACKET@0..1 "["
TEXT@1..3 "fn"
KEYWORD@1..3 "fn"
COLON@3..4 ":"
TEXT@4..4 ""
FN_LABEL@4..4 ""
COLON@4..5 ":"
TEXT@5..6 "2"
FN_CONTENT@5..6
TEXT@5..6 "2"
R_BRACKET@6..7 "]"
"###
"#
);
insta::assert_debug_snapshot!(
to_fn_ref("[fn::[]]").syntax,
@r###"
@r#"
FN_REF@0..8
L_BRACKET@0..1 "["
TEXT@1..3 "fn"
KEYWORD@1..3 "fn"
COLON@3..4 ":"
TEXT@4..4 ""
FN_LABEL@4..4 ""
COLON@4..5 ":"
TEXT@5..7 "[]"
FN_CONTENT@5..7
TEXT@5..7 "[]"
R_BRACKET@7..8 "]"
"###
"#
);
let config = &ParseConfig::default();

View file

@ -199,6 +199,8 @@ pub enum SyntaxKind {
COOKIE,
RADIO_TARGET,
FN_REF,
FN_LABEL,
FN_CONTENT,
LATEX_FRAGMENT,
MACROS,
SNIPPET,

View file

@ -305,7 +305,7 @@ fn parse() {
insta::assert_debug_snapshot!(
t("~org-inlinetask-min-level~[fn:oiml:The default value of \n~org-inlinetask-min-level~ is =15=.]"),
@r###"
@r#"
PARAGRAPH@0..93
CODE@0..26
TILDE@0..1 "~"
@ -313,23 +313,24 @@ fn parse() {
TILDE@25..26 "~"
FN_REF@26..93
L_BRACKET@26..27 "["
TEXT@27..29 "fn"
KEYWORD@27..29 "fn"
COLON@29..30 ":"
TEXT@30..34 "oiml"
FN_LABEL@30..34 "oiml"
COLON@34..35 ":"
TEXT@35..57 "The default value of \n"
CODE@57..83
TILDE@57..58 "~"
TEXT@58..82 "org-inlinetask-min-level"
TILDE@82..83 "~"
TEXT@83..87 " is "
VERBATIM@87..91
EQUAL@87..88 "="
TEXT@88..90 "15"
EQUAL@90..91 "="
TEXT@91..92 "."
FN_CONTENT@35..92
TEXT@35..57 "The default value of \n"
CODE@57..83
TILDE@57..58 "~"
TEXT@58..82 "org-inlinetask-min-level"
TILDE@82..83 "~"
TEXT@83..87 " is "
VERBATIM@87..91
EQUAL@87..88 "="
TEXT@88..90 "15"
EQUAL@90..91 "="
TEXT@91..92 "."
R_BRACKET@92..93 "]"
"###
"#
);
insta::assert_debug_snapshot!(

View file

@ -174,3 +174,18 @@ fn line_break() {
@r###""<main><section><p>aa<br/>bb</p></section></main>""###
);
}
#[test]
fn footnote() {
insta::assert_debug_snapshot!(
Org::parse("[fn:1] In particular, the parser requires stars at column 0 to be\n").to_html(),
@r##""<main><section><aside class=\"footnote-definition\" ><a href=\"#footnote_1\" class=\"footnote-reference\" >[1]</a><span class=\"footnote-content\" id=\"footnote_1\" > In particular, the parser requires stars at column 0 to be</span></aside></section></main>""##
);
// "~org-inlinetask-min-level~[fn:oiml:The default value of \n~org-inlinetask-min-level~ is =15=.]"
insta::assert_debug_snapshot!(
Org::parse(
"~org-inlinetask-min-level~[fn:oiml:The default value of \n~org-inlinetask-min-level~ is =15=.]"
).to_html(),
@r##""<main><section><p><code>org-inlinetask-min-level</code><a href=\"#footnote_oiml\" class=\"footnote-reference\">[oiml]</a><span class=\"footnote-content\" id=\"footnote_oiml\" >The default value of \n<code>org-inlinetask-min-level</code> is <code>15</code>.</span></p></section></main>""##
);
}