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:
@@ -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, "<") {
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user