Commit 5906358 h
Changes: 2 files +167 -3
Changes (Side-by-Side)
share/jenny/lib/md2html.awk +62 -3
function re_quote(s, out, i, c) { | |||
| 58 | } |
58 | } |
| 59 | |
59 | |
| 60 | function nextil(t) { |
60 | function nextil(t) { |
| 61 | if(!match(t, /[`<&\[*_\\-]|(!\[)|(\[\^)|(\[#)/)) |
||
| 61 | if(!match(t, /[`<&\[*_~\\-]|(!\[)|(\[\^)|(\[#)/)) |
||
| 62 | return t; |
62 | return t; |
| 63 | t1 = substr(t, 1, RSTART - 1); |
63 | t1 = substr(t, 1, RSTART - 1); |
| 64 | tag = substr(t, RSTART, RLENGTH); |
64 | tag = substr(t, RSTART, RLENGTH); |
function nextil(t) { | |||
| 263 | return t1 "<a href=\"" r "\">" nextil(linktext) "</a>" nextil(pt2); |
263 | return t1 "<a href=\"" r "\">" nextil(linktext) "</a>" nextil(pt2); |
| 264 | } |
264 | } |
| 265 | } |
265 | } |
| 266 | # Strikethrough |
||
| 267 | # |
||
| 268 | # Only the doubled form, which is what GFM defines and what authors |
||
| 269 | # write. A single ~ stays literal so that "~/path" and "approx ~5" are |
||
| 270 | # not silently turned into markup. An unpaired "~~" is left alone rather |
||
| 271 | # than opening a <del> that would run to the end of the document. |
||
| 272 | if(tag == "~"){ |
||
| 273 | if(sub(/^~/, "", t2)){ |
||
| 274 | if(match(t2, /~~/)){ |
||
| 275 | strike = substr(t2, 1, RSTART - 1); |
||
| 276 | # t1, tag and t2 are globals shared with every nextil frame, |
||
| 277 | # so parsing the struck text clobbers the tail still waiting |
||
| 278 | # here -- it came back with whatever the inner call left |
||
| 279 | # behind, dropping the rest of the line. The link handler |
||
| 280 | # above keeps its own copy for the same reason. |
||
| 281 | strike_t1 = t1; |
||
| 282 | strike_rest = substr(t2, RSTART + 2); |
||
| 283 | strike_done = nextil(strike); |
||
| 284 | return strike_t1 "<del>" strike_done "</del>" nextil(strike_rest); |
||
| 285 | } |
||
| 286 | return t1 "~~" nextil(t2); |
||
| 287 | } |
||
| 288 | return t1 "~" nextil(t2); |
||
| 289 | } |
||
| 266 | # Emphasis |
290 | # Emphasis |
| 267 | if(match(tag, /[*_]/)){ |
291 | if(match(tag, /[*_]/)){ |
| 268 | # An underscore between two word characters is literal, not an |
292 | # An underscore between two word characters is literal, not an |
function nextil(t) { | |||
| 272 | # the same exception, and only for _: intraword * still emphasises. |
296 | # the same exception, and only for _: intraword * still emphasises. |
| 273 | if(tag == "_" && match(t1, /[A-Za-z0-9]$/) && match(t2, /^[A-Za-z0-9]/)) |
297 | if(tag == "_" && match(t1, /[A-Za-z0-9]$/) && match(t2, /^[A-Za-z0-9]/)) |
| 274 | return t1 tag nextil(t2); |
298 | return t1 tag nextil(t2); |
| 299 | # An underscore that is the whole content of an HTML element is |
||
| 300 | # literal: <span class="us">_</span> is a way of writing a visible |
||
| 301 | # underscore, not emphasis. Without this it opened an <em> that |
||
| 302 | # swallowed the markup after it, and two such underscores paired with |
||
| 303 | # each other across the tags between them, emitting mis-nested |
||
| 304 | # "<b><em></b> ... <b></em></b>". CommonMark reaches the same result |
||
| 305 | # by its flanking rules: a delimiter preceded by punctuation and |
||
| 306 | # followed by punctuation is neither opening nor closing. |
||
| 307 | if(tag == "_" && t1 == "" && match(t2, /^</) && stag[ns] != "_" && stag[ns] != "__") |
||
| 308 | return t1 tag nextil(t2); |
||
| 275 | ntag = tag; |
309 | ntag = tag; |
| 276 | # `tag` is a literal * or _, so it must be escaped before being |
310 | # `tag` is a literal * or _, so it must be escaped before being |
| 277 | # interpolated into a regex: "^" "*" is not a valid regex. |
311 | # interpolated into a regex: "^" "*" is not a valid regex. |
fence { | |||
| 632 | # List and quote blocks |
666 | # List and quote blocks |
| 633 | |
667 | |
| 634 | # Remove indentation |
668 | # Remove indentation |
| 669 | # |
||
| 670 | # One nesting level is peeled per step. A tab or four spaces was the only unit |
||
| 671 | # understood here, but a child is conventionally indented to its parent's |
||
| 672 | # content column, which for "- " is two spaces -- so two-space nesting, the |
||
| 673 | # common form, was invisible: the item regex below tolerates up to three |
||
| 674 | # leading spaces as noise, so a two-space child matched as a sibling and the |
||
| 675 | # whole list came out flat. |
||
| 676 | # |
||
| 677 | # Two spaces is the step, not an alternative to four. Written as an |
||
| 678 | # alternation with four spaces first, a four-space indent matched the wide |
||
| 679 | # branch and peeled a single level, so three levels of four-space nesting |
||
| 680 | # collapsed into two. A tab remains one whole level. |
||
| 635 | { |
681 | { |
| 636 | for(nnl = 0; nnl < nl; nnl++) |
682 | for(nnl = 0; nnl < nl; nnl++) |
| 637 | if((match(block[nnl + 1], /[ou]l/) && !sub(/^( | )/, "")) || \ |
||
| 683 | if((match(block[nnl + 1], /[ou]l/) && !sub(/^( | )/, "")) || \ |
||
| 638 | (block[nnl + 1] == "blockquote" && !sub(/^> ?/, ""))) |
684 | (block[nnl + 1] == "blockquote" && !sub(/^> ?/, ""))) |
| 639 | break; |
685 | break; |
| 640 | } |
686 | } |
nnl != nl || nblock[nl] != block[nl] { | |||
| 700 | par = (match(b, /[ou]l/)) ? "" : "p"; |
746 | par = (match(b, /[ou]l/)) ? "" : "p"; |
| 701 | } |
747 | } |
| 702 | nnl < nl || (nnl == nl && nblock[nl] != block[nl]) { |
748 | nnl < nl || (nnl == nl && nblock[nl] != block[nl]) { |
| 703 | for(; nl > nnl || (nnl == nl && pblock[nl] != block[nl]); nl--){ |
||
| 749 | # The second clause compares against nblock, the block kind this line |
||
| 750 | # wants, not pblock -- which is assigned nowhere and so was always empty, |
||
| 751 | # making the test true for any open list. Once the walk reached nnl it |
||
| 752 | # therefore kept going and closed the outer <ul> along with the nested one, |
||
| 753 | # leaving a dedented sibling no list to rejoin. Harmless while nesting did |
||
| 754 | # not work at all; load-bearing now that it does. |
||
| 755 | for(; nl > nnl || (nl > 0 && nnl == nl && nblock[nl] != block[nl]); nl--){ |
||
| 704 | if(match(block[nl], /[ou]l/)) |
756 | if(match(block[nl], /[ou]l/)) |
| 705 | oprint("</li>"); |
757 | oprint("</li>"); |
| 706 | oprint("</" block[nl] ">"); |
758 | oprint("</" block[nl] ">"); |
| 707 | } |
759 | } |
| 760 | # A sibling after a nested list needs a fresh <li> in the list we just came |
||
| 761 | # back to. The "</li><li>" rule earlier only fires when the item is already |
||
| 762 | # at its own depth (nnl == nl), which is false on a dedent. |
||
| 763 | if(newli && nnl > 0 && nnl == nl && match(block[nl], /[ou]l/) && block[nl] == nblock[nl]){ |
||
| 764 | oprint("</li><li>"); |
||
| 765 | newli = 0; |
||
| 766 | } |
||
| 708 | } |
767 | } |
| 709 | nnl > nl { |
768 | nnl > nl { |
| 710 | for(; nl < nnl; nl++){ |
769 | for(; nl < nnl; nl++){ |
test/regressions.sh +105 -0
assert_contains "$output" "extensionless" | |||
| 671 | it "nothing is written when validation fails" |
671 | it "nothing is written when validation fails" |
| 672 | assert_file_absent "$ub/.dist/index.html" |
672 | assert_file_absent "$ub/.dist/index.html" |
| 673 | |
673 | |
| 674 | suite "nested lists" |
||
| 675 | |
||
| 676 | # The nesting machinery existed but the indent unit was a tab or four spaces, |
||
| 677 | # while a child is conventionally indented to its parent's content column -- |
||
| 678 | # two spaces for "- ". Two-space nesting, the common form, came out flat. |
||
| 679 | |
||
| 680 | md_render() { |
||
| 681 | printf '%s\n' "$1" | "$AWK" -f "$JENNY_SHARE_DIR/lib/md2html.awk" |
||
| 682 | } |
||
| 683 | |
||
| 684 | it "a two-space child nests inside its parent" |
||
| 685 | out=$(md_render '- a |
||
| 686 | - b |
||
| 687 | - c') |
||
| 688 | assert_contains "$out" '<li> |
||
| 689 | a |
||
| 690 | <ul>' |
||
| 691 | |
||
| 692 | it "a sibling after a nested list rejoins the outer list" |
||
| 693 | # The closer walked past the target depth because it compared against pblock, |
||
| 694 | # which is assigned nowhere and so was always empty. It closed the outer <ul> |
||
| 695 | # too, and the sibling opened a second one. |
||
| 696 | assert_eq "$(printf '%s' "$out" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "2" |
||
| 697 | assert_eq "$(printf '%s' "$out" | "$GREP" -o '</ul>' | wc -l | tr -d ' ')" "2" |
||
| 698 | |
||
| 699 | it "three levels of two-space nesting all nest" |
||
| 700 | deep=$(md_render '- a |
||
| 701 | - b |
||
| 702 | - c |
||
| 703 | - d') |
||
| 704 | assert_eq "$(printf '%s' "$deep" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "3" |
||
| 705 | |
||
| 706 | it "four-space nesting still works" |
||
| 707 | # Two spaces is the step, not an alternative to four: written as an |
||
| 708 | # alternation with four first, a four-space indent matched the wide branch and |
||
| 709 | # peeled only one level, collapsing three levels into two. |
||
| 710 | four=$(md_render '- a |
||
| 711 | - b |
||
| 712 | - c') |
||
| 713 | assert_eq "$(printf '%s' "$four" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "2" |
||
| 714 | |
||
| 715 | it "a flat list is unaffected" |
||
| 716 | flat=$(md_render '- a |
||
| 717 | - b |
||
| 718 | - c') |
||
| 719 | assert_eq "$(printf '%s' "$flat" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "1" |
||
| 720 | assert_eq "$(printf '%s' "$flat" | "$GREP" -o '<li>' | wc -l | tr -d ' ')" "3" |
||
| 721 | |
||
| 722 | it "ordered lists nest too" |
||
| 723 | onest=$(md_render '1. a |
||
| 724 | 1. b |
||
| 725 | 2. c') |
||
| 726 | assert_eq "$(printf '%s' "$onest" | "$GREP" -o '<ol>' | wc -l | tr -d ' ')" "2" |
||
| 727 | |
||
| 728 | suite "strikethrough" |
||
| 729 | |
||
| 730 | it "doubled tildes become del" |
||
| 731 | assert_contains "$(md_render 'Plain ~~struck~~ here.')" '<del>struck</del>' |
||
| 732 | |
||
| 733 | it "text after a strike containing emphasis is not lost" |
||
| 734 | # nextil shares t1/tag/t2 globally, so parsing the struck text clobbered the |
||
| 735 | # tail still waiting in the outer frame -- the rest of the line vanished. |
||
| 736 | assert_contains "$(md_render '~~*it*~~ TAIL')" '<del><em>it</em></del> TAIL' |
||
| 737 | |
||
| 738 | it "two strikes on one line both close" |
||
| 739 | two=$(md_render '~~one~~ mid ~~*two*~~ end') |
||
| 740 | assert_eq "$(printf '%s' "$two" | "$GREP" -o '<del>' | wc -l | tr -d ' ')" "2" |
||
| 741 | assert_contains "$two" 'end' |
||
| 742 | |
||
| 743 | it "a single tilde stays literal" |
||
| 744 | # "~/path" and "approx ~5" must survive untouched. |
||
| 745 | assert_contains "$(md_render 'Path ~/x and approx ~5 items.')" '~/x and approx ~5' |
||
| 746 | |
||
| 747 | it "an unpaired double tilde stays literal" |
||
| 748 | # Rather than opening a <del> that runs to the end of the document. |
||
| 749 | assert_contains "$(md_render 'Unpaired ~~ stays.')" 'Unpaired ~~ stays.' |
||
| 750 | |
||
| 751 | it "tildes inside code are untouched" |
||
| 752 | assert_contains "$(md_render 'A `~~code~~` span.')" '<code>~~code~~</code>' |
||
| 753 | |
||
| 754 | suite "underscores in inline HTML" |
||
| 755 | |
||
| 756 | it "an underscore that is a tag's whole content is literal" |
||
| 757 | # <span class="us">_</span> writes a visible underscore. It opened an <em> |
||
| 758 | # that swallowed the markup after it. |
||
| 759 | assert_contains "$(md_render 'x <span class="us">_</span> y')" '<span class="us">_</span>' |
||
| 760 | |
||
| 761 | it "two such underscores do not pair across their tags" |
||
| 762 | # They emitted mis-nested "<b><em></b> ... <b></em></b>". |
||
| 763 | pair=$(md_render 'A <b>_</b> and <b>_</b> here.') |
||
| 764 | assert_contains "$pair" '<b>_</b> and <b>_</b>' |
||
| 765 | assert_not_contains "$pair" '<em>' |
||
| 766 | |
||
| 767 | it "real emphasis still works" |
||
| 768 | assert_contains "$(md_render 'Real _emphasis_ here.')" '<em>emphasis</em>' |
||
| 769 | |
||
| 770 | it "bold still works" |
||
| 771 | assert_contains "$(md_render 'Real __bold__ here.')" '<strong>bold</strong>' |
||
| 772 | |
||
| 773 | it "emphasis spanning a tag still works" |
||
| 774 | assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more</em>' |
||
| 775 | |
||
| 776 | it "intraword underscores are still literal" |
||
| 777 | assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name' |
||
| 778 | |
||
| 674 | suite "MMD metadata end to end" |
779 | suite "MMD metadata end to end" |
| 675 | |
780 | |
| 676 | it "a metadata pair survives into the rendered post" |
781 | it "a metadata pair survives into the rendered post" |
Raw
commit 59063589ec9bdb343e1ce47f97aa9b4b46b67584
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date: Tue Aug 25 22:36:34 2026 +0800
Fix nested lists, add strikethrough, stop stray underscore emphasis
Found by checking whether the mdd corpus (26 files) would survive jenny's
parser before porting it.
Nested lists were flattened. The nesting machinery was there, but a level
was peeled per tab or four spaces, while a child is conventionally
indented to its parent's content column -- two spaces for "- ". The item
regex tolerates up to three leading spaces as noise, so a two-space child
matched as a sibling and the list came out flat. Two spaces is now the
step; a tab remains one level. Written first as an alternation with four
spaces, a four-space indent matched the wide branch and peeled a single
level, collapsing three levels into two -- so the step is two spaces and
four spaces is simply two of them.
A sibling after a nested list opened a second <ul> instead of rejoining
the first. The closing loop compared against pblock, which is assigned
nowhere in the program and so was always empty, making the test true for
any open list: once the walk reached the target depth it kept going and
closed the outer list as well. Dead state that was harmless only because
nesting never worked; load-bearing now that it does. It compares against
nblock, the block kind the line wants, as the rest of the machine does.
Strikethrough was unimplemented -- ~~text~~ passed through as literal
tildes, 23 times in the corpus. Only the doubled form is markup: a single
~ stays literal so "~/path" and "approx ~5" survive, and an unpaired ~~ is
left alone rather than opening a <del> that runs to the end of the
document. The first version dropped the rest of the line whenever the
struck text contained emphasis: nextil shares t1/tag/t2 across frames, so
parsing the inner text clobbered the tail still waiting in the outer one.
The link handler keeps its own copy for the same reason.
An underscore that is a tag's whole content is now literal.
<span class="us">_</span> is a way of writing a visible underscore; it
opened an <em> that swallowed the following markup, and two of them paired
with each other across the tags between, emitting mis-nested
"<b><em></b> ... <b></em></b>". CommonMark reaches the same result through
its flanking rules. Intraword underscores, real emphasis, bold, and
emphasis spanning a tag are unaffected.
Verified against the corpus: all 26 files parse, all 23 strikethroughs
convert, lists nest, and one <em> imbalance remains -- an authoring typo
in notes/open-questions.md, where "~~**text**~~ *(Resolved...)" leaves a
fifth asterisk unclosed. That file is not published by the existing site.
Tests: 18 covering both indent widths, three-level nesting, dedent to a
sibling, flat lists and ordered lists; strikethrough with nested emphasis,
two per line, single tildes, unpaired markers and tildes in code; and the
underscore cases including the ones that must keep working. Checked by
mutation -- reverting each of the four fixes in turn -- all caught. Two
assertions first used grep -c, which counts matching lines rather than
occurrences, and passed against correct output for the wrong reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnSAstgnDYdKPwLGv1YZE5
diff --git a/share/jenny/lib/md2html.awk b/share/jenny/lib/md2html.awk
index c214f40..5b0a8c1 100755
--- a/share/jenny/lib/md2html.awk
+++ b/share/jenny/lib/md2html.awk
@@ -58,7 +58,7 @@ function re_quote(s, out, i, c) {
}
function nextil(t) {
- if(!match(t, /[`<&\[*_\\-]|(!\[)|(\[\^)|(\[#)/))
+ if(!match(t, /[`<&\[*_~\\-]|(!\[)|(\[\^)|(\[#)/))
return t;
t1 = substr(t, 1, RSTART - 1);
tag = substr(t, RSTART, RLENGTH);
@@ -263,6 +263,30 @@ function nextil(t) {
return t1 "<a href=\"" r "\">" nextil(linktext) "</a>" nextil(pt2);
}
}
+ # Strikethrough
+ #
+ # Only the doubled form, which is what GFM defines and what authors
+ # write. A single ~ stays literal so that "~/path" and "approx ~5" are
+ # not silently turned into markup. An unpaired "~~" is left alone rather
+ # than opening a <del> that would run to the end of the document.
+ if(tag == "~"){
+ if(sub(/^~/, "", t2)){
+ if(match(t2, /~~/)){
+ strike = substr(t2, 1, RSTART - 1);
+ # t1, tag and t2 are globals shared with every nextil frame,
+ # so parsing the struck text clobbers the tail still waiting
+ # here -- it came back with whatever the inner call left
+ # behind, dropping the rest of the line. The link handler
+ # above keeps its own copy for the same reason.
+ strike_t1 = t1;
+ strike_rest = substr(t2, RSTART + 2);
+ strike_done = nextil(strike);
+ return strike_t1 "<del>" strike_done "</del>" nextil(strike_rest);
+ }
+ return t1 "~~" nextil(t2);
+ }
+ return t1 "~" nextil(t2);
+ }
# Emphasis
if(match(tag, /[*_]/)){
# An underscore between two word characters is literal, not an
@@ -272,6 +296,16 @@ function nextil(t) {
# the same exception, and only for _: intraword * still emphasises.
if(tag == "_" && match(t1, /[A-Za-z0-9]$/) && match(t2, /^[A-Za-z0-9]/))
return t1 tag nextil(t2);
+ # An underscore that is the whole content of an HTML element is
+ # literal: <span class="us">_</span> is a way of writing a visible
+ # underscore, not emphasis. Without this it opened an <em> that
+ # swallowed the markup after it, and two such underscores paired with
+ # each other across the tags between them, emitting mis-nested
+ # "<b><em></b> ... <b></em></b>". CommonMark reaches the same result
+ # by its flanking rules: a delimiter preceded by punctuation and
+ # followed by punctuation is neither opening nor closing.
+ if(tag == "_" && t1 == "" && match(t2, /^</) && stag[ns] != "_" && stag[ns] != "__")
+ return t1 tag nextil(t2);
ntag = tag;
# `tag` is a literal * or _, so it must be escaped before being
# interpolated into a regex: "^" "*" is not a valid regex.
@@ -632,9 +666,21 @@ fence {
# List and quote blocks
# Remove indentation
+#
+# One nesting level is peeled per step. A tab or four spaces was the only unit
+# understood here, but a child is conventionally indented to its parent's
+# content column, which for "- " is two spaces -- so two-space nesting, the
+# common form, was invisible: the item regex below tolerates up to three
+# leading spaces as noise, so a two-space child matched as a sibling and the
+# whole list came out flat.
+#
+# Two spaces is the step, not an alternative to four. Written as an
+# alternation with four spaces first, a four-space indent matched the wide
+# branch and peeled a single level, so three levels of four-space nesting
+# collapsed into two. A tab remains one whole level.
{
for(nnl = 0; nnl < nl; nnl++)
- if((match(block[nnl + 1], /[ou]l/) && !sub(/^( | )/, "")) || \
+ if((match(block[nnl + 1], /[ou]l/) && !sub(/^( | )/, "")) || \
(block[nnl + 1] == "blockquote" && !sub(/^> ?/, "")))
break;
}
@@ -700,11 +746,24 @@ nnl != nl || nblock[nl] != block[nl] {
par = (match(b, /[ou]l/)) ? "" : "p";
}
nnl < nl || (nnl == nl && nblock[nl] != block[nl]) {
- for(; nl > nnl || (nnl == nl && pblock[nl] != block[nl]); nl--){
+ # The second clause compares against nblock, the block kind this line
+ # wants, not pblock -- which is assigned nowhere and so was always empty,
+ # making the test true for any open list. Once the walk reached nnl it
+ # therefore kept going and closed the outer <ul> along with the nested one,
+ # leaving a dedented sibling no list to rejoin. Harmless while nesting did
+ # not work at all; load-bearing now that it does.
+ for(; nl > nnl || (nl > 0 && nnl == nl && nblock[nl] != block[nl]); nl--){
if(match(block[nl], /[ou]l/))
oprint("</li>");
oprint("</" block[nl] ">");
}
+ # A sibling after a nested list needs a fresh <li> in the list we just came
+ # back to. The "</li><li>" rule earlier only fires when the item is already
+ # at its own depth (nnl == nl), which is false on a dedent.
+ if(newli && nnl > 0 && nnl == nl && match(block[nl], /[ou]l/) && block[nl] == nblock[nl]){
+ oprint("</li><li>");
+ newli = 0;
+ }
}
nnl > nl {
for(; nl < nnl; nl++){
diff --git a/test/regressions.sh b/test/regressions.sh
index 49218b0..72f767b 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -671,6 +671,111 @@ assert_contains "$output" "extensionless"
it "nothing is written when validation fails"
assert_file_absent "$ub/.dist/index.html"
+suite "nested lists"
+
+# The nesting machinery existed but the indent unit was a tab or four spaces,
+# while a child is conventionally indented to its parent's content column --
+# two spaces for "- ". Two-space nesting, the common form, came out flat.
+
+md_render() {
+ printf '%s\n' "$1" | "$AWK" -f "$JENNY_SHARE_DIR/lib/md2html.awk"
+}
+
+it "a two-space child nests inside its parent"
+out=$(md_render '- a
+ - b
+- c')
+assert_contains "$out" '<li>
+a
+<ul>'
+
+it "a sibling after a nested list rejoins the outer list"
+# The closer walked past the target depth because it compared against pblock,
+# which is assigned nowhere and so was always empty. It closed the outer <ul>
+# too, and the sibling opened a second one.
+assert_eq "$(printf '%s' "$out" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "2"
+assert_eq "$(printf '%s' "$out" | "$GREP" -o '</ul>' | wc -l | tr -d ' ')" "2"
+
+it "three levels of two-space nesting all nest"
+deep=$(md_render '- a
+ - b
+ - c
+- d')
+assert_eq "$(printf '%s' "$deep" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "3"
+
+it "four-space nesting still works"
+# Two spaces is the step, not an alternative to four: written as an
+# alternation with four first, a four-space indent matched the wide branch and
+# peeled only one level, collapsing three levels into two.
+four=$(md_render '- a
+ - b
+- c')
+assert_eq "$(printf '%s' "$four" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "2"
+
+it "a flat list is unaffected"
+flat=$(md_render '- a
+- b
+- c')
+assert_eq "$(printf '%s' "$flat" | "$GREP" -o '<ul>' | wc -l | tr -d ' ')" "1"
+assert_eq "$(printf '%s' "$flat" | "$GREP" -o '<li>' | wc -l | tr -d ' ')" "3"
+
+it "ordered lists nest too"
+onest=$(md_render '1. a
+ 1. b
+2. c')
+assert_eq "$(printf '%s' "$onest" | "$GREP" -o '<ol>' | wc -l | tr -d ' ')" "2"
+
+suite "strikethrough"
+
+it "doubled tildes become del"
+assert_contains "$(md_render 'Plain ~~struck~~ here.')" '<del>struck</del>'
+
+it "text after a strike containing emphasis is not lost"
+# nextil shares t1/tag/t2 globally, so parsing the struck text clobbered the
+# tail still waiting in the outer frame -- the rest of the line vanished.
+assert_contains "$(md_render '~~*it*~~ TAIL')" '<del><em>it</em></del> TAIL'
+
+it "two strikes on one line both close"
+two=$(md_render '~~one~~ mid ~~*two*~~ end')
+assert_eq "$(printf '%s' "$two" | "$GREP" -o '<del>' | wc -l | tr -d ' ')" "2"
+assert_contains "$two" 'end'
+
+it "a single tilde stays literal"
+# "~/path" and "approx ~5" must survive untouched.
+assert_contains "$(md_render 'Path ~/x and approx ~5 items.')" '~/x and approx ~5'
+
+it "an unpaired double tilde stays literal"
+# Rather than opening a <del> that runs to the end of the document.
+assert_contains "$(md_render 'Unpaired ~~ stays.')" 'Unpaired ~~ stays.'
+
+it "tildes inside code are untouched"
+assert_contains "$(md_render 'A `~~code~~` span.')" '<code>~~code~~</code>'
+
+suite "underscores in inline HTML"
+
+it "an underscore that is a tag's whole content is literal"
+# <span class="us">_</span> writes a visible underscore. It opened an <em>
+# that swallowed the markup after it.
+assert_contains "$(md_render 'x <span class="us">_</span> y')" '<span class="us">_</span>'
+
+it "two such underscores do not pair across their tags"
+# They emitted mis-nested "<b><em></b> ... <b></em></b>".
+pair=$(md_render 'A <b>_</b> and <b>_</b> here.')
+assert_contains "$pair" '<b>_</b> and <b>_</b>'
+assert_not_contains "$pair" '<em>'
+
+it "real emphasis still works"
+assert_contains "$(md_render 'Real _emphasis_ here.')" '<em>emphasis</em>'
+
+it "bold still works"
+assert_contains "$(md_render 'Real __bold__ here.')" '<strong>bold</strong>'
+
+it "emphasis spanning a tag still works"
+assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more</em>'
+
+it "intraword underscores are still literal"
+assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name'
+
suite "MMD metadata end to end"
it "a metadata pair survives into the rendered post"