delete the widows feature; line breaking is CSS

The human asked whether widow prevention belonged in the backend at all. It did
not, and it broke two rules already written down: the theme contract says the
engine decides nothing about how something looks, and ADR-0034 says authored body
text is the author's — while this inserted U+00A0 into that text.

The practical harm follows from the layer error rather than from a coding mistake.
The engine cannot see the line box, so joining the last two words is a guess that
can overflow a narrow viewport, and a reader copying the paragraph gets a
non-breaking space in their clipboard. `text-wrap: pretty` and `text-wrap: balance`
in the reference stylesheet know the line box and need no bytes in the content.

108 lines of engine deleted for one CSS declaration. The typographer stays: turning
`--` into an en dash is a text transformation no stylesheet can express, which is
exactly the distinction the new layer test draws.

Also worth recording: this took the Extensions counter from 3 back to 2. A threshold
reached by a feature that should not have existed was never a threshold.
This commit is contained in:
Claude Opus 5
2026-07-31 12:47:03 +06:00
committed by bdeshi
parent cf65c82df6
commit b574800adb
10 changed files with 43 additions and 222 deletions
-7
View File
@@ -1,7 +0,0 @@
// Package widows keeps the last word of a paragraph or heading from falling alone onto its own line.
//
// Contributes: a Markdown AST transformer (PhaseParse).
// Cascade keys: none.
// Contract fields: none — it changes one space in the text, and nothing a theme reads.
// Not doing: hyphenation, orphans, balancing headings across lines — those are the theme's typography.
package widows
-101
View File
@@ -1,101 +0,0 @@
package widows
import (
"bytes"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// nbsp is the space that will not break. Written as bytes because it replaces bytes in the text.
var nbsp = []byte(" ")
// New returns the Markdown extension.
func New() goldmark.Extender { return extension{} }
type extension struct{}
func (extension) Extend(md goldmark.Markdown) {
md.Parser().AddOptions(parser.WithASTTransformers(util.Prioritized(transformer{}, 200)))
}
// transformer joins the last two words of every paragraph and heading.
//
// It works on the parsed tree rather than on rendered HTML, which is the whole reason this waited for the
// shortcode machinery: a pass over HTML cannot tell prose from an escaped code span, and would happily edit
// the inside of one (content-model.md).
type transformer struct{}
func (transformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
source := reader.Source()
err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch n.Kind() {
case ast.KindParagraph, ast.KindHeading:
join(n, source)
}
return ast.WalkContinue, nil
})
if err != nil {
// Walk only fails if this function does, and it does not.
return
}
}
// join replaces the last space of a block's final line with a non-breaking one.
//
// Only plain text qualifies. A block ending in a code span, a link or emphasis is left alone: the last
// "word" is then a construct rather than a word, and joining it to the one before would mean reaching inside
// markup for a typographic nicety.
func join(block ast.Node, source []byte) {
run := finalLine(block)
if len(run) == 0 {
return
}
var line []byte
for _, t := range run {
line = append(line, t.Segment.Value(source)...)
}
i := bytes.LastIndexByte(line, ' ')
if i < 0 || i == len(line)-1 {
// One word, or a trailing space with nothing after it: nothing to keep together.
return
}
// A String node carries its own bytes, so the text can differ from the source. Editing the segment is
// impossible — a segment is an offset into bytes every other node shares.
joined := make([]byte, 0, len(line)+len(nbsp))
joined = append(joined, line[:i]...)
joined = append(joined, nbsp...)
joined = append(joined, line[i+1:]...)
block.ReplaceChild(block, run[0], ast.NewString(joined))
for _, t := range run[1:] {
block.RemoveChild(block, t)
}
}
// finalLine is the block's last line as the consecutive text nodes that make it up.
//
// Several nodes, not one: another extension may have split the run. The typographer splits it 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 — which is exactly how the first version of this passed its own tests and did
// nothing in the assembled engine.
func finalLine(block ast.Node) []*ast.Text {
var run []*ast.Text
last := block.LastChild()
for n := last; n != nil; n = n.PreviousSibling() {
t, isText := n.(*ast.Text)
if !isText {
break
}
if n != last && (t.SoftLineBreak() || t.HardLineBreak()) {
break // the final line starts after this node
}
run = append([]*ast.Text{t}, run...)
}
return run
}
-95
View File
@@ -1,95 +0,0 @@
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())
}
}
+3 -1
View File
@@ -1,7 +1,9 @@
/* Reference theme: legibility only, no design opinions (ADR-0026). */
html { font-family: Georgia, serif; line-height: 1.6; color: #1a1a1a; background: #fdfdfb; }
main { max-width: 34rem; margin: 3rem auto; padding: 0 1rem; }
h1, h2, h3 { line-height: 1.25; font-weight: 600; }
h1, h2, h3 { line-height: 1.25; font-weight: 600; text-wrap: balance; }
/* Line breaking belongs to the browser, which knows the line box no engine-side guess can see (ADR-0045). */
p, li, figcaption { text-wrap: pretty; }
a { color: #1a4d7a; }
img { max-width: 100%; height: auto; }
pre, code { font-family: ui-monospace, monospace; font-size: 0.9em; }