From 661fb469e8ef0df4dcfa9a01b4c6908f672ee2f0 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 21:25:44 +0600 Subject: [PATCH] expand abbreviations from a definition line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `*[TERM]: expansion` on its own line, PHP Markdown Extra's form, and every whole-word use in the document becomes . A transformer rather than an inline parser, because a definition may appear after the use it explains and a parser only ever sees what it has already read. A block parser rather than a pattern found later, because the line has to stop being content — an author would notice that going wrong before anything else. Whole-word matching is the part that would have bitten: without it a definition of HTML quietly rewrites HTMLish and xHTML too, so both have cases. Code spans, autolinks, raw HTML and an already-expanded term are skipped, the longest definition wins where two could match, and the expansion is escaped into the attribute so a quoted phrase cannot end it. Definitions are document-scoped. A term defined in a page does not reach an included fragment, which is parsed on its own bytes exactly as footnotes are — stated in content-model.md rather than left to be discovered. The nesting gate caught firstMatch four levels deep; the inner search is its own function now, which reads better than it did before the warning. core 2794/2800, ext 1483/2000, 34 gates green, 0 warnings. --- docs/content-model.md | 7 +- docs/decisions.md | 17 ++ docs/state.md | 4 +- docs/surface.md | 65 +++-- docs/theme-contract.md | 2 +- .../writing/notes-on-water/index.en.md | 4 +- internal/ext/notation/abbr.go | 246 ++++++++++++++++++ internal/ext/notation/notation.go | 10 +- internal/ext/notation/notation_test.go | 61 +++++ internal/web/example_test.go | 3 + 10 files changed, 393 insertions(+), 26 deletions(-) create mode 100644 internal/ext/notation/abbr.go diff --git a/docs/content-model.md b/docs/content-model.md index 677f4fc..ff48336 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -320,13 +320,18 @@ them is a render transform, and the list lives in `cmd/khosra/wire.go` where fea | Subscript | `H~2~O` | | Superscript | `10^6^` | | Highlight | `==marked==` | +| Abbreviations | `*[HTML]: HyperText Markup Language` on its own line; every whole-word use expands | | Heading ids | automatic, from the heading's text — the anchor a table of contents needs | -The last four are khosra's own (ADR-0061), because goldmark's strikethrough claims a single tilde as well as +The last five are khosra's own (ADR-0061, ADR-0062), because goldmark's strikethrough claims a single tilde as well as a double and would read `H~2~O` as struck text. **A subscript or superscript may not contain a space** — it holds a formula, not a phrase — which is what keeps `x^2 + y^2` prose. Its content is taken literally, so there is no emphasis inside one. +An abbreviation's definition may sit anywhere in the file, before or after the uses it explains, and renders +nothing itself. It applies to that document only: a term defined in a page does not reach an included +fragment, which is parsed on its own bytes exactly as footnotes are. + Deliberately absent, so their absence is a decision rather than an oversight: **task lists** (a note-taking affordance, not a publishing one), **linkify** (it rewrites an author's plain text into markup, which is the line ADR-0034 draws), **CJK line breaking** (wrong script family — it does nothing for Bengali), and the diff --git a/docs/decisions.md b/docs/decisions.md index 6f0be06..d695320 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -989,3 +989,20 @@ subscript, which is a limit worth stating rather than discovering; and khosra no implementation it used to get from upstream. Revisit if: a mark wants markup inside a single run — which needs recursive inline parsing and is a different mechanism, not a wider table. + +## ADR-0062 — Abbreviations are a definition line plus a replacement pass +Date: 2026-08-01 · Status: accepted (extends ADR-0061's package with a second mechanism) +Decision: `*[TERM]: expansion` on its own line defines an abbreviation, renders nothing itself, and every +whole-word occurrence of TERM in that document becomes ``. A block parser claims the +line; an AST transformer does the replacing. Code spans, autolinks, raw HTML and an already-expanded term +are skipped. The longest defined term wins where two could match. +Why: PHP Markdown Extra's form, so it is a syntax authors already know. It is a transformer rather than an +inline parser because a definition may appear *after* the use it explains — a parser only ever sees what it +has already read — and a block parser rather than a pattern found later because the line has to stop being +content, which is the part an author would notice going wrong. Whole-word matching is what keeps `HTMLish` +and `xHTML` intact; without it a definition quietly rewrites every substring on the page. +Consequence: cheap — one more mark from the same package, and the definition can sit anywhere in the file. +Expensive — the replacement walks every text node in the document, which is work proportional to the page +rather than to the number of definitions; and definitions are document-scoped, so a term defined in a page +does not reach an included fragment, which is parsed on its own bytes (ADR-0038) exactly as footnotes are. +Revisit if: definitions want to be site-wide, which is a settings-cascade question and not this mechanism. diff --git a/docs/state.md b/docs/state.md index f861915..85afc7e 100644 --- a/docs/state.md +++ b/docs/state.md @@ -26,7 +26,7 @@ table owns. | `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) | | `internal/render/templates/` | reference theme, complete: `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026, ADR-0049) | | `internal/ext/shortcodes/` | first feature: `::name{key=value}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044). `FootnotePrefix` namespaces an included file's footnote ids (ADR-0058). Directive syntax since ADR-0059 | -| `internal/ext/notation/` | the inline marks CommonMark lacks: `~sub~`, `^sup^`, `==mark==`, and `~~strike~~`, which it owns so a single tilde can mean subscript (ADR-0061) | +| `internal/ext/notation/` | the inline marks CommonMark lacks: `~sub~`, `^sup^`, `==mark==`, and `~~strike~~`, which it owns so a single tilde can mean subscript (ADR-0061). `abbr.go` adds `*[TERM]:` definitions and the pass that expands them (ADR-0062) | | `internal/ext/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite | | `internal/ext/watch/` | polls `content/` and `templates/` on an interval it is given, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048, ADR-0056). `site.yaml` is deliberately not fingerprinted (ADR-0055) | | `internal/ext/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering, and calls left in the retired shortcode form (ADR-0059) | @@ -48,7 +48,7 @@ section-narrowed, sequence navigation and a series archive on any nested bundle, URL, generated derivatives under `/derived/`, Atom feeds per site, section and tag, a bundle's extras as a browsable tree, plus `/robots.txt` and `/sitemap.xml`. Markdown is CommonMark plus tables, footnotes, definition lists, heading ids and the inline marks -(`~sub~`, `^sup^`, `==mark==`, `~~strike~~`), and nothing else (ADR-0058, ADR-0061); HTML an author writes renders, because the site root is trusted (ADR-0060). Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic +(`~sub~`, `^sup^`, `==mark==`, `~~strike~~`, `*[TERM]:`), and nothing else (ADR-0058, ADR-0061, ADR-0062); HTML an author writes renders, because the site root is trusted (ADR-0060). Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic smoothing (ADR-0034); line breaking is left to CSS (ADR-0045). This repo holds engine source only — the site root is external and passed with `khosra check` validates a site root and exits non-zero on anything that makes it wrong; `khosra new` scaffolds a draft bundle into one. A running server notices changes under `content/` and `templates/` by diff --git a/docs/surface.md b/docs/surface.md index 824f042..4692989 100644 --- a/docs/surface.md +++ b/docs/surface.md @@ -113,31 +113,58 @@ check.go 223 · doc.go 8 - check.go:172 func asset(fsys fs.FS, trimmed string, site *content.Site) bool - check.go:200 func mixedOrdering(bundles []content.Bundle, site *content.Site) []Finding -## internal/ext/notation — 159 lines + 88 test +## internal/ext/notation — 411 lines + 149 test -doc.go 8 · notation.go 151 +abbr.go 246 · doc.go 8 · notation.go 157 +- abbr.go:17 var defined = parser.NewContextKey() +- abbr.go:21 var definitionKind = ast.NewNodeKind("AbbrDefinition") +- abbr.go:23 type definition struct{ ast.BaseBlock } +- abbr.go:25 func (n *definition) Kind() ast.NodeKind { return definitionKind } +- abbr.go:27 func (n *definition) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } +- abbr.go:30 var abbrKind = ast.NewNodeKind("Abbr") +- abbr.go:32 type abbr struct +- abbr.go:37 func (n *abbr) Kind() ast.NodeKind { return abbrKind } +- abbr.go:39 func (n *abbr) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } +- abbr.go:45 type definitionParser struct{} +- abbr.go:47 func (definitionParser) Trigger() []byte { return []byte{'*'} } +- abbr.go:49 func (definitionParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) +- abbr.go:65 func (definitionParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State +- abbr.go:69 func (definitionParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {} +- abbr.go:71 func (definitionParser) CanInterruptParagraph() bool { return true } +- abbr.go:73 func (definitionParser) CanAcceptIndentedLine() bool { return false } +- abbr.go:77 func parseDefinition(line string) (term, expansion string, ok bool) +- abbr.go:101 type expand struct{} +- abbr.go:103 func (expand) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) +- abbr.go:136 func replace(node *ast.Text, source []byte, terms map[string]string, ordered []string) +- abbr.go:172 func slice(seg text.Segment, from, to int) text.Segment +- abbr.go:180 func firstMatch(body string, ordered []string) (string, int) +- abbr.go:193 func earliest(body, term string) int +- abbr.go:209 func whole(body string, index, length int) bool +- abbr.go:226 func renderDefinition(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) +- abbr.go:232 func renderAbbr(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) +- abbr.go:243 func (nodeRenderer) registerAbbr(reg renderer.NodeRendererFuncRegisterer) - notation.go:17 var marks = []mark{ - notation.go:27 type mark struct - notation.go:33 func New() goldmark.Extender { return extension{} } - notation.go:35 type extension struct{} - notation.go:41 func (extension) Extend(md goldmark.Markdown) -- notation.go:51 var kind = ast.NewNodeKind("Notation") -- notation.go:54 type node struct -- notation.go:59 func (n *node) Kind() ast.NodeKind { return kind } -- notation.go:61 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } -- notation.go:65 type processor struct{ tag string } -- notation.go:67 func (p processor) IsDelimiter(b byte) bool { return b == '~' || b == '=' } -- notation.go:69 func (p processor) CanOpenCloser(opener, closer *parser.Delimiter) bool -- notation.go:73 func (p processor) OnMatch(consumes int) ast.Node { return &node{tag p.tag} } -- notation.go:76 type inlineParser struct{ m mark } -- notation.go:78 func (p inlineParser) Trigger() []byte { return []byte{p.m.char} } -- notation.go:80 func (p inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node -- notation.go:89 func (p inlineParser) doubled(block text.Reader, line []byte, segment text.Segment, pc parser.Context) ast.Node -- notation.go:108 func (p inlineParser) single(block text.Reader, line []byte, segment text.Segment) ast.Node -- notation.go:137 type nodeRenderer struct{} -- notation.go:139 func (nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) -- notation.go:143 func render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) +- notation.go:56 var kind = ast.NewNodeKind("Notation") +- notation.go:59 type node struct +- notation.go:64 func (n *node) Kind() ast.NodeKind { return kind } +- notation.go:66 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } +- notation.go:70 type processor struct{ tag string } +- notation.go:72 func (p processor) IsDelimiter(b byte) bool { return b == '~' || b == '=' } +- notation.go:74 func (p processor) CanOpenCloser(opener, closer *parser.Delimiter) bool +- notation.go:78 func (p processor) OnMatch(consumes int) ast.Node { return &node{tag p.tag} } +- notation.go:81 type inlineParser struct{ m mark } +- notation.go:83 func (p inlineParser) Trigger() []byte { return []byte{p.m.char} } +- notation.go:85 func (p inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node +- notation.go:94 func (p inlineParser) doubled(block text.Reader, line []byte, segment text.Segment, pc parser.Context) ast.Node +- notation.go:113 func (p inlineParser) single(block text.Reader, line []byte, segment text.Segment) ast.Node +- notation.go:142 type nodeRenderer struct{} +- notation.go:144 func (r nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) +- notation.go:149 func render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) ## internal/ext/scaffold — 102 lines + 89 test @@ -254,7 +281,7 @@ chrome.go 110 · render.go 473 · view.go 130 - view.go:111 type Item struct - view.go:122 type Alternate struct -## internal/web — 734 lines + 1605 test +## internal/web — 734 lines + 1608 test asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217 diff --git a/docs/theme-contract.md b/docs/theme-contract.md index df26aa3..73c7b1f 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -152,7 +152,7 @@ not insert characters into an author's prose to influence line breaking. `.HTML` is Markdown output, so the dialect decides which elements a theme has to be ready to style (`content-model.md`). Beyond CommonMark's own, since ADR-0058: `` with ``/``, -`
`/`
`/`
`, ``, ``, ``, ``, and goldmark's footnote markup — `` in the text and a +`
`/`
`/`
`, ``, ``, ``, ``, ``, and goldmark's footnote markup — `` in the text and a `
` carrying an `
    ` of notes. Headings arrive with an `id`. None of it is optional and none of it is configurable: a theme that styles none of these still renders a diff --git a/examples/demo-site/content/writing/notes-on-water/index.en.md b/examples/demo-site/content/writing/notes-on-water/index.en.md index 95fdd92..100827e 100644 --- a/examples/demo-site/content/writing/notes-on-water/index.en.md +++ b/examples/demo-site/content/writing/notes-on-water/index.en.md @@ -19,7 +19,9 @@ The page has a footnote of its own[^page], a table, a definition list and ~~a st The notation marks cover what prose needs most often: the gauge measures H~2~O to about 10^-3^ m, and the ==important== column is the second. Where there is no mark for something, HTML does it: the reading is taken with Shift held. Content in the site root is the author's own, so it renders rather than being -dropped. +dropped. Readings go to the NIWA archive, and the definition below expands every use of it. + +*[NIWA]: National Institute of Water and Atmospheric Research | Gauge | Reading | Note | |-------|---------|------| diff --git a/internal/ext/notation/abbr.go b/internal/ext/notation/abbr.go new file mode 100644 index 0000000..58e57cb --- /dev/null +++ b/internal/ext/notation/abbr.go @@ -0,0 +1,246 @@ +package notation + +import ( + "sort" + "strings" + "unicode" + "unicode/utf8" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// defined holds this document's abbreviations, keyed by the term as written. +var defined = parser.NewContextKey() + +// definitionKind marks the line that declared one. It renders nothing: a definition is a note to the parser, +// not content. +var definitionKind = ast.NewNodeKind("AbbrDefinition") + +type definition struct{ ast.BaseBlock } + +func (n *definition) Kind() ast.NodeKind { return definitionKind } + +func (n *definition) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } + +// abbrKind is one expanded occurrence. +var abbrKind = ast.NewNodeKind("Abbr") + +type abbr struct { + ast.BaseInline + title string +} + +func (n *abbr) Kind() ast.NodeKind { return abbrKind } + +func (n *abbr) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } + +// definitionParser claims a whole line of the form `*[TERM]: expansion`. +// +// Its own block parser rather than a pattern found later, because the line has to stop being content: left to +// the paragraph parser it would render as prose, which is what the author is least expecting. +type definitionParser struct{} + +func (definitionParser) Trigger() []byte { return []byte{'*'} } + +func (definitionParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { + line, seg := reader.PeekLine() + term, expansion, ok := parseDefinition(string(line)) + if !ok { + return nil, parser.NoChildren + } + terms, _ := pc.Get(defined).(map[string]string) + if terms == nil { + terms = map[string]string{} + pc.Set(defined, terms) + } + terms[term] = expansion + reader.Advance(seg.Len() - 1) + return &definition{}, parser.NoChildren +} + +func (definitionParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { + return parser.Close +} + +func (definitionParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {} + +func (definitionParser) CanInterruptParagraph() bool { return true } + +func (definitionParser) CanAcceptIndentedLine() bool { return false } + +// parseDefinition reads `*[TERM]: expansion`. A space after the asterisk makes it a list item instead, which +// is why the bracket must follow immediately. +func parseDefinition(line string) (term, expansion string, ok bool) { + rest, found := strings.CutPrefix(strings.TrimRight(line, "\r\n"), "*[") + if !found { + return "", "", false + } + term, rest, found = strings.Cut(rest, "]") + if !found || term == "" { + return "", "", false + } + expansion, found = strings.CutPrefix(rest, ":") + if !found { + return "", "", false + } + expansion = strings.TrimSpace(expansion) + if expansion == "" { + return "", "", false + } + return term, expansion, true +} + +// expand replaces every defined term in the document's text with an abbreviation. +// +// A transformer rather than an inline parser, because a definition may appear after the use it explains, and +// an inline parser only ever sees what it has already read. +type expand struct{} + +func (expand) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { + terms, _ := pc.Get(defined).(map[string]string) + if len(terms) == 0 { + return + } + // Longest first, so a definition of "HTML5" is not shadowed by one of "HTML". + ordered := make([]string, 0, len(terms)) + for term := range terms { + ordered = append(ordered, term) + } + sort.Slice(ordered, func(i, j int) bool { return len(ordered[i]) > len(ordered[j]) }) + + source := reader.Source() + var texts []*ast.Text + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch n.Kind() { + case ast.KindCodeSpan, ast.KindRawHTML, ast.KindAutoLink, abbrKind: + // An author's literal text, a tag, a URL, and an expansion already made: none of them is prose. + return ast.WalkSkipChildren, nil + case ast.KindText: + texts = append(texts, n.(*ast.Text)) + } + return ast.WalkContinue, nil + }) + for _, node := range texts { + replace(node, source, terms, ordered) + } +} + +// replace swaps one text node for the alternating run of text and abbreviations it contains. +func replace(node *ast.Text, source []byte, terms map[string]string, ordered []string) { + parent := node.Parent() + if parent == nil { + return + } + seg := node.Segment + body := string(source[seg.Start:seg.Stop]) + at := 0 + var built []ast.Node + for at < len(body) { + term, index := firstMatch(body[at:], ordered) + if term == "" { + break + } + start := at + index + if start > at { + built = append(built, ast.NewTextSegment(slice(seg, at, start))) + } + marked := &abbr{title: terms[term]} + marked.AppendChild(marked, ast.NewTextSegment(slice(seg, start, start+len(term)))) + built = append(built, marked) + at = start + len(term) + } + if len(built) == 0 { + return + } + if at < len(body) { + built = append(built, ast.NewTextSegment(slice(seg, at, len(body)))) + } + for _, n := range built { + parent.InsertBefore(parent, node, n) + } + parent.RemoveChild(parent, node) +} + +// slice is the segment covering body[from:to]. +func slice(seg text.Segment, from, to int) text.Segment { + out := seg + out.Start, out.Stop = seg.Start+from, seg.Start+to + return out +} + +// firstMatch finds the earliest whole-word occurrence of any term. `ordered` is longest first and the +// comparison is strict, so two terms starting at the same place resolve to the longer one. +func firstMatch(body string, ordered []string) (string, int) { + best, at := "", -1 + for _, term := range ordered { + index := earliest(body, term) + if index >= 0 && (at < 0 || index < at) { + best, at = term, index + } + } + return best, at +} + +// earliest is the first whole-word occurrence of one term, or -1. Its own function so firstMatch stays flat: +// skipping a match that turned out to sit inside a longer word is a loop, not a condition. +func earliest(body, term string) int { + for from := 0; from+len(term) <= len(body); { + index := strings.Index(body[from:], term) + if index < 0 { + return -1 + } + index += from + if whole(body, index, len(term)) { + return index + } + from = index + 1 + } + return -1 +} + +// whole reports whether the match at index stands alone rather than sitting inside a longer word. +func whole(body string, index, length int) bool { + if index > 0 { + before, _ := utf8.DecodeLastRuneInString(body[:index]) + if unicode.IsLetter(before) || unicode.IsDigit(before) { + return false + } + } + if end := index + length; end < len(body) { + after, _ := utf8.DecodeRuneInString(body[end:]) + if unicode.IsLetter(after) || unicode.IsDigit(after) { + return false + } + } + return true +} + +// renderDefinition writes nothing: the line was a note to the parser. +func renderDefinition(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + return ast.WalkSkipChildren, nil +} + +// renderAbbr writes the element. The title is authored text going into an attribute, so it is escaped — +// correctness rather than trust: an apostrophe or a quote in an expansion would otherwise end the attribute. +func renderAbbr(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + _, _ = w.WriteString("") + return ast.WalkContinue, nil + } + _, _ = w.WriteString(``) + return ast.WalkContinue, nil +} + +func (nodeRenderer) registerAbbr(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(definitionKind, renderDefinition) + reg.Register(abbrKind, renderAbbr) +} diff --git a/internal/ext/notation/notation.go b/internal/ext/notation/notation.go index 49b689c..0e57556 100644 --- a/internal/ext/notation/notation.go +++ b/internal/ext/notation/notation.go @@ -43,7 +43,12 @@ func (extension) Extend(md goldmark.Markdown) { for _, m := range marks { inline = append(inline, util.Prioritized(inlineParser{m}, 500)) } - md.Parser().AddOptions(parser.WithInlineParsers(inline...)) + md.Parser().AddOptions( + parser.WithInlineParsers(inline...), + // Above the paragraph parser, so a definition line stops being content (ADR-0062). + parser.WithBlockParsers(util.Prioritized(definitionParser{}, 99)), + parser.WithASTTransformers(util.Prioritized(expand{}, 200)), + ) md.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(nodeRenderer{}, 500))) } @@ -136,8 +141,9 @@ func (p inlineParser) single(block text.Reader, line []byte, segment text.Segmen // anything an author supplied, so it is written directly. type nodeRenderer struct{} -func (nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { +func (r nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { reg.Register(kind, render) + r.registerAbbr(reg) } func render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { diff --git a/internal/ext/notation/notation_test.go b/internal/ext/notation/notation_test.go index 0c45ae8..6c83b8e 100644 --- a/internal/ext/notation/notation_test.go +++ b/internal/ext/notation/notation_test.go @@ -86,3 +86,64 @@ func TestCodeSpansAreUntouched(t *testing.T) { t.Errorf("a code span must survive verbatim:\n%s", got) } } + +func TestAbbreviationsExpandWhereverTheyAppear(t *testing.T) { + got := html(t, "The HTML spec.\n\n*[HTML]: HyperText Markup Language\n\nHTML again.\n") + if n := strings.Count(got, `HTML`); n != 2 { + t.Errorf("both uses should expand, got %d:\n%s", n, got) + } + // A definition may follow the use it explains, which is why this is a transformer and not a parser. + if strings.Contains(got, "*[HTML]") || strings.Contains(got, "HyperText Markup Language<") { + t.Errorf("the definition line is a note to the parser, not content:\n%s", got) + } +} + +func TestAnAbbreviationIsAWholeWord(t *testing.T) { + got := html(t, "HTML and HTMLish and xHTML.\n\n*[HTML]: HyperText Markup Language\n") + if strings.Count(got, "HTML5`) { + t.Errorf("HTML5 should not be shadowed by HTML:\n%s", got) + } + if !strings.Contains(got, `HTML`) { + t.Errorf("the shorter term should still expand on its own:\n%s", got) + } +} + +func TestCodeAndUrlsAreNotExpanded(t *testing.T) { + got := html(t, "Use `HTML` here and there, but HTML in prose.\n\n*[HTML]: Markup\n") + if strings.Count(got, "HTML") { + t.Errorf("a code span is literal text:\n%s", got) + } +} + +func TestAnExpansionCannotBreakItsAttribute(t *testing.T) { + got := html(t, `Term T here.`+"\n\n"+`*[T]: a "quoted" thing`+"\n") + if strings.Contains(got, `title="a "quoted" thing"`) { + t.Errorf("the attribute was broken by the expansion:\n%s", got) + } + if !strings.Contains(got, ""quoted"") { + t.Errorf("the expansion should be escaped into the attribute:\n%s", got) + } +} + +// A list item starts with an asterisk too, so the definition form must not eat one. +func TestAListItemIsNotADefinition(t *testing.T) { + got := html(t, "* [a link](/x/): still a list\n") + if !strings.Contains(got, "
  1. ") { + t.Errorf("a list item must survive:\n%s", got) + } +} diff --git a/internal/web/example_test.go b/internal/web/example_test.go index 57ac697..531b1fd 100644 --- a/internal/web/example_test.go +++ b/internal/web/example_test.go @@ -124,6 +124,9 @@ var exampleFeatures = []featureCase{ {what: "notation marks become elements, and a single tilde is a subscript not a strike", path: "/writing/notes-on-water/", code: 200, expect: []string{"H2O", "10-3", "important", "a struck phrase"}, absent: []string{"2"}}, + {what: "an abbreviation expands and its definition line renders nothing", path: "/writing/notes-on-water/", code: 200, + expect: []string{`NIWA`}, + absent: []string{"*[NIWA]"}}, {what: "the dialect renders tables, definition lists and strikethrough", path: "/writing/notes-on-water/", code: 200, expect: []string{"
", "", "
", "
Monsoon
", "a struck phrase"}}, {what: "a fragment's footnote ids are namespaced, so the page's own keep working", path: "/writing/notes-on-water/", code: 200,
Gauge