Files
khosra/internal/ext/widows/widows.go
T
bdeshi ff6272974e 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.
2026-07-31 02:12:08 +06:00

102 lines
3.3 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}