prevent widows, as the second feature package

The last two words of a paragraph or heading are joined by a non-breaking space,
so one word never falls alone onto its own line. Deferred from entry 11 for the
right reason: over rendered HTML this cannot tell prose from an escaped code span,
so it had to wait for a tree transform.

The interesting failure is worth keeping: written against goldmark alone it passed
six tests, and did nothing in the real engine. The typographer splits a text run
wherever it looks for a substitution, so a paragraph ending "hand." arrives as two
text nodes and the last of them holds no space at all. A version that inspects
only the last child therefore finds nothing to join. It now takes the whole
trailing run of text nodes, stopping at a line break or any markup, and there is a
regression test that builds both extensions together — the only configuration that
would have caught it.

The joined text becomes a String node, which carries its own bytes: a segment is
an offset into bytes every node shares, so editing the source in place is not
possible. That path still escapes, and a test says so, since otherwise this
transform would be an injection route.

PhaseMarkup is now empty in extensions.md, and honestly so: everything expected
there turned out to belong either earlier or later.
This commit is contained in:
2026-07-31 02:12:08 +06:00
parent 0bffef2aac
commit ff6272974e
7 changed files with 215 additions and 8 deletions
+95
View File
@@ -0,0 +1,95 @@
package widows
import (
"strings"
"testing"
"github.com/yuin/goldmark"
gmext "github.com/yuin/goldmark/extension"
)
const nb = "\u00a0"
func convert(t *testing.T, markdown string) string {
t.Helper()
md := goldmark.New(goldmark.WithExtensions(New()))
var out strings.Builder
if err := md.Convert([]byte(markdown), &out); err != nil {
t.Fatal(err)
}
return out.String()
}
func TestTheLastTwoWordsStayTogether(t *testing.T) {
got := convert(t, "The rain did not stop for nine days.\n")
if !strings.Contains(got, "nine"+nb+"days.") {
t.Errorf("expected a non-breaking space before the last word:\n%q", got)
}
if strings.Count(got, nb) != 1 {
t.Errorf("exactly one space should change, got %d:\n%q", strings.Count(got, nb), got)
}
}
func TestHeadingsGetItToo(t *testing.T) {
got := convert(t, "## The Long Monsoon\n")
if !strings.Contains(got, "Long"+nb+"Monsoon") {
t.Errorf("a heading widow is the ugliest one:\n%q", got)
}
}
func TestNothingToJoinIsLeftAlone(t *testing.T) {
for _, markdown := range []string{"Word\n", "\n"} {
got := convert(t, markdown)
if strings.Contains(got, nb) {
t.Errorf("%q should be untouched, got %q", markdown, got)
}
}
}
func TestABlockEndingInMarkupIsLeftAlone(t *testing.T) {
// The last "word" is a construct, not a word. Reaching inside markup for a typographic nicety is how a
// transform starts corrupting content.
for _, markdown := range []string{
"Run it with `khosra -site`\n",
"Read more [in the archive](/posts/)\n",
"It was *raining*\n",
} {
got := convert(t, markdown)
if strings.Contains(got, nb) {
t.Errorf("%q ends in markup and should be untouched, got %q", markdown, got)
}
}
}
func TestCodeSpansAreNeverEdited(t *testing.T) {
got := convert(t, "Run `go test ./...` and then read the output.\n")
if strings.Contains(got, "go"+nb+"test") || strings.Contains(got, "test"+nb) {
t.Errorf("a code span must survive byte for byte:\n%q", got)
}
if !strings.Contains(got, "the"+nb+"output.") {
t.Errorf("the prose after it should still be joined:\n%q", got)
}
}
func TestTextIsStillEscaped(t *testing.T) {
// The joined text becomes a String node, which is a different render path — it must escape like any
// other text, or this transform would be an injection route.
got := convert(t, "Compare a < b and c > d.\n")
if strings.Contains(got, "a < b") || !strings.Contains(got, "&lt;") {
t.Errorf("a rewritten run must stay escaped:\n%q", got)
}
}
func TestItStillWorksBesideTheTypographer(t *testing.T) {
// The bug this exists to catch: the typographer splits a text run wherever it looks for a substitution,
// so a paragraph ending "hand." arrives as two text nodes and the last holds no space. A version that
// only inspected the last child passed every test above and did nothing in the real engine.
md := goldmark.New(goldmark.WithExtensions(gmext.Typographer, New()))
var out strings.Builder
if err := md.Convert([]byte("Built by hand.\n"), &out); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "by"+nb+"hand.") {
t.Errorf("widows must survive being combined with other extensions:\n%q", out.String())
}
}