Skip to content

Commit 868a055

Browse files
committed
markdown: add footnote support
1 parent 6256c62 commit 868a055

File tree

6 files changed

+259
-0
lines changed

6 files changed

+259
-0
lines changed

footnote.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright 2024 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package markdown
6+
7+
import (
8+
"strconv"
9+
"strings"
10+
)
11+
12+
type Footnote struct {
13+
Position
14+
Label string
15+
Blocks []Block
16+
}
17+
18+
type FootnoteLink struct {
19+
Label string
20+
Footnote *Footnote
21+
}
22+
23+
type printedNote struct {
24+
num string
25+
note *Footnote
26+
refs []string
27+
}
28+
29+
func (*FootnoteLink) Inline() {}
30+
31+
func (x *Footnote) printed(p *printer) *printedNote {
32+
if p.footnotes == nil {
33+
p.footnotes = make(map[*Footnote]*printedNote)
34+
}
35+
pr, ok := p.footnotes[x]
36+
if !ok {
37+
pr = &printedNote{
38+
num: strconv.Itoa(len(p.footnotes) + 1),
39+
note: x,
40+
}
41+
p.footnotes[x] = pr
42+
p.footnotelist = append(p.footnotelist, pr)
43+
}
44+
ref := pr.num
45+
if len(pr.refs) > 0 {
46+
ref += "-" + strconv.Itoa(len(pr.refs)+1)
47+
}
48+
pr.refs = append(pr.refs, ref)
49+
return pr
50+
}
51+
52+
func (x *FootnoteLink) printHTML(p *printer) {
53+
note := x.Footnote
54+
if note == nil {
55+
return
56+
}
57+
pr := note.printed(p)
58+
ref := pr.refs[len(pr.refs)-1]
59+
p.html(`<sup class="fn"><a id="fnref-`, ref, `" href="#fn-`, pr.num, `">`, pr.num, `</a></sup>`)
60+
}
61+
62+
func (x *FootnoteLink) printMarkdown(p *printer) {
63+
note := x.Footnote
64+
if note == nil {
65+
return
66+
}
67+
note.printed(p) // add to list for printFootnoteMarkdown
68+
p.text(`[^`, x.Label, `]`)
69+
}
70+
71+
func (x *FootnoteLink) printText(p *printer) {
72+
p.text(`[^`, x.Label, `]`)
73+
}
74+
75+
func printFootnoteHTML(p *printer) {
76+
if len(p.footnotelist) == 0 {
77+
return
78+
}
79+
80+
p.html(`<div class="footnotes">Footnotes</div>`, "\n")
81+
p.html("<ol>\n")
82+
for num, note := range p.footnotelist {
83+
num++
84+
str := strconv.Itoa(num)
85+
p.html(`<li id="fn-`, str, `">`, "\n")
86+
for _, b := range note.note.Blocks {
87+
b.printHTML(p)
88+
}
89+
if !p.eraseCloseP() {
90+
p.html("<p>\n")
91+
}
92+
for _, ref := range note.refs {
93+
p.html("\n", `<a class="fnref" href="#fnref-`, ref, `">↩</a>`)
94+
}
95+
p.html("</p>\n")
96+
p.html("</li>\n")
97+
}
98+
p.html("</ol>\n")
99+
}
100+
101+
func (x *Footnote) printMarkdown(p *printer) {
102+
p.md(`[^`, x.Label, `]: `)
103+
defer p.pop(p.push(" "))
104+
printMarkdownBlocks(x.Blocks, p)
105+
}
106+
107+
func printFootnoteMarkdown(p *printer) {
108+
if len(p.footnotelist) == 0 {
109+
return
110+
}
111+
112+
p.maybeNL()
113+
for _, note := range p.footnotelist {
114+
p.nl()
115+
note.note.printMarkdown(p)
116+
}
117+
}
118+
119+
func parseFootnoteRef(p *parser, s string, start int) (x Inline, end int, ok bool) {
120+
if !p.Footnote || start+1 >= len(s) || s[start+1] != '^' {
121+
return
122+
}
123+
end = strings.Index(s[start:], "]")
124+
if end < 0 {
125+
return
126+
}
127+
end += start + 1
128+
label := s[start+2 : end-1]
129+
note, ok := p.footnotes[normalizeLabel(label)]
130+
if !ok {
131+
return
132+
}
133+
return &FootnoteLink{label, note}, end, true
134+
}
135+
136+
func startFootnote(p *parser, s line) (line, bool) {
137+
t := s
138+
t.trimSpace(0, 3, false)
139+
if !t.trim('[') || !t.trim('^') {
140+
return s, false
141+
}
142+
label := t.string()
143+
i := strings.Index(label, "]")
144+
if i < 0 || i+1 >= len(label) && label[i+1] != ':' {
145+
return s, false
146+
}
147+
label = label[:i]
148+
for j := 0; j < i; j++ {
149+
c := label[j]
150+
if c == ' ' || c == '\r' || c == '\n' || c == 0x00 || c == '\t' {
151+
return s, false
152+
}
153+
}
154+
t.skip(i + 2)
155+
156+
if _, ok := p.footnotes[normalizeLabel(label)]; ok {
157+
// Already have a footnote with this label.
158+
// cmark-gfm ignores all future references,
159+
// dropping them from the document,
160+
// but it seems more helpful to not treat it
161+
// as a footnote.
162+
p.corner = true
163+
return s, false
164+
}
165+
166+
fb := &footnoteBuilder{label}
167+
p.addBlock(fb)
168+
return t, true
169+
}
170+
171+
type footnoteBuilder struct {
172+
label string
173+
}
174+
175+
func (b *footnoteBuilder) extend(p *parser, s line) (line, bool) {
176+
if !s.trimSpace(4, 4, true) {
177+
return s, false
178+
}
179+
return s, true
180+
}
181+
182+
func (b *footnoteBuilder) build(p *parser) Block {
183+
if p.footnotes == nil {
184+
p.footnotes = make(map[string]*Footnote)
185+
}
186+
p.footnotes[normalizeLabel(b.label)] = &Footnote{p.pos(), b.label, p.blocks()}
187+
return &Empty{}
188+
}

line.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ func (s *line) trim(c byte) bool {
100100
return false
101101
}
102102

103+
func (s *line) skip(n int) {
104+
s.i += n
105+
if s.nonblank < s.i {
106+
s.setNonblank()
107+
}
108+
}
109+
103110
func (s *line) string() string {
104111
switch s.spaces {
105112
case 0:

link.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ func (x *Image) printText(p *printer) {
136136
// parseLinkOpen is an [inlineParser] for a link open [.
137137
// The caller has checked that s[start] == '['.
138138
func parseLinkOpen(p *parser, s string, start int) (x Inline, end int, ok bool) {
139+
if p.Footnote {
140+
if x, end, ok := parseFootnoteRef(p, s, start); ok {
141+
return x, end, ok
142+
}
143+
}
139144
return &openPlain{Plain{s[start : start+1]}, start + 1}, start + 1, true
140145
}
141146

parse.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ type parser struct {
9393
// texts to apply inline processing to
9494
texts []textRaw
9595

96+
footnotes map[string]*Footnote
97+
9698
// inline parsing
9799
s string
98100
emitted int // s[:emitted] has been emitted into list
@@ -343,4 +345,5 @@ var starters = []starter{
343345
startThematicBreak,
344346
startListItem,
345347
startHTMLBlock,
348+
startFootnote,
346349
}

print.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type printer struct {
2020
prefixOlder []byte
2121
trimLimit int
2222
listOut
23+
footnotes map[*Footnote]*printedNote
24+
footnotelist []*printedNote
2325
}
2426

2527
type listOut struct {
@@ -83,12 +85,14 @@ func ToHTML(b Block) string {
8385
var p printer
8486
p.writeMode = writeHTML
8587
b.printHTML(&p)
88+
printFootnoteHTML(&p)
8689
return p.buf.String()
8790
}
8891

8992
func Format(b Block) string {
9093
var p printer
9194
b.printMarkdown(&p)
95+
printFootnoteMarkdown(&p)
9296
// TODO footnotes?
9397
return p.buf.String()
9498
}

testdata/footnote.txt

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
-- parser.json --
2+
{"Footnote": true}
3+
-- 1.md --
4+
Here is a simple footnote[^1][^4].
5+
6+
A footnote can also[^3] have multiple lines[^4].
7+
8+
[^1]: My reference.
9+
[^4]: To add line breaks within a footnote, prefix new lines with 2 spaces.
10+
This is a second line.
11+
-- 1.html --
12+
<p>Here is a simple footnote<sup class="fn"><a id="fnref-1" href="#fn-1">1</a></sup><sup class="fn"><a id="fnref-2" href="#fn-2">2</a></sup>.</p>
13+
<p>A footnote can also[^3] have multiple lines<sup class="fn"><a id="fnref-2-2" href="#fn-2">2</a></sup>.</p>
14+
<div class="footnotes">Footnotes</div>
15+
<ol>
16+
<li id="fn-1">
17+
<p>My reference.
18+
<a class="fnref" href="#fnref-1">↩</a></p>
19+
</li>
20+
<li id="fn-2">
21+
<p>To add line breaks within a footnote, prefix new lines with 2 spaces.
22+
This is a second line.
23+
<a class="fnref" href="#fnref-2">↩</a>
24+
<a class="fnref" href="#fnref-2-2">↩</a></p>
25+
</li>
26+
</ol>
27+
-- 2.md --
28+
Footnote[^abc].
29+
30+
[^aBc]: Hi.
31+
-- 2.html --
32+
<p>Footnote<sup class="fn"><a id="fnref-1" href="#fn-1">1</a></sup>.</p>
33+
<div class="footnotes">Footnotes</div>
34+
<ol>
35+
<li id="fn-1">
36+
<p>Hi.
37+
<a class="fnref" href="#fnref-1">↩</a></p>
38+
</li>
39+
</ol>
40+
-- 3.md --
41+
Footnote[^aBc].
42+
43+
[^abC]: Hi.
44+
-- 3.html --
45+
<p>Footnote<sup class="fn"><a id="fnref-1" href="#fn-1">1</a></sup>.</p>
46+
<div class="footnotes">Footnotes</div>
47+
<ol>
48+
<li id="fn-1">
49+
<p>Hi.
50+
<a class="fnref" href="#fnref-1">↩</a></p>
51+
</li>
52+
</ol>

0 commit comments

Comments
 (0)