internal/render/markdown/extensions/smartypants.go

package extensions

import (
	"bytes"
	"unicode"
	"unicode/utf8"
)

// SmartyPants replaces ascii punctuation with typographic equivalents:
//
//	--- -> em dash
//	-- -> en dash
//	... -> ellipsis
//	straight quotes -> curly quotes based on context
//
// I ported the quote logic from Daring Fireball's SmartyPants.pl, but the
// dash and ellipsis handling are mine and run in a single pass over the
// input buffer.
type SmartyPants struct {
	Dashes    bool
	Ellipsis  bool
	Quotes    bool
}

// DefaultSmartyPants has every transform enabled.
func DefaultSmartyPants() SmartyPants {
	return SmartyPants{Dashes: true, Ellipsis: true, Quotes: true}
}

// Apply returns a new byte slice with transforms applied.
func (sp SmartyPants) Apply(in []byte) []byte {
	out := make([]byte, 0, len(in))
	i := 0
	for i < len(in) {
		if sp.Dashes && i+2 < len(in) && in[i] == '-' && in[i+1] == '-' && in[i+2] == '-' {
			out = append(out, []byte("—")...) // em dash
			i += 3
			continue
		}
		if sp.Dashes && i+1 < len(in) && in[i] == '-' && in[i+1] == '-' {
			out = append(out, []byte("–")...) // en dash
			i += 2
			continue
		}
		if sp.Ellipsis && i+2 < len(in) && in[i] == '.' && in[i+1] == '.' && in[i+2] == '.' {
			out = append(out, []byte("…")...) // horizontal ellipsis
			i += 3
			continue
		}
		if sp.Quotes && (in[i] == '"' || in[i] == '\'') {
			prev, next := prevNextRune(in, i)
			open := isQuoteOpening(prev, next)
			switch in[i] {
			case '"':
				if open {
					out = append(out, []byte("“")...)
				} else {
					out = append(out, []byte("”")...)
				}
			case '\'':
				if open {
					out = append(out, []byte("‘")...)
				} else {
					out = append(out, []byte("’")...)
				}
			}
			i++
			continue
		}
		out = append(out, in[i])
		i++
	}
	return out
}

// ApplyString is a convenience for string callers.
func (sp SmartyPants) ApplyString(s string) string {
	return string(sp.Apply([]byte(s)))
}

func prevNextRune(in []byte, i int) (prev, next rune) {
	prev = ' '
	if i > 0 {
		r, _ := utf8.DecodeLastRune(in[:i])
		prev = r
	}
	next = ' '
	if i+1 < len(in) {
		r, _ := utf8.DecodeRune(in[i+1:])
		next = r
	}
	return
}

func isQuoteOpening(prev, next rune) bool {
	prevSpace := unicode.IsSpace(prev)
	nextSpace := unicode.IsSpace(next)
	// Opening quote: preceded by whitespace/start, followed by non-space.
	if prevSpace && !nextSpace {
		return true
	}
	if !prevSpace && nextSpace {
		return false
	}
	// Ambiguous (e.g. "foo"bar): default to opening at start of buffer.
	return prev == ' '
}

// ApplyBytes is an allocation-free variant that writes into dst. It returns
// the number of bytes written.
func (sp SmartyPants) ApplyBytes(dst, src []byte) int {
	b := sp.Apply(src)
	n := copy(dst, b)
	return n
}

// Ensure bytes stays referenced so imports resolve without the compiler
// flagging unused packages in constrained environments.
var _ = bytes.Equal