Files
khosra/internal/ext/shortcodes/icons.go
T
Claude Opus 5andbdeshi 7136f6e2d9 let a shortcode fragment speak the reader's language
Three fragments added this session needed a word the author did not write: an
untitled :::warn told the reader nothing about being a warning, an untitled
panel fell back to whatever the browser calls <details>, and the contents list
had no label at all — an accessibility gap as much as an untranslated one. None
of them could be fixed, because `t` needs a language and Fragment had none, so
those words could only ever have been English on a site that serves Bengali.

Fragment gains Lang, captured on each call at parse time — a node renderer never
receives the parse context, the same constraint that put pictures and headings
on the node. Five phrase keys follow, and a Bengali page now reads সূচিপত্র,
সতর্কতা and বিস্তারিত where an English one reads Contents, Warning and Details.

The demo's own list.html was the better example of the problem and now shows the
answer: a site's own sentences are not in the engine's phrase table, so a
template needing its own words branches on the language it was given. That is
what theme-contract.md has always told a theme to do, demonstrated rather than
asserted, and a case proves the Bengali listing carries no English.

Two files crossed the size advisory on the way. render.go shed the contract
types to view.go, where state.md already claimed they lived and where the file's
own header said they belonged; shortcodes_test.go split to mirror its sources,
which the one-file-per-source convention already asked for. Both are pure moves.
2026-08-01 23:09:03 +06:00

130 lines
3.9 KiB
Go

package shortcodes
import (
"bytes"
"log/slog"
"unicode"
"unicode/utf8"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"khosra/internal/render"
)
// iconFragment is the theme template every icon renders through. One template for the whole set, not one per
// icon: which names exist is the theme's business, and the engine holds no list of them (ADR-0063).
const iconFragment = "icon"
// icons parses `:name:` into a call on the icon fragment.
//
// The colon is the commonest punctuation in technical prose, so this is deliberately hard to trigger: the
// name must start with a letter and hold only letters, digits and hyphens, and neither side of the pair may
// touch an alphanumeric. That leaves `10:30:15`, `key:value:pair` and `Note: this` alone, which is the whole
// difficulty of the syntax (ADR-0063).
type icons struct{}
func (icons) Trigger() []byte { return []byte{':'} }
func (icons) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
if before := block.PrecendingCharacter(); isWordRune(before) {
return nil
}
line, _ := block.PeekLine()
name, width, ok := iconName(line)
if !ok {
return nil
}
// The source text is kept so a name the theme does not know can be written back exactly as the author
// typed it, rather than vanishing from the middle of a sentence.
n := &iconNode{name: name, literal: string(line[:width])}
if origin, ok := render.OriginFrom(pc); ok {
n.lang = origin.Lang
}
block.Advance(width)
return n
}
// iconKind is one inline call. Its own node rather than the block one because goldmark distinguishes the two
// by type, and an icon sits inside a sentence.
var iconKind = ast.NewNodeKind("ShortcodeIcon")
type iconNode struct {
ast.BaseInline
name string
literal string
lang string
}
func (n *iconNode) Kind() ast.NodeKind { return iconKind }
func (n *iconNode) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
// renderIcon writes the theme's icon fragment, or the author's own text when the theme does not know the
// name. Rendering nothing is how a theme says so, and losing a word out of a sentence is never the answer.
func (f fragments) renderIcon(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
call := n.(*iconNode)
out, err := f.partial(iconFragment, render.Fragment{Args: map[string]string{"name": call.name}, Lang: call.lang})
if err != nil {
slog.Error("skipping icon", "name", call.name, "err", err)
out = nil
}
if len(bytes.TrimSpace(out)) == 0 {
out = []byte(call.literal)
}
if _, err := w.Write(out); err != nil {
return ast.WalkStop, err
}
return ast.WalkContinue, nil
}
// iconName reads `:name:` from the start of a line and reports how many bytes it spans.
func iconName(line []byte) (string, int, bool) {
if len(line) < 3 || line[0] != ':' {
return "", 0, false
}
end := 0
for i := 1; i < len(line); i++ {
c := line[i]
if c == ':' {
end = i
break
}
if !isNameByte(c, i == 1) {
return "", 0, false
}
}
if end < 2 {
return "", 0, false
}
// Whatever follows the closing colon must not be part of a word either, or `:a:b` would be an icon.
if after := end + 1; after < len(line) {
r, _ := utf8.DecodeRune(line[after:])
if isWordRune(r) {
return "", 0, false
}
}
return string(line[1:end]), end + 1, true
}
// isNameByte reports whether c may appear in an icon name; first is true for the opening character, which
// must be a letter so a time like `10:30:15` cannot become one.
func isNameByte(c byte, first bool) bool {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z':
return true
case first:
return false
case c >= '0' && c <= '9', c == '-', c == '_':
return true
}
return false
}
func isWordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) }