ssh git@grit.deadkey.org man

mailmanpat/markdown.awk · 2026-09-05 17:01:23 +08002026-9-5 +8git clone …

FILES / test / units.sh

NameLast CommitTimestampSize
..
assert.bash 6b66487 h Extract the markdown parser from jenny 26-09-05 09:57 3 KB
regressions.sh e8441ae h Let a thematic break end a quoted paragraph 26-09-05 17:01 10 KB
run.sh 6b66487 h Extract the markdown parser from jenny 26-09-05 09:57 383 B
units.sh ba20446 h Fold the probe after each decode, not only before the first 26-09-05 10:46 20 KB
#!/usr/bin/env bash
# Unit tests for the inline and block parsing rules.
# Run: test/units.sh (or test/run.sh for everything)
set -uo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/assert.bash"
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
AWK="${AWK:-awk}"
SED="${SED:-sed}"
suite "markdown: intraword underscores"
# The deep-link id is stripped so these assertions can state the markup they
# are about. Each is checking how a span of text is parsed, not that a
# paragraph carries an anchor -- that has tests of its own, and pinning a
# content hash in every one of these would make them unreadable and would
# break them all again on any wording change.
md_unsafe() { printf '%s\n' "$1" | "$AWK" -v UNSAFE_HTML=1 -f "$REPO_ROOT/markdown.awk"; }
md() { printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/markdown.awk" \
| "$SED" 's/<p id="p-[0-9a-f]*\(-[0-9]*\)\{0,1\}">/<p>/g'; }
it "an underscore inside a word is literal"
assert_eq "$(md 'node_modules here')" "<p>node_modules here</p>"
it "snake_case survives intact"
assert_eq "$(md 'snake_case_name')" "<p>snake_case_name</p>"
it "a leading underscore still opens emphasis"
assert_eq "$(md 'a _real_ emphasis')" "<p>a <em>real</em> emphasis</p>"
it "intraword and real emphasis coexist on one line"
assert_eq "$(md 'x_y and _z_')" "<p>x_y and <em>z</em></p>"
it "asterisk emphasis is unaffected"
assert_eq "$(md 'a *starred* word')" "<p>a <em>starred</em> word</p>"
it "intraword asterisks still emphasise, as in CommonMark"
assert_eq "$(md 'a*b*c')" "<p>a<em>b</em>c</p>"
it "bold with underscores still works"
assert_eq "$(md 'a __bold__ word')" "<p>a <strong>bold</strong> word</p>"
it "no stray em is emitted for an identifier"
assert_not_contains "$(md 'multi_word_ident text')" "<em>"
suite "markdown: fenced code blocks"
# Whitespace is significant inside <pre>, so the tags are joined to the first
# and last content lines. A newline after the opening tag would render as a
# blank first line in the block.
it "a plain fence produces a code block"
assert_eq "$(md '```
echo hi
```')" "<pre><code>echo hi</code></pre>"
it "no blank line is introduced at the top of a block"
assert_not_contains "$(md '```
echo hi
```')" "<code>
"
it "no blank line at the bottom either"
assert_not_contains "$(md '```
echo hi
```')" "
</code>"
it "interior blank lines are preserved"
assert_contains "$(md '```
one
two
```')" "one
two"
it "the fence markers are not in the output"
assert_not_contains "$(md '```
echo hi
```')" '`'
it "a language tag becomes a class"
assert_contains "$(md '```bash
echo hi
```')" 'class="language-bash"'
it "multiple lines are preserved separately"
out=$(md '```
line one
line two
```')
assert_contains "$out" "line one
line two"
it "HTML inside a fence is escaped"
assert_contains "$(md '```
<b>x</b> & y
```')" "&lt;b&gt;x&lt;/b&gt; &amp; y"
it "markdown inside a fence is left alone"
assert_contains "$(md '```
*not emphasis* and _not_ either
```')" "*not emphasis* and _not_ either"
it "smart quotes do not touch fenced content"
assert_contains "$(md '```
say "quoted" here
```')" 'say "quoted" here'
it "text around a fence still renders"
out=$(md 'Before.
```
code
```
After.')
assert_contains "$out" "<p>Before.</p>"
it "text after a fence is a separate paragraph"
assert_contains "$out" "<p>After.</p>"
it "a tilde fence works too"
assert_contains "$(md '~~~
echo hi
~~~')" "<pre><code>"
it "an empty fence is handled"
assert_contains "$(md '```
```')" "<pre><code>"
it "indented code blocks still work"
assert_contains "$(md 'Text:
indented')" "<pre><code>"
suite "markdown: reference links"
it "an explicit label resolves"
assert_contains "$(md 'See [the docs][d].
[d]: http://example.com')" '<a href="http://example.com">the docs</a>'
it "the definition does not appear in the output"
assert_not_contains "$(md 'See [the docs][d].
[d]: http://example.com')" 'http://example.com<'
it "an implicit label reuses the link text"
assert_contains "$(md 'See [docs][].
[docs]: http://example.com')" '<a href="http://example.com">docs</a>'
it "a shortcut reference resolves"
assert_contains "$(md 'See [docs].
[docs]: http://example.com')" '<a href="http://example.com">docs</a>'
it "labels are case-insensitive"
assert_contains "$(md 'See [Docs][D].
[d]: http://example.com')" 'href="http://example.com"'
it "a title is carried through"
assert_contains "$(md 'See [x][d].
[d]: http://example.com "The Title"')" 'title="The Title"'
it "a definition before its use resolves"
assert_contains "$(md '[d]: http://example.com
See [x][d].')" 'href="http://example.com"'
it "an unresolved reference is left as literal text"
out=$(md 'See [x][nope].')
assert_not_contains "$out" '<a href="">'
it "inline links are unaffected"
assert_contains "$(md 'See [x](http://example.com).')" '<a href="http://example.com">x</a>'
it "footnotes still work alongside references"
out=$(md 'Text[^1] and [x][d].
[^1]: note
[d]: http://example.com')
assert_contains "$out" 'href="http://example.com"'
it "the footnote still renders"
assert_contains "$out" 'fn-item'
it "a definition inside a fence is literal"
assert_contains "$(md '```
[d]: http://example.com
```')" '[d]: http://example.com'
suite "markdown: MMD metadata block"
it "a metadata pair is wrapped, not stripped"
assert_contains "$(md 'Series: Web Typography
# Title')" 'data-key="Series"'
it "the value is exposed as an attribute"
assert_contains "$(md 'Series: Web Typography
# Title')" 'data-value="Web Typography"'
it "the original text is preserved verbatim"
assert_contains "$(md 'Series: Web Typography
# Title')" '>Series: Web Typography</p>'
it "the block carries a class the theme can target"
assert_contains "$(md 'Series: Web Typography
# Title')" 'class="meta"'
it "the body still renders"
# Headings carry an id now, so this asserts the text rather than the exact tag.
assert_contains "$(md 'Series: Web Typography
# Title')" ">Title</h1>"
it "multiple keys each get their own element"
out=$(md 'Series: Web Typography
Author: Pat
# Title')
assert_contains "$out" 'data-key="Series"'
it "the second key is wrapped too"
assert_contains "$out" 'data-key="Author"'
it "tags are wrapped like any other key"
assert_contains "$(md 'tags: alpha beta
# Title')" 'data-key="tags"'
it "a multi-word key is preserved verbatim"
assert_contains "$(md 'Base Header Level: 2
# Title')" 'data-key="Base Header Level"'
# MultiMarkdown makes no attempt to tell metadata from prose that happens to
# start Key: value -- position is the whole rule. Verified against
# multimarkdown 6.6.0, which renders "Note: this is prose." at the top of a
# file as <meta name="note">. Matching that rather than inventing a smarter
# heuristic keeps the parser predictable for anyone who knows MMD.
it "a leading Key: value line is metadata even if it reads as prose"
assert_contains "$(md 'Note: this is ordinary prose.
# Title')" 'data-key="Note"'
it "such a line keeps its text, so nothing is lost either way"
assert_contains "$(md 'Note: this is ordinary prose.
# Title')" '>Note: this is ordinary prose.</p>'
it "a block below the top of the file is prose"
assert_not_contains "$(md '# Title
Series: Web Typography')" 'data-key="Series"'
it "a document with no metadata emits no meta element"
assert_not_contains "$(md '# Title
Body.')" 'class="meta"'
it "attribute values are escaped"
assert_contains "$(md 'Series: A & B <x>
# Title')" 'data-value="A &amp; B &lt;x&gt;"'
suite "markdown: tables"
it "a basic table renders as a table"
assert_contains "$(md '| A | B |
|---|---|
| 1 | 2 |')" "<table>"
it "the header row becomes thead"
assert_contains "$(md '| A | B |
|---|---|
| 1 | 2 |')" "<thead>"
it "header cells are th"
assert_contains "$(md '| A | B |
|---|---|
| 1 | 2 |')" "<th"
it "body cells are td"
assert_contains "$(md '| A | B |
|---|---|
| 1 | 2 |')" "<td"
it "the separator row is not rendered"
assert_not_contains "$(md '| A | B |
|---|---|
| 1 | 2 |')" "---"
it "cell text is preserved"
out=$(md '| A | B |
|---|---|
| 1 | 2 |')
assert_contains "$out" "A"
it "left alignment is applied"
assert_contains "$(md '| L |
|:--|
| 1 |')" "text-align:left"
it "centre alignment is applied"
assert_contains "$(md '| C |
|:-:|
| 1 |')" "text-align:center"
it "right alignment is applied"
assert_contains "$(md '| R |
|--:|
| 1 |')" "text-align:right"
it "pipes at the row edges are optional"
assert_contains "$(md 'A | B
--- | ---
1 | 2')" "<table>"
it "inline markdown inside a cell is parsed"
assert_contains "$(md '| **bold** |
|---|
| a |')" "<strong>bold</strong>"
it "a link inside a cell is parsed"
assert_contains "$(md '| x |
|---|
| [y](http://e.com) |')" '<a href="http://e.com">y</a>'
it "multiple body rows all render"
out=$(md '| A |
|---|
| 1 |
| 2 |')
assert_eq "$(printf '%s' "$out" | grep -c '<td')" "2"
it "the table closes"
assert_contains "$(md '| A |
|---|
| 1 |')" "</table>"
it "text after a table is a separate paragraph"
assert_contains "$(md '| A |
|---|
| 1 |
After.')" "<p>After.</p>"
it "a pipe in prose is not a table"
assert_not_contains "$(md 'a | b in ordinary prose')" "<table>"
# A cell must handle raw HTML exactly as prose does. That used to mean both
# passed it through; it now means both escape it unless the caller asks for
# passthrough. What matters is that a cell is not a hole in whichever rule is
# in force.
it "raw html in a cell is escaped, as in prose"
assert_contains "$(md '| <b>x</b> |
|---|
| a |')" "&lt;b&gt;x&lt;/b&gt;"
it "raw html in a cell passes through when the caller asks, as in prose"
assert_contains "$(md_unsafe '| <b>x</b> |
|---|
| a |')" "<b>x</b>"
it "a table inside a fence is literal"
assert_contains "$(md '```
| A |
|---|
```')" "| A |"
suite "markdown: citations"
it "a citation renders an inline reference"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" 'class="citation"'
it "the citation is numbered"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" '(1)'
it "a locator is carried into the reference"
assert_contains "$(md 'Shown[p. 42][#smith].
[#smith]: John Smith. 2011.')" '(p. 42, 1)'
it "the definition does not render as body text"
assert_not_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" '<p>[#smith]:'
it "a citation list is emitted"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" 'class="citations"'
it "the citation body appears in the list"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" 'John Smith. 2011.'
it "markdown inside a citation body is parsed"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. *A Book*. 2011.')" '<em>A Book</em>'
it "the inline reference links to the entry"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" 'href="#cn:1"'
it "the entry links back to the reference"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" 'href="#cnref:1"'
it "numbering follows first use"
out=$(md 'A[#x] and B[#y].
[#x]: First.
[#y]: Second.')
assert_contains "$out" '(1)'
it "the second citation gets the next number"
assert_contains "$out" '(2)'
it "a repeated citation reuses its number"
out=$(md 'A[#x], B[#y], C[#x].
[#x]: First.
[#y]: Second.')
assert_eq "$(printf '%s' "$out" | grep -o 'class="citation">(1)' | wc -l | tr -d ' ')" "2"
it "footnotes still work alongside citations"
out=$(md 'A[^1] and B[#x].
[^1]: a note
[#x]: a citation')
assert_contains "$out" 'fn-item'
it "the citation still renders too"
assert_contains "$out" 'class="citations"'
it "a citation inside a fence is literal"
assert_contains "$(md '```
[#x]: literal
```')" '[#x]: literal'
suite "markdown: images"
# Images had no test at all, and the untested path was broken: alt text went
# into the attribute unescaped, so a quote in it closed the attribute early
# and anything after became markup. Found by auditing which emitted elements
# had coverage, not by anything failing.
it "an image renders with src and alt"
assert_eq "$(md '![alt text](/img/x.png)')" '<p><img src="/img/x.png" alt="alt text" /></p>'
it "an empty alt is still emitted"
# The attribute is required for the image to be announced as decorative
# rather than left unlabelled.
assert_eq "$(md '![](/img/x.png)')" '<p><img src="/img/x.png" alt="" /></p>'
it "a title is carried through"
assert_contains "$(md '![a](/x.png "the title")')" 'title="the title"'
it "a quote in alt text cannot close the attribute"
assert_contains "$(md '![say "hi"](/x.png)')" 'alt="say &quot;hi&quot;"'
it "a title is escaped as an attribute"
# The title lands inside title="...", so a quote in it must not close the
# attribute. Escaping of the definition-side title is part of the attribute
# safety work; this covers the inline form.
assert_contains "$(md '![a](/x.png "plain title")')" 'title="plain title"'
it "a quote in the url cannot close the attribute"
assert_contains "$(md '![a](/x.png?q="z")')" 'src="/x.png?q=&quot;z&quot;"'
it "a referenced image resolves through its definition"
assert_contains "$(md '![alt][r]
[r]: /img/ref.png')" '<img src="/img/ref.png" alt="alt" />'
suite "markdown: horizontal rules"
it "three dashes make a rule"
assert_contains "$(md 'a
---
b')" '<hr>'
it "three asterisks make a rule"
assert_contains "$(md 'a
***
b')" '<hr>'
it "three underscores make a rule"
assert_contains "$(md 'a
___
b')" '<hr>'
it "a rule separates the paragraphs around it"
out=$(md 'a
---
b')
assert_contains "$out" '<p>a</p>'
assert_contains "$out" '<p>b</p>'
suite "markdown: ordered lists"
it "a numbered list becomes an ol"
assert_contains "$(md '1. one
2. two')" '<ol>'
it "each entry becomes an li"
out=$(md '1. one
2. two')
assert_contains "$out" '<li>'
assert_contains "$out" 'one'
assert_contains "$out" 'two'
suite "markdown: blockquotes"
it "a quoted line becomes a blockquote"
assert_contains "$(md '> quoted')" '<blockquote>'
it "the quoted text is a paragraph inside it"
assert_contains "$(md '> quoted')" 'quoted</p>'
suite "markdown: autolinks"
it "an angle-bracketed url becomes a link"
assert_contains "$(md '<https://example.com>')" '<a href="https://example.com">https://example.com</a>'
it "a quote in an autolink cannot close the attribute"
assert_contains "$(md '<https://x.com/?a="b">')" 'href="https://x.com/?a=&quot;b&quot;"'
suite "markdown: html is escaped unless the caller opts in"
# The parser renders documents it did not write onto pages other people read,
# and it cannot tell an author's deliberate <div> from an injected <script>.
# So escaping is what you get without saying anything, and passthrough is what
# you have to ask for by name -- a caller who forgets the flag is safe, and a
# caller who wants raw HTML has said so somewhere greppable.
it "a script tag is shown as text, not run"
out=$(md '<script>alert(1)</script>')
assert_contains "$out" '&lt;script&gt;'
assert_not_contains "$out" '<script>'
it "an inline tag with a handler is shown as text"
out=$(md 'x <img src=y onerror=alert(1)>')
assert_not_contains "$out" '<img'
it "a block-level tag is shown as text"
out=$(md '<div class="x">
raw
</div>')
assert_not_contains "$out" '<div class="x">'
it "the flag brings passthrough back"
assert_contains "$(md_unsafe 'a <b>bold</b>')" '<b>bold</b>'
it "the flag does not disable attribute escaping"
# The flag is about whole tags the author wrote. A quote breaking out of an
# attribute the parser built is never something a caller asked for.
assert_not_contains "$(md_unsafe 'x[^f" onclick="alert(1)]')" '" onclick="alert(1)"'
suite "markdown: attributes cannot be broken out of"
# Each of these put user text into an attribute without escaping the quote, so
# the quote closed the attribute early and what followed became a live handler.
it "a citation id cannot carry a handler"
out=$(md 'x[#c" onclick="alert(1)]
[#c" onclick="alert(1)]: A source')
assert_not_contains "$out" '" onclick="alert(1)"'
assert_contains "$out" 'data-citation="c&quot;'
it "a footnote reference cannot carry a handler"
out=$(md 'x[^f" onclick="alert(1)]
[^f" onclick="alert(1)]: note')
assert_not_contains "$out" '" onclick="alert(1)"'
it "a footnote list entry cannot carry a handler"
# The marker and the list entry are built separately; both take the key.
out=$(md 'a[^k" onclick="alert(1)]
[^k" onclick="alert(1)]: note')
assert_not_contains "$out" 'id="fn-k" onclick='
it "a fence language cannot carry a handler"
out=$(md '```x" onload="alert(1)
code
```')
assert_not_contains "$out" '" onload="alert(1)"'
suite "markdown: urls that execute are dropped"
it "a javascript: link is emptied"
assert_not_contains "$(md '[x](javascript:alert(1))')" 'href="javascript:'
it "case does not smuggle it past"
assert_not_contains "$(md '[x](JaVaScRiPt:alert(1))')" 'alert(1)'
it "leading whitespace does not smuggle it past"
assert_not_contains "$(md '[x]( javascript:alert(1))')" 'alert(1)'
it "a data: image is emptied"
assert_not_contains "$(md '![y](data:text/html,xx)')" 'src="data:'
it "vbscript: is dropped too"
assert_not_contains "$(md '[z](vbscript:msgbox(1))')" 'href="vbscript:'
it "a reference definition cannot hide one"
assert_not_contains "$(md '[a][r]
[r]: javascript:alert(1)')" 'href="javascript:'
it "an ordinary link is untouched"
assert_contains "$(md '[ok](https://example.com)')" 'href="https://example.com"'
it "a relative link is untouched"
assert_contains "$(md '[ok](/local/page)')" 'href="/local/page"'
suite "markdown: percent-encoded schemes are decoded before the check"
# A browser percent-decodes an href before it decides what scheme the URL
# holds, so a check that reads the raw text reads something the browser never
# sees. Every one of these was emitted verbatim and ran on click.
it "a fully encoded scheme is caught"
assert_not_contains "$(md '[x](%6a%61%76%61%73%63%72%69%70%74:alert(1))')" '%6a%61%76%61'
it "a partly encoded scheme is caught"
assert_not_contains "$(md '[x](%6aavascript:alert(1))')" '%6aavascript'
it "a double-encoded scheme is caught"
# %256a is a percent sign that is itself encoded: one pass leaves %6a, so the
# decode has to run until the string stops changing.
assert_not_contains "$(md '[x](%256a%61%76%61%73%63%72%69%70%74:alert(1))')" 'alert(1)'
it "an encoded data: image is caught"
assert_not_contains "$(md '![y](%64%61%74%61:text/html,x)')" '%64%61%74%61'
it "a reference definition cannot hide an encoded scheme"
assert_not_contains "$(md '[a][r]
[r]: %6a%61%76%61%73%63%72%69%70%74:alert(1)')" 'alert(1)'
it "a legitimate percent escape survives into the output"
# The decode is done on a throwaway copy read only by the scheme test. The URL
# that is emitted is the one the author wrote, so a space encoded in a path is
# still encoded when it gets to the browser.
assert_contains "$(md '[ok](https://example.com/a%20b)')" 'href="https://example.com/a%20b"'
it "a percent escape in an image path survives too"
assert_contains "$(md '![i](/i/a%20b.png)')" 'src="/i/a%20b.png"'
it "the earlier vectors are still blocked"
assert_not_contains "$(md '[x](javascript:alert(1))')" 'href="javascript:'
assert_not_contains "$(md '[x](JaVaScRiPt:alert(1))')" 'alert(1)'
assert_not_contains "$(md '[x]( javascript:alert(1))')" 'alert(1)'
suite "markdown: decoding reveals letters that must be folded"
# The probe was lowercased once, before decoding. Decoding then produced
# letters that had never been folded -- "%4a" is lowercase already and decodes
# to "J" -- so "%4Aavascript:" arrived at the check as "Javascript:" and did
# not match a pattern written in lowercase. A browser reads a scheme
# case-insensitively, so it ran. The fold has to follow the decode that
# produced the letter, on every pass.
it "an uppercase escape at the start is caught"
assert_not_contains "$(md '[x](%4Aavascript:alert(1))')" '%4Aavascript'
it "a mixed-case pair of escapes is caught"
assert_not_contains "$(md '[x](%4a%61vascript:alert(1))')" 'alert(1)'
it "an uppercase escape in the middle is caught"
assert_not_contains "$(md '[x](jav%41script:alert(1))')" 'jav%41script'
it "an uppercase escape in another scheme is caught"
assert_not_contains "$(md '[x](%56%42script:alert(1))')" 'alert(1)'
it "uppercase hex in a real path is untouched"
# The decoder reads either case now, but it reads a throwaway copy. What is
# emitted is what the author wrote, so an uppercase escape in a genuine path
# survives byte for byte.
assert_contains "$(md '[ok](https://example.com/A%2FB)')" 'href="https://example.com/A%2FB"'
it "a utf-8 sequence in a path is untouched"
assert_contains "$(md '[ok](https://example.com/%E2%9C%93)')" 'href="https://example.com/%E2%9C%93"'
it "the decoder reads an uppercase pair on its own terms"
# sanitize_url folds before decoding, so the decoder is never handed
# uppercase hex in practice and narrowing this class back passes every other
# test here. It is still wrong: a decoder that reads %6a and not %6A is
# incorrect independently of what its one caller happens to do, and the next
# caller may not fold first. Exercised directly for that reason.
out=$(printf '%%4Aavascript\n' | "$AWK" -f "$REPO_ROOT/markdown.awk" -e 'BEGIN{ print pct_decode("%4Aavascript") }' 2>/dev/null </dev/null)
if [ "$out" = "Javascript" ]; then pass; else fail "decoder gave [$out], wanted [Javascript]"; fi
summary