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