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()) } }