diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fc1bd28..06cee60 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- ameba-version: [v1.6.4]
+ ameba-version: [v1.7.0]
name: Ameba ${{ matrix.ameba-version }} linter check
steps:
- name: Install latest Crystal
@@ -64,7 +64,7 @@ jobs:
crystal: ${{ matrix.crystal }}
- name: Install dependencies
- run: shards install --without-development
+ run: shards install
- name: Run specs
run: crystal spec --error-on-warnings --error-trace
diff --git a/.gitignore b/.gitignore
index 1bad4d9..98023d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,10 +4,18 @@
/.shards/
/src/main.cr
+# Compiled spec binary
+/markd_spec
+
# Libraries don't need dependency lock
# Dependencies will be locked in application that uses them
/shard.lock
# vscode
/.history/
-/.vscode/settings.json
+/.vscode/
+
+
+# claude
+CLAUDE.md
+/.claude/
diff --git a/.vscode/launch.json b/.vscode/launch.json
deleted file mode 100644
index 3ab15c3..0000000
--- a/.vscode/launch.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "version": "0.2.0",
- "configurations": [
- {
- "type": "lldb",
- "request": "launch",
- "name": "Launch",
- "program": "${workspaceRoot}/bin/main",
- "args": [],
- "cwd": "${workspaceRoot}"
- }
- ]
-}
diff --git a/README.md b/README.md
index d3a99f1..eeef57b 100644
--- a/README.md
+++ b/README.md
@@ -48,8 +48,8 @@ Markd.to_html(markdown, options)
| source_pos | `Bool` | false | if **true**, source position information for block-level elements
will be rendered in the data-sourcepos attribute (for HTML) |
| safe | `Bool` | false | if **true**, raw HTML will not be passed through to HTML output (it will be replaced by comments) |
| prettyprint | `Bool` | false | if **true**, code tags generated by code blocks will have a `prettyprint` class added to them, to be used by [Google code-prettify](https://github.com/google/code-prettify). |
-| gfm | `Bool` | false | **Partial support** |
-| autolink | `Bool` | false | if **true**, more autolinks are detected, like bare email addresses or http links |
+| gfm | `Bool` | false | **Partial support** |
+| autolink | `Bool` | false | if **true**, more autolinks are detected, like bare email addresses or http links |
| toc | `Bool` | false | **Not supported for now** |
| emoji | `Bool` | false | if **true**, convert Github style emoji chars. |
| tagfilter | `Bool` | false | if **true**, escape certain HTML tags. |
diff --git a/shard.yml b/shard.yml
index 7f06234..709771f 100644
--- a/shard.yml
+++ b/shard.yml
@@ -2,7 +2,7 @@ name: markd
version: 0.6.0
development_dependencies:
- tartrazine :
+ tartrazine:
github: ralsina/tartrazine
authors:
diff --git a/spec/fixtures/gfm-extensions.txt b/spec/fixtures/gfm-extensions.txt
index a5ff96d..dbb876d 100644
--- a/spec/fixtures/gfm-extensions.txt
+++ b/spec/fixtures/gfm-extensions.txt
@@ -704,7 +704,7 @@ Even with {"x":"y"} or 1 > 2 or whatever. Even **markdown**.
## Footnotes
-```````````````````````````````` example pending
+```````````````````````````````` example
This is some text![^1]. Other text.[^footnote].
Here's a thing[^other-note].
@@ -762,9 +762,54 @@ Hi!
````````````````````````````````
+Multibyte characters before a reference must not shift its label:
+the parser tracks byte offsets but the label used to be cut with
+character-based slicing.
+
+```````````````````````````````` example
+Emoji ✅[^emoji] and accented é[^accent].
+
+| Tables | Cells |
+| ------ | ----- |
+| emoji | ✅[^t-emoji] |
+
+[^emoji]: emoji note
+[^accent]: accent note
+[^t-emoji]: table note
+.
+
Emoji ✅ and accented é.
+
+
+
+| Tables |
+Cells |
+
+
+
+
+| emoji |
+✅ |
+
+
+
+
+````````````````````````````````
+
## When a footnote is used multiple times, we insert multiple backrefs.
-```````````````````````````````` example pending
+```````````````````````````````` example
This is some text. It has a footnote[^a-footnote].
This footnote is referenced[^a-footnote] multiple times, in lots of different places.[^a-footnote]
@@ -784,7 +829,7 @@ This footnote is referenced[^a-footnote] multiple times, in lots of different pl
## Footnote reference labels are href escaped
-```````````````````````````````` example pending
+```````````````````````````````` example
Hello[^">]
[^">]: pwned
diff --git a/spec/fixtures/gfm-regression.txt b/spec/fixtures/gfm-regression.txt
index b611a0c..302b3ab 100644
--- a/spec/fixtures/gfm-regression.txt
+++ b/spec/fixtures/gfm-regression.txt
@@ -167,7 +167,7 @@ This ~text~ ~~is~~ ~~~curious~~~.
Footnotes in tables
-```````````````````````````````` example table footnotes pending
+```````````````````````````````` example table footnotes
A footnote in a paragraph[^1]
| Column1 | Column2 |
@@ -273,7 +273,7 @@ Pull request #128 - Buffer overread in tables extension
Footnotes may be nested inside other footnotes.
-```````````````````````````````` example footnotes pending
+```````````````````````````````` example footnotes
This is some text. It has a citation.[^citation]
[^another-citation]: My second citation.
@@ -295,7 +295,7 @@ This is some text. It has a citation.[^citation]
Footnotes are similar to, but should not be confused with, link references
-```````````````````````````````` example footnotes pending
+```````````````````````````````` example footnotes
This is some text. It has two footnotes references, side-by-side without any spaces,[^footnote1][^footnote2] which are definitely not link references.
[^footnote1]: Hello.
@@ -317,7 +317,7 @@ This is some text. It has two footnotes references, side-by-side without any spa
Footnotes may begin with or have a 'w' or a '_' in their reference label.
-```````````````````````````````` example footnotes autolink pending
+```````````````````````````````` example footnotes autolink
This is some text. Sometimes the autolinker splits up text into multiple nodes, hoping it will find a hyperlink, so this text has a footnote whose reference label begins with a `w`.[^widely-cited]
It has another footnote that contains many different characters (the autolinker was also breaking on `_`).[^sphinx-of-black-quartz_judge-my-vow-0123456789]
@@ -342,7 +342,8 @@ It has another footnote that contains many different characters (the autolinker
Footnotes interacting with strikethrough should not lead to a use-after-free
-```````````````````````````````` example footnotes autolink strikethrough table pending
+
+```````````````````````````````` example footnotes autolink strikethrough table
|Tot.....[^_a_]|
.
|Tot.....[^_a_]|
@@ -350,7 +351,8 @@ Footnotes interacting with strikethrough should not lead to a use-after-free
Footnotes interacting with strikethrough should not lead to a use-after-free pt2
-```````````````````````````````` example footnotes autolink strikethrough table pending
+
+```````````````````````````````` example footnotes autolink strikethrough table
[^~~is~~1]
.
[^~~is~~1]
@@ -374,3 +376,52 @@ Issue #424 - emphasis before links
.
text link
````````````````````````````````
+
+A footnote reference followed by (parentheses) is not parsed as a link
+
+```````````````````````````````` example footnotes
+Text with a footnote[^a], and [^a](url) with a link-ish suffix.
+
+[^a]: note one
+.
+Text with a footnote, and (url) with a link-ish suffix.
+
+````````````````````````````````
+
+Footnote definitions must start at the beginning of the line
+
+```````````````````````````````` example footnotes
+foo [^b]: this is mid-line, so not a definition
+
+[^b]: real note
+.
+foo [^b]: this is mid-line, so not a definition
+````````````````````````````````
+
+Nested footnote references may contain arbitrary characters
+
+```````````````````````````````` example footnotes
+See[^one].
+
+[^one]: outer [^">]
+
+[^">]: inner note
+.
+See.
+
+````````````````````````````````
diff --git a/spec/fixtures/tartrazine.cr b/spec/fixtures/tartrazine.cr
index 5b1cb9f..f3473bf 100644
--- a/spec/fixtures/tartrazine.cr
+++ b/spec/fixtures/tartrazine.cr
@@ -2,9 +2,9 @@ require "tartrazine"
require "../../src/markd"
markdown = <<-MD
-# Hello Markd
+ # Hello Markd
-> Yet another markdown parser built for speed, written in Crystal, Compliant to CommonMark specification.
-MD
+ > Yet another markdown parser built for speed, written in Crystal, Compliant to CommonMark specification.
+ MD
-html = Markd.to_html(markdown)
+Markd.to_html(markdown)
diff --git a/src/markd/node.cr b/src/markd/node.cr
index 31223b3..783c29b 100644
--- a/src/markd/node.cr
+++ b/src/markd/node.cr
@@ -2,32 +2,33 @@ module Markd
class Node
# Node Type
enum Type
- Document
- Paragraph
- Text
- Strong
- Emphasis
- Strikethrough
- Link
- Image
- Heading
- List
- Item
BlockQuote
Alert
ThematicBreak
Code
CodeBlock
+ CustomBlock
+ CustomInLine
+ Document
+ Emphasis
+ Footnote
+ FootnoteDefinition
+ Heading
HTMLBlock
HTMLInline
+ Image
+ Item
LineBreak
+ Link
+ List
+ Paragraph
SoftBreak
-
- CustomInLine
- CustomBlock
+ Strikethrough
+ Strong
Table
TableCell
TableRow
+ Text
def container?
CONTAINER_TYPES.includes?(self)
@@ -35,23 +36,24 @@ module Markd
end
CONTAINER_TYPES = {
+ Type::BlockQuote,
+ Type::Alert,
+ Type::CustomBlock,
+ Type::CustomInLine,
Type::Document,
- Type::Paragraph,
- Type::Strong,
Type::Emphasis,
- Type::Strikethrough,
- Type::Link,
- Type::Image,
+ Type::FootnoteDefinition,
Type::Heading,
- Type::List,
+ Type::Image,
Type::Item,
- Type::BlockQuote,
- Type::Alert,
- Type::CustomInLine,
- Type::CustomBlock,
+ Type::Link,
+ Type::List,
+ Type::Paragraph,
+ Type::Strikethrough,
+ Type::Strong,
Type::Table,
- Type::TableRow,
Type::TableCell,
+ Type::TableRow,
}
alias DataValue = String | Int32 | Bool
@@ -132,7 +134,7 @@ module Markd
end
def to_s(io : IO)
- io << "#<" << {{@type.name.id.stringify}} << ":0x"
+ io << "#<" << {{ @type.name.id.stringify }} << ":0x"
object_id.to_s(16, io)
io << " @type=" << @type
io << " @parent=" << @parent if @parent
diff --git a/src/markd/parsers/block.cr b/src/markd/parsers/block.cr
index 2f0d459..0c8a2b5 100644
--- a/src/markd/parsers/block.cr
+++ b/src/markd/parsers/block.cr
@@ -7,17 +7,18 @@ module Markd::Parser
end
RULES = {
- Node::Type::Document => Rule::Document.new,
- Node::Type::BlockQuote => Rule::BlockQuote.new,
- Node::Type::Alert => Rule::BlockQuote.new, # Alerts and BlockQuotes are the same
- Node::Type::Heading => Rule::Heading.new,
- Node::Type::CodeBlock => Rule::CodeBlock.new,
- Node::Type::HTMLBlock => Rule::HTMLBlock.new,
- Node::Type::ThematicBreak => Rule::ThematicBreak.new,
- Node::Type::List => Rule::List.new,
- Node::Type::Item => Rule::Item.new,
- Node::Type::Paragraph => Rule::Paragraph.new,
- Node::Type::Table => Rule::Table.new,
+ Node::Type::Document => Rule::Document.new,
+ Node::Type::BlockQuote => Rule::BlockQuote.new,
+ Node::Type::Alert => Rule::BlockQuote.new, # Alerts and BlockQuotes are the same
+ Node::Type::Heading => Rule::Heading.new,
+ Node::Type::CodeBlock => Rule::CodeBlock.new,
+ Node::Type::HTMLBlock => Rule::HTMLBlock.new,
+ Node::Type::ThematicBreak => Rule::ThematicBreak.new,
+ Node::Type::List => Rule::List.new,
+ Node::Type::Item => Rule::Item.new,
+ Node::Type::Paragraph => Rule::Paragraph.new,
+ Node::Type::Table => Rule::Table.new,
+ Node::Type::FootnoteDefinition => Rule::FootnoteDefinition.new,
}
property! tip : Node?
@@ -59,13 +60,142 @@ module Markd::Parser
parse_blocks(source)
end
+ # Process footnote definitions - parse their content as blocks
+ if @options.gfm?
+ process_footnote_definitions
+ end
+
Utils.timer("inline parsing", @options.time?) do
process_inlines
end
+ if @options.gfm?
+ process_footnotes
+ end
+
@document
end
+ # Process footnotes: extract, resolve nested references, number them, and move definitions to end
+ private def process_footnotes
+ # Extract all footnotes and footnote definitions
+ walker = @document.walker
+ footnotes = {} of String => Array(Node)
+ footnote_definitions = {} of String => Node
+ while (event = walker.next)
+ node, entering = event
+ if node.type.footnote?
+ title = node.data["title"].to_s
+ footnotes[title] ||= [] of Node
+ footnotes[title] << node
+ elsif !entering && node.type.footnote_definition?
+ footnote_definitions[node.data["title"].to_s] = node
+ end
+ end
+
+ # Recursively resolve nested footnotes in definitions before numbering
+ loop do
+ new_nodes = 0
+ footnotes.keys.each do |footnote_title|
+ nodes = footnotes[footnote_title]?
+ next unless nodes
+ if !footnote_definitions.has_key?(footnote_title)
+ nodes.each do |fn_node|
+ fn_node.type = Node::Type::Text
+ fn_node.text = "[^#{footnote_title}]"
+ end
+ footnotes.delete footnote_title
+ else
+ def_node = footnote_definitions[footnote_title]
+ walker = def_node.walker
+ while (event = walker.next)
+ n, entering = event
+ if entering && n.type.text?
+ replaced = false
+ n.text = n.text.gsub(/\[\^([^\]]+)\]/) do |m|
+ nested_label = $1
+ if footnote_definitions[nested_label]?
+ fn = Node.new(Node::Type::Footnote)
+ fn.data["title"] = nested_label
+ n.insert_after(fn)
+ footnotes[nested_label] ||= [] of Node
+ footnotes[nested_label] << fn
+ replaced = true
+ new_nodes += 1
+ ""
+ else
+ m
+ end
+ end
+ if replaced && n.parent?
+ @inline_lexer.parse(n.parent)
+ end
+ n.unlink if n.text.empty?
+ end
+ end
+ end
+ end
+ break if new_nodes == 0
+ end
+
+ # Ensure all Footnote nodes are present in the footnotes hash
+ walker = @document.walker
+ while (event = walker.next)
+ node, entering = event
+ if node.type.footnote?
+ title = node.data["title"].to_s
+ footnotes[title] ||= [] of Node
+ unless footnotes[title].includes?(node)
+ footnotes[title] << node
+ end
+ end
+ end
+
+ # Remove definitions without footnotes
+ footnote_definitions.keys.each do |footnote_title|
+ def_node = footnote_definitions[footnote_title]?
+ next unless def_node
+ unless footnotes.has_key?(footnote_title)
+ def_node.unlink
+ footnote_definitions.delete footnote_title
+ end
+ end
+
+ # Footnote numbers are normalized to 1...n
+ # Each reference gets a ref_index (1, 2, 3...) and the definition gets a ref_count
+ footnote_number = 0
+ footnotes.each do |footnote_title, nodes|
+ footnote_number += 1
+ nodes.each_with_index do |fn_node, index|
+ fn_node.data["number"] = footnote_number
+ fn_node.data["ref_index"] = index + 1
+ end
+ footnote_definitions[footnote_title].data["number"] = footnote_number
+ footnote_definitions[footnote_title].data["ref_count"] = nodes.size
+ end
+
+ # Footnote definitions are moved to the end of the document
+ footnotes.each do |footnote_title, _|
+ def_node = footnote_definitions[footnote_title]
+ def_node.unlink
+ @document.append_child(def_node)
+ end
+
+ # After all footnote definitions are attached, re-run inline parsing on their children
+ footnote_definitions.each_value do |def_node|
+ child = def_node.first_child?
+ while child
+ next_child = child.next?
+ if child.type.paragraph? || child.type.heading? || child.type.table_cell?
+ @inline_lexer.parse(child)
+ end
+ child = next_child
+ end
+ end
+
+ nil
+ end
+
private def parse_blocks(source)
lines_size = 0
source.each_line do |line|
@@ -198,6 +328,8 @@ module Markd::Parser
@inline_lexer.refmap = @refmap
while (event = walker.next)
node, entering = event
+ # Note: footnote_definition is not parsed here because its content
+ # has already been parsed as blocks in process_footnote_definitions
if !entering && (node.type.paragraph? || node.type.heading? || node.type.table_cell?)
@inline_lexer.parse(node)
end
@@ -206,6 +338,34 @@ module Markd::Parser
nil
end
+ # Parse footnote definition contents as block-level content
+ private def process_footnote_definitions
+ walker = @document.walker
+ while (event = walker.next)
+ node, entering = event
+ if entering && node.type.footnote_definition? && !node.text.empty?
+ # Parse the footnote definition content as blocks
+ content = node.text
+ node.text = ""
+
+ # Create a temporary sub-parser for the footnote content
+ sub_parser = Block.new(@options)
+ sub_doc = sub_parser.parse(content)
+
+ # Move all children from the sub-document to the footnote definition
+ child = sub_doc.first_child?
+ while child
+ next_child = child.next?
+ child.unlink
+ node.append_child(child)
+ child = next_child
+ end
+ end
+ end
+
+ nil
+ end
+
def token(container : Node, line_number : Int32)
container_parent = container.parent?
diff --git a/src/markd/parsers/inline.cr b/src/markd/parsers/inline.cr
index 8854318..78c34dc 100644
--- a/src/markd/parsers/inline.cr
+++ b/src/markd/parsers/inline.cr
@@ -14,6 +14,7 @@ module Markd::Parser
@text = ""
@pos = 0
@refmap = {} of String => Hash(String, String) | String
+ @footnote_counter = 0
end
def parse(node : Node)
@@ -194,12 +195,19 @@ module Markd::Parser
private def bang(node : Node)
start_pos = @pos
@pos += 1
- if char_at?(@pos) == '['
+ # It's an image if the next character is a [
+ # And it's not a footnote OR we are not in GFM mode
+ #
+ # * This IS an image: `![...]`
+ # * This IS an image in non-GFM mode: `![^...]`
+ # * This IS NOT an image in GFM mode: `![^...]`
+
+ if char_at?(@pos) == '[' && (char_at?(@pos + 1) != '^' || !@options.gfm?)
@pos += 1
child = text("![")
node.append_child(child)
- add_bracket(child, start_pos + 1, true)
+ add_bracket(child, start_pos + 1, image: true)
else
node.append_child(text("!"))
end
@@ -207,9 +215,9 @@ module Markd::Parser
true
end
- private def add_bracket(node : Node, index : Int32, image = false)
+ private def add_bracket(node : Node, index : Int32, *, image = false, footnote = false)
brackets.bracket_after = true if brackets?
- @brackets = Bracket.new(node, @brackets, @delimiters, index, image, true)
+ @brackets = Bracket.new(node, @brackets, @delimiters, index, image: image, active: true, footnote: footnote)
end
private def remove_bracket
@@ -223,7 +231,11 @@ module Markd::Parser
child = text("[")
node.append_child(child)
- add_bracket(child, start_pos, false)
+ if char_at(@pos) == '^' && @options.gfm?
+ add_bracket(child, start_pos, footnote: true)
+ else
+ add_bracket(child, start_pos)
+ end
true
end
@@ -253,12 +265,15 @@ module Markd::Parser
# If we got here, open is a potential opener
is_image = opener.image?
+ is_footnote = opener.footnote?
# Check to see if we have a link/image
save_pos = @pos
# Inline link?
- if char_at?(@pos) == '('
+ # Footnote openers ([^...]) are not links, so don't try to parse a
+ # `(destination)` after the closing bracket for them.
+ if !is_footnote && char_at?(@pos) == '('
@pos += 1
if spnl && (dest = link_destination) &&
spnl && (char_at?(@pos - 1).try(&.whitespace?) &&
@@ -271,6 +286,22 @@ module Markd::Parser
end
end
+ # Is it a footnote?
+ if is_footnote
+ # If the 1st char after the closing bracket is a ":" then it's NOT
+ # a footnote, it's a footnote definition.
+ if char_at?(@pos) == ':'
+ @pos = start_pos
+ node.append_child(text("]"))
+ remove_bracket
+ return true
+ end
+ # @pos and opener.@index are byte offsets: slice bytes, or any
+ # multi-byte character earlier in the line shifts the label.
+ title = @text.byte_slice(opener.@index + 2, @pos - opener.@index - 3)
+ matched = true
+ end
+
ref_label = nil
unless matched
# Next, see if there's a link label
@@ -300,7 +331,15 @@ module Markd::Parser
end
if matched
- child = Node.new(is_image ? Node::Type::Image : Node::Type::Link)
+ if is_image
+ child = Node.new(Node::Type::Image)
+ elsif is_footnote
+ child = Node.new(Node::Type::Footnote)
+ child.data["number"] = @footnote_counter += 1
+ else
+ child = Node.new(Node::Type::Link)
+ end
+
child.data["destination"] = dest.not_nil!
child.data["title"] = title || ""
@@ -904,7 +943,7 @@ module Markd::Parser
text = @text.byte_slice(@pos)
if (match = text.match(regex))
@pos += match.byte_end.not_nil!
- return match[0]
+ match[0]
end
end
@@ -1101,8 +1140,9 @@ module Markd::Parser
property? image : Bool
property? active : Bool
property? bracket_after : Bool
+ property? footnote : Bool
- def initialize(@node, @previous, @previous_delimiter, @index, @image, @active = true)
+ def initialize(@node, @previous, @previous_delimiter, @index, *, @image = false, @active = true, @footnote = false)
@bracket_after = false
end
end
diff --git a/src/markd/renderer.cr b/src/markd/renderer.cr
index 2283981..1578e4c 100644
--- a/src/markd/renderer.cr
+++ b/src/markd/renderer.cr
@@ -49,28 +49,30 @@ module Markd
false
end
- abstract def heading(node : Node, entering : Bool) : Nil
- abstract def list(node : Node, entering : Bool) : Nil
- abstract def item(node : Node, entering : Bool) : Nil
abstract def block_quote(node : Node, entering : Bool) : Nil
abstract def alert(node : Node, entering : Bool) : Nil
abstract def thematic_break(node : Node, entering : Bool) : Nil
abstract def code_block(node : Node, entering : Bool, formatter : T?) : Nil forall T
abstract def code(node : Node, entering : Bool) : Nil
+ abstract def emphasis(node : Node, entering : Bool) : Nil
+ abstract def footnote(node : Node, entering : Bool) : Nil
+ abstract def footnote_definition(node : Node, entering : Bool) : Nil
+ abstract def heading(node : Node, entering : Bool) : Nil
abstract def html_block(node : Node, entering : Bool) : Nil
abstract def html_inline(node : Node, entering : Bool) : Nil
+ abstract def image(node : Node, entering : Bool) : Nil
+ abstract def item(node : Node, entering : Bool) : Nil
+ abstract def line_break(node : Node, entering : Bool) : Nil
+ abstract def link(node : Node, entering : Bool) : Nil
+ abstract def list(node : Node, entering : Bool) : Nil
abstract def paragraph(node : Node, entering : Bool) : Nil
- abstract def emphasis(node : Node, entering : Bool) : Nil
abstract def soft_break(node : Node, entering : Bool) : Nil
- abstract def line_break(node : Node, entering : Bool) : Nil
- abstract def strong(node : Node, entering : Bool) : Nil
abstract def strikethrough(node : Node, entering : Bool) : Nil
- abstract def link(node : Node, entering : Bool) : Nil
- abstract def image(node : Node, entering : Bool) : Nil
- abstract def text(node : Node, entering : Bool) : Nil
- abstract def table(node : Node, entering : Bool) : Nil
- abstract def table_row(node : Node, entering : Bool) : Nil
+ abstract def strong(node : Node, entering : Bool) : Nil
abstract def table_cell(node : Node, entering : Bool) : Nil
+ abstract def table_row(node : Node, entering : Bool) : Nil
+ abstract def table(node : Node, entering : Bool) : Nil
+ abstract def text(node : Node, entering : Bool) : Nil
def render(document : Node, formatter : T? = nil) forall T
Utils.timer("rendering", @options.time?) do
@@ -121,6 +123,10 @@ module Markd
table_row(node, entering)
when Node::Type::TableCell
table_cell(node, entering)
+ when Node::Type::Footnote
+ footnote(node, entering)
+ when Node::Type::FootnoteDefinition
+ footnote_definition(node, entering)
else
text(node, entering)
end
diff --git a/src/markd/renderers/html_renderer.cr b/src/markd/renderers/html_renderer.cr
index a6f65d6..912ca12 100644
--- a/src/markd/renderers/html_renderer.cr
+++ b/src/markd/renderers/html_renderer.cr
@@ -257,11 +257,76 @@ module Markd
newline
tag("p", attrs(node))
else
+ # If this is the last paragraph in a footnote definition, append backrefs
+ if last_paragraph_in_footnote?(node)
+ append_footnote_backrefs(node.parent)
+ end
tag("p", end_tag: true)
newline
end
end
+ # Check if this paragraph is the last paragraph child of a footnote definition
+ private def last_paragraph_in_footnote?(node : Node) : Bool
+ parent = node.parent?
+ return false unless parent && parent.type.footnote_definition?
+
+ # Check if this is the last paragraph (there might be no more paragraphs after this)
+ # but there could be non-paragraph nodes after, so we check if there's no next sibling
+ # that is a paragraph
+ sibling = node.next?
+ while sibling
+ return false if sibling.type.paragraph?
+ sibling = sibling.next?
+ end
+ true
+ end
+
+ # Encode a string for use in HTML id attributes
+ # This matches the old URI.encode behavior for footnote IDs
+ private def encode_id_component(string : String) : String
+ String.build do |io|
+ URI.encode(string, io) do |byte|
+ # Allow unreserved chars plus some additional chars that are safe in HTML IDs
+ URI.unreserved?(byte) || ['/', '(', ')', '*', '!', '$', '\'', ',', ';', ':', '@', '&', '=', '+'].includes?(byte.chr)
+ end
+ end
+ end
+
+ # Append footnote back-reference links
+ # @param in_paragraph If true, adds a space before the backrefs
+ private def append_footnote_backrefs(footnote_def : Node, in_paragraph : Bool = true) : Nil
+ encoded_title = encode_id_component(footnote_def.data["title"].to_s)
+ footnote_number = footnote_def.data["number"].as(Int32)
+ ref_count = footnote_def.data["ref_count"].as(Int32)
+
+ # Add a space before the backrefs if we're inside a paragraph
+ literal " " if in_paragraph
+
+ # Generate backref(s) - multiple if referenced multiple times
+ (1..ref_count).each do |ref_index|
+ backref_id = ref_index == 1 ? "fnref-#{encoded_title}" : "fnref-#{encoded_title}-#{ref_index}"
+ backref_idx = ref_index == 1 ? footnote_number.to_s : "#{footnote_number}-#{ref_index}"
+
+ tag("a", {
+ "href" => "##{backref_id}",
+ "class" => "footnote-backref",
+ "data-footnote-backref" => nil,
+ "data-footnote-backref-idx" => backref_idx,
+ "aria-label" => "Back to reference #{backref_idx}",
+ })
+ literal "↩"
+ # For 2nd and later backrefs, add a superscript with the index
+ if ref_index > 1
+ tag("sup", {"class" => "footnote-ref"})
+ literal ref_index.to_s
+ tag("sup", end_tag: true)
+ end
+ tag("a", end_tag: true)
+ literal " " if ref_index < ref_count
+ end
+ end
+
def emphasis(node : Node, entering : Bool) : Nil
if entering
node.data["strong_stack"] = @strong_stack
@@ -300,6 +365,79 @@ module Markd
output(node.text)
end
+ def footnote(node : Node, entering : Bool) : Nil
+ # Spec says `[^1]` should generate:
+ #
+ # For multiple references to the same footnote:
+ # First: id="fnref-label", Second: id="fnref-label-2", etc.
+ if entering
+ tag("sup", {
+ "class" => "footnote-ref",
+ })
+ ref_index = node.data["ref_index"].as(Int32)
+ encoded_title = encode_id_component(node.data["title"].to_s)
+ id = ref_index == 1 ? "fnref-#{encoded_title}" : "fnref-#{encoded_title}-#{ref_index}"
+ tag("a", {
+ "href" => "#fn-#{encoded_title}",
+ "id" => id,
+ "data-footnote-ref" => nil,
+ })
+ # GFM spec says to output the number of the footnote
+ output node.data["number"].to_s
+ tag("a", end_tag: true)
+ tag("sup", end_tag: true)
+ end
+ end
+
+ def footnote_definition(node : Node, entering : Bool) : Nil
+ # A footnote definition by spec should render something like:
+ #
+ # The actual content of the footnote
+ #
+ #
+ # For multiple references, multiple backref links are generated:
+ #
+ #
+ if entering
+ if !node.prev.type.footnote_definition?
+ newline
+ tag("section", {"class" => "footnotes", "data-footnotes" => nil})
+ newline
+ tag("ol")
+ end
+ newline
+ tag("li", {
+ "id" => "fn-#{encode_id_component(node.data["title"].to_s)}",
+ })
+ newline
+ else
+ # If there's no paragraph child, output backrefs here
+ # (e.g., for footnote definitions that only contain code blocks)
+ unless has_paragraph_child?(node)
+ append_footnote_backrefs(node, in_paragraph: false)
+ newline
+ end
+ tag("li", end_tag: true)
+ if node == node.parent.last_child
+ newline
+ tag("ol", end_tag: true)
+ newline
+ tag("section", end_tag: true)
+ newline
+ end
+ end
+ end
+
+ # Check if a node has a paragraph child
+ private def has_paragraph_child?(node : Node) : Bool
+ child = node.first_child?
+ while child
+ return true if child.type.paragraph?
+ child = child.next?
+ end
+ false
+ end
+
private def tag(name : String, attrs = nil, self_closing = false, end_tag = false)
return if @disable_tag > 0
@@ -307,7 +445,11 @@ module Markd
@output_io << "/" if end_tag
@output_io << name
attrs.try &.each do |key, value|
- @output_io << ' ' << key << '=' << '"' << value << '"'
+ if value.nil?
+ @output_io << ' ' << key
+ else
+ @output_io << ' ' << key << '=' << '"' << value << '"'
+ end
end
@output_io << " /" if self_closing
diff --git a/src/markd/rule.cr b/src/markd/rule.cr
index 5f57db0..1efaaa2 100644
--- a/src/markd/rule.cr
+++ b/src/markd/rule.cr
@@ -15,7 +15,7 @@ module Markd
ATTRIBUTE_VALUE_SPEC_STRING = "(?:" + "\\s*=" + "\\s*" + ATTRIBUTE_VALUE_STRING + ")"
ATTRIBUTE = "(?:" + "\\s+" + ATTRIBUTE_NAME_STRING + ATTRIBUTE_VALUE_SPEC_STRING + "?)"
- MAYBE_SPECIAL = {'#', '`', '~', '*', '+', '_', '=', '<', '>', '-', '|'}
+ MAYBE_SPECIAL = {'#', '`', '~', '*', '+', '_', '=', '<', '>', '-', '|', '['}
THEMATIC_BREAK = /^(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,})[ \t]*$/
ESCAPABLE = /^#{ESCAPABLE_STRING}/
@@ -107,6 +107,8 @@ module Markd
ADMONITION_START = /^> \[!((?:NOTE|TIP|IMPORTANT|CAUTION|WARNING)+)](\s*.*)?$/
+ FOOTNOTE_DEFINITION_START = /^\[\^[^\]]+\]:(\s|$)/
+
# Match Value
#
# - None: no match
diff --git a/src/markd/rules/footnote_definition.cr b/src/markd/rules/footnote_definition.cr
new file mode 100644
index 0000000..db0ada4
--- /dev/null
+++ b/src/markd/rules/footnote_definition.cr
@@ -0,0 +1,64 @@
+module Markd::Rule
+ struct FootnoteDefinition
+ include Rule
+
+ def match(parser : Parser, container : Node) : MatchValue
+ if match?(parser) && parser.gfm?
+ parser.close_unmatched_blocks
+ parser.add_child(Node::Type::FootnoteDefinition, 0)
+ MatchValue::Leaf
+ else
+ MatchValue::None
+ end
+ end
+
+ # Footnote definitions continue as long as lines are indented (4+ spaces)
+ # Blank lines are allowed (they separate paragraphs inside footnotes)
+ def continue(parser : Parser, container : Node) : ContinueStatus
+ # Blank lines are allowed (separate paragraphs)
+ return ContinueStatus::Continue if parser.blank
+
+ # Indented content continues the definition
+ return ContinueStatus::Continue if parser.indent >= Rule::CODE_INDENT
+
+ ContinueStatus::Stop
+ end
+
+ def token(parser : Parser, container : Node) : Nil
+ lines = container.text.split "\n"
+ lines.each_with_index do |line, i|
+ if i == 0
+ # First line has the footnote definition label removed and
+ # leading spaces removed.
+ container.data["title"] = line.split("]:")[0].lstrip("[^")
+ lines[i] = line.split("]:", 2)[1].lstrip
+ elsif line == ""
+ # Empty lines go as-is
+ lines[i] = ""
+ else
+ # Subsequent lines have the leading 4 spaces removed
+ lines[i] = line[4..-1]
+ end
+ end
+ # Store the processed text for later block parsing
+ container.text = lines.join("\n")
+ end
+
+ def can_contain?(type)
+ true
+ end
+
+ # Footnote definitions can be multiline
+ def accepts_lines? : Bool
+ true
+ end
+
+ # Match only lines that look like the first line of a footnote definition:
+ # Start with [^something]:
+
+ private def match?(parser)
+ !parser.indented && \
+ parser.line.match(FOOTNOTE_DEFINITION_START)
+ end
+ end
+end