diff --git a/go.mod b/go.mod index df8616d..6b34b3b 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26 require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 github.com/spf13/cobra v1.10.2 + github.com/yuin/goldmark v1.8.4 golang.org/x/text v0.37.0 sigs.k8s.io/yaml v1.4.0 ) diff --git a/go.sum b/go.sum index 2b79d06..452d866 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= +github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= diff --git a/internal/render/markdown/markdown.go b/internal/render/markdown/markdown.go index 1cb787f..f242c3e 100644 --- a/internal/render/markdown/markdown.go +++ b/internal/render/markdown/markdown.go @@ -11,6 +11,10 @@ import ( "strings" "unicode" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/stacklok/modelith/internal/model" "github.com/stacklok/modelith/internal/render/mermaid" ) @@ -44,10 +48,10 @@ func Render(m *model.Model, sourceDir, outDir string) string { if title == "" { title = "Domain Model" } - fmt.Fprintf(&b, "# %s\n\n", oneLine(title)) + fmt.Fprintf(&b, "# %s\n\n", proseLine(title)) if desc := strings.TrimSpace(m.Description); desc != "" { - b.WriteString(desc) + b.WriteString(proseBlock(desc)) b.WriteString("\n\n") } @@ -72,7 +76,7 @@ func renderInvariants(b *strings.Builder, m *model.Model) { } b.WriteString("## Invariants\n\n") for _, inv := range m.Invariants { - fmt.Fprintf(b, "- **%s** — %s\n", inv.ID, inv.Statement) + fmt.Fprintf(b, "- %s\n", invariantLine(inv.ID, inv.Statement)) } b.WriteString("\n") } @@ -188,7 +192,7 @@ func renderGlossary(b *strings.Builder, m *model.Model) { } sort.Strings(terms) for _, t := range terms { - fmt.Fprintf(b, "- **`%s`** — %s\n", t, oneLine(m.Glossary[t])) + fmt.Fprintf(b, "- %s\n", assembledLine("**"+codeSpan(t)+"**", oneLine(m.Glossary[t]))) } b.WriteString("\n") } @@ -208,13 +212,13 @@ func renderEnums(b *strings.Builder, m *model.Model) { en := m.Enums[name] fmt.Fprintf(b, "### `%s`\n\n", name) if d := strings.TrimSpace(en.Description); d != "" { - b.WriteString(d) + b.WriteString(proseBlock(d)) b.WriteString("\n\n") } b.WriteString("| Value | Definition |\n") b.WriteString("| --- | --- |\n") for _, v := range en.Values { - fmt.Fprintf(b, "| `%s` | %s |\n", v.Name, mdCell(v.Definition)) + fmt.Fprintf(b, "| %s | %s |\n", codeCell(v.Name), mdCell(v.Definition)) } b.WriteString("\n") } @@ -240,7 +244,7 @@ func renderEntities(b *strings.Builder, m *model.Model, sourceDir, outDir string fmt.Fprintf(b, "### `%s`\n\n", name) if def := strings.TrimSpace(ent.Definition); def != "" { - b.WriteString(def) + b.WriteString(proseBlock(def)) b.WriteString("\n\n") } @@ -258,7 +262,7 @@ func renderEntities(b *strings.Builder, m *model.Model, sourceDir, outDir string if ent.Derived { d := "**Derived.** Computed on demand from other state; never persisted." if derivation := strings.TrimSpace(ent.Derivation); derivation != "" { - d = fmt.Sprintf("**Derived:** %s", derivation) + d = fmt.Sprintf("**Derived:** %s", proseLine(derivation)) } b.WriteString(d) b.WriteString("\n\n") @@ -275,12 +279,12 @@ func renderEntities(b *strings.Builder, m *model.Model, sourceDir, outDir string parts = append(parts, rel.Ownership) } if rel.Role != "" { - parts = append(parts, rel.Role) + parts = append(parts, oneLine(rel.Role)) } if rel.Note != "" { - parts = append(parts, rel.Note) + parts = append(parts, oneLine(rel.Note)) } - fmt.Fprintf(b, "- %s\n", strings.Join(parts, " — ")) + fmt.Fprintf(b, "- %s\n", assembledLine(parts...)) } b.WriteString("\n") } @@ -302,7 +306,7 @@ func renderEntities(b *strings.Builder, m *model.Model, sourceDir, outDir string desc = d } } - fmt.Fprintf(b, "| `%s` | %s | %s |\n", attr.Name, typeCell(attr.Type, importTargets), mdCell(desc)) + fmt.Fprintf(b, "| %s | %s | %s |\n", codeCell(attr.Name), typeCell(attr.Type, importTargets), mdCell(desc)) } b.WriteString("\n") } @@ -312,7 +316,7 @@ func renderEntities(b *strings.Builder, m *model.Model, sourceDir, outDir string if len(ent.Invariants) > 0 { b.WriteString("**Invariants**\n\n") for _, inv := range ent.Invariants { - fmt.Fprintf(b, "- **%s** — %s\n", inv.ID, inv.Statement) + fmt.Fprintf(b, "- %s\n", invariantLine(inv.ID, inv.Statement)) } b.WriteString("\n") } @@ -336,9 +340,9 @@ func renderActions(b *strings.Builder, actions []model.Action) { if !structured { quoted := make([]string, len(actions)) for i, a := range actions { - quoted[i] = "`" + a.Name + "`" + quoted[i] = codeSpan(a.Name) } - fmt.Fprintf(b, "**Actions:** %s\n\n", strings.Join(quoted, ", ")) + fmt.Fprintf(b, "%s\n\n", escapeProse("**Actions:** "+strings.Join(quoted, ", "), false)) return } @@ -346,19 +350,23 @@ func renderActions(b *strings.Builder, actions []model.Action) { for _, a := range actions { var detail []string if a.Actor != "" { - detail = append(detail, fmt.Sprintf("actor `%s`", a.Actor)) + detail = append(detail, "actor "+codeSpan(a.Actor)) } if len(a.Preserves) > 0 { - detail = append(detail, "preserves "+strings.Join(a.Preserves, ", ")) + preserves := make([]string, len(a.Preserves)) + for i, id := range a.Preserves { + preserves[i] = oneLine(id) + } + detail = append(detail, "preserves "+strings.Join(preserves, ", ")) } - line := "`" + a.Name + "`" + parts := []string{codeSpan(a.Name)} if len(detail) > 0 { - line += " — " + strings.Join(detail, "; ") + parts = append(parts, strings.Join(detail, "; ")) } if a.Description != "" { - line += " — " + oneLine(a.Description) + parts = append(parts, oneLine(a.Description)) } - fmt.Fprintf(b, "- %s\n", line) + fmt.Fprintf(b, "- %s\n", assembledLine(parts...)) } b.WriteString("\n") } @@ -392,21 +400,25 @@ func renderScenarios(b *strings.Builder, m *model.Model) { b.WriteString("## Scenarios\n\n") for _, sc := range m.Scenarios { - fmt.Fprintf(b, "### %s\n\n", oneLine(sc.Name)) + fmt.Fprintf(b, "### %s\n\n", proseLine(sc.Name)) if desc := strings.TrimSpace(sc.Description); desc != "" { - b.WriteString(desc) + b.WriteString(proseBlock(desc)) b.WriteString("\n\n") } if len(sc.Actors) > 0 { - fmt.Fprintf(b, "**Actors:** %s\n\n", strings.Join(sc.Actors, ", ")) + actors := make([]string, len(sc.Actors)) + for i, a := range sc.Actors { + actors[i] = oneLine(a) + } + fmt.Fprintf(b, "%s\n\n", escapeProse("**Actors:** "+strings.Join(actors, ", "), false)) } if len(sc.Steps) > 0 { b.WriteString("**Steps**\n\n") for i, step := range sc.Steps { - fmt.Fprintf(b, "%d. %s\n", i+1, step) + fmt.Fprintf(b, "%d. %s\n", i+1, proseLine(step)) } b.WriteString("\n") } @@ -415,9 +427,9 @@ func renderScenarios(b *strings.Builder, m *model.Model) { b.WriteString("**Invariants touched**\n\n") for _, id := range sc.InvariantsTouched { if s, ok := statement[id]; ok { - fmt.Fprintf(b, "- **%s** — %s\n", id, s) + fmt.Fprintf(b, "- %s\n", invariantLine(id, s)) } else { - fmt.Fprintf(b, "- **%s**\n", id) + fmt.Fprintf(b, "- %s\n", escapeProse("**"+oneLine(id)+"**", false)) } } b.WriteString("\n") @@ -425,9 +437,223 @@ func renderScenarios(b *strings.Builder, m *model.Model) { } } -// mdCell escapes a value for use inside a Markdown table cell. +// assembledLine joins the pieces of one rendered line with the em-dash +// separator and escapes the result once, as the finished line. +// +// Escaping the pieces one at a time asked the wrong question. Whether a backtick +// opens a code span, and so whether the tag behind it is literal, depends on +// what else is on the line — so a stray backtick in a `role:` paired with the +// backticks of the `note:` beside it and left the note's contents outside every +// span, live (#37). Each piece is collapsed to a line by its caller; only the +// assembled line is parsed, because only the assembled line is what the reader's +// parser sees. +func assembledLine(parts ...string) string { + return escapeProse(strings.Join(parts, " — "), false) +} + +// invariantLine renders an invariant's id and statement as the one line they +// share. Both are author-written, so they are escaped together; see +// assembledLine. +func invariantLine(id, statement string) string { + return assembledLine("**"+oneLine(id)+"**", oneLine(statement)) +} + +// mdCell escapes an author-written prose value for use inside a Markdown table +// cell. A cell is escaped on its own because GFM splits a row into cells before +// it parses any of them, so nothing in one cell can reach into the next. func mdCell(s string) string { - return strings.ReplaceAll(oneLine(s), "|", "\\|") + return cellEscape(proseLine(s)) +} + +// codeCell renders a value as a code span inside a Markdown table cell. GFM +// recognizes a backslash-escaped pipe inside a code span, which is the only way +// a cell can carry one. +func codeCell(s string) string { + return cellEscape(codeSpan(s)) +} + +func cellEscape(s string) string { + return strings.ReplaceAll(s, "|", "\\|") +} + +// mdParser is a CommonMark parser used only to locate the literal regions of a +// prose string. Nothing is ever rendered through goldmark: its renderer +// normalizes and reflows text, which would rewrite prose the author wrote and +// churn every committed .md, so the escaping writes back into the original +// bytes at the offsets the parse reports (ADR-0014). +// +// The parser is built from the CommonMark defaults rather than through +// goldmark.New so the HTML renderer never enters the binary. parser.Parse is +// safe for concurrent use: its one-time setup is guarded by a sync.Once and +// each call carries its own context. +var mdParser = parser.NewParser( + parser.WithBlockParsers(parser.DefaultBlockParsers()...), + parser.WithInlineParsers(parser.DefaultInlineParsers()...), + parser.WithParagraphTransformers(parser.DefaultParagraphTransformers()...), +) + +// byteRange is a half-open [start, stop) region of a prose string. +type byteRange struct{ start, stop int } + +// linePrefix stands in, during the parse only, for text a line-context value +// follows on its rendered line — the "# " of a heading, the "| " of a table +// cell, the "**Bold** — " of a bullet that opens with generated markup. It +// holds column zero so the value is read as the inline content it is there. +// The offsets come back shifted by its length and are shifted off again. +const linePrefix = "x " + +// proseBlock escapes an author-written string that is emitted as its own block +// — a model, enum or scenario description, an entity definition. Fenced and +// indented code blocks are real there, so their contents are text and stay +// verbatim. +func proseBlock(s string) string { return escapeProse(s, true) } + +// proseLine escapes an author-written string that lands inside a line — a +// heading, a list item, a table cell — collapsing it to one line first. +// +// A value sharing its line with other author-written values is assembled first +// and escaped once, by the call site, rather than passed here piece by piece: +// what pairs with what is a property of the finished line (#37). +func proseLine(s string) string { return escapeProse(oneLine(s), false) } + +// escapeProse escapes the raw HTML in an author-written string and leaves every +// other byte alone. A `definition:` is Markdown — models backtick their entity +// names throughout and authors use emphasis — but it is not HTML: an angle +// bracket is text the author typed, not a tag to interpret (ADR-0014). +// +// The rule is an allowlist of what to escape, not of what to leave: exactly the +// bytes a parser reported as markup are escaped, and everything else is copied +// through. Escaping by exclusion was the same idea inverted, and it escaped +// Markdown structure — a ">" that opened a blockquote, the "<...>" around a link +// destination — which changed how the text parsed and so invalidated the very +// offsets it was writing at (#37). +func escapeProse(s string, blockContext bool) string { + ranges := markupRanges(s, blockContext) + if len(ranges) == 0 { + return s + } + var b strings.Builder + i := 0 + for _, r := range ranges { + b.WriteString(s[i:r.start]) + escapeMarkup(&b, s[r.start:r.stop]) + i = r.stop + } + b.WriteString(s[i:]) + return b.String() +} + +// markupRanges returns the regions of s that a CommonMark parser reads as +// markup rather than as text, sorted and non-overlapping: the tags of every +// raw-HTML node, and a code fence's info string. +// +// The parse is what decides. A hand-rolled backtick scanner has no model of +// block structure or of backslash escapes, so it read an escaped "\`" as a +// delimiter and let a span run across a paragraph break — two ways to call raw +// HTML literal that a real parser never would (#37). +// +// A code fence's info string is markup by the same test: it reaches the page as +// a class attribute rather than as text, so escaping it costs nothing and keeps +// an unclosed fence from carrying a tag on its opening line. It is also inert to +// block structure, so escaping it cannot disturb the parse it came from. +// +// A line-context value is read twice, and everything either reading calls markup +// is escaped. The two readings are both real: a value that opens a list item +// — a scenario step, rendered as "1. " and then the step — begins a block, where +// "" further along the line is +// the live tag instead. Neither reading alone covers the other, and the parse is +// too cheap to be worth guessing which applies. +func markupRanges(s string, blockContext bool) []byteRange { + out := parseMarkup(s, "") + if !blockContext { + out = append(out, parseMarkup(s, linePrefix)...) + } + + sort.Slice(out, func(i, j int) bool { return out[i].start < out[j].start }) + merged := out[:0] + for _, r := range out { + if n := len(merged); n > 0 && r.start <= merged[n-1].stop { + merged[n-1].stop = max(merged[n-1].stop, r.stop) + continue + } + merged = append(merged, r) + } + return merged +} + +// parseMarkup parses s behind prefix and returns the markup it finds, as ranges +// in s. The ranges are neither sorted nor merged; markupRanges does both, and +// two readings of the same string can overlap. +// +// The trailing newline is a workaround, not a nicety. goldmark decides whether +// a line is blank by comparing an indent measured in *columns* against the +// line's length in *bytes*, so a final line short enough for a tab to outrun it +// — "> \t`" — is called blank, and the fenced-code-block parser then indexes it +// at the -1 that marks one. That panics `render` on a model that lints clean. +// A line ending keeps the comparison honest, costs nothing (a document is +// defined to end with one) and, appended at the end, moves no offset in s. +// Upstream yuin/goldmark#556 fixed one path into the same -1 and v1.8.4 carries +// that fix; this input reaches it by another, and #556's own repro still panics +// on v1.8.4. TestRender_UnnormalisedProseDoesNotPanic guards it. +func parseMarkup(s, prefix string) []byteRange { + src := prefix + s + if !strings.HasSuffix(src, "\n") { + src += "\n" + } + shift := len(prefix) + + var out []byteRange + add := func(start, stop int) { + start, stop = max(start-shift, 0), min(stop-shift, len(s)) + if start < stop { + out = append(out, byteRange{start, stop}) + } + } + addSegments := func(segs *text.Segments) { + for i := 0; i < segs.Len(); i++ { + seg := segs.At(i) + add(seg.Start, seg.Stop) + } + } + // Walk never returns an error: the callback below returns none. + _ = ast.Walk(mdParser.Parse(text.NewReader([]byte(src))), func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch node := n.(type) { + case *ast.RawHTML: + addSegments(node.Segments) + case *ast.HTMLBlock: + addSegments(node.Lines()) + if node.HasClosure() { + add(node.ClosureLine.Start, node.ClosureLine.Stop) + } + case *ast.FencedCodeBlock: + if node.Info != nil { + add(node.Info.Segment.Start, node.Info.Segment.Stop) + } + } + return ast.WalkContinue, nil + }) + return out +} + +// escapeMarkup writes s as the text it should have been. A character reference +// is text to every Markdown parser, so nothing here can become a tag again. +func escapeMarkup(b *strings.Builder, s string) { + for i := 0; i < len(s); i++ { + switch c := s[i]; c { + case '<': + b.WriteString("<") + case '>': + b.WriteString(">") + case '&': + b.WriteString("&") + default: + b.WriteByte(c) + } + } } // oneLine collapses a value onto a single line for use in a heading, a list diff --git a/internal/render/markdown/markdown_test.go b/internal/render/markdown/markdown_test.go index f646bc7..8f4ebb2 100644 --- a/internal/render/markdown/markdown_test.go +++ b/internal/render/markdown/markdown_test.go @@ -3,12 +3,177 @@ package markdown import ( "fmt" "os" + "slices" "strings" "testing" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" + "github.com/stacklok/modelith/internal/model" ) +// gfm parses rendered output the way a reader's tooling will: CommonMark plus +// the GFM extensions, tables above all, since most of what this renderer emits +// is a table. +// +// Every assertion about escaping in this file goes through it rather than +// through a hand-written scan of the output. The renderer used to carry its own +// backtick matcher, and the test that guarded it reimplemented the same +// matching — so the guard shared the implementation's model of Markdown and +// agreed with it about an escaped "\`" and about a span running across a +// paragraph break, both wrong, both shipping live HTML (#37). A test that +// reuses the implementation's assumptions proves nothing; this one asks a +// second implementation. +var gfm = goldmark.New(goldmark.WithExtensions(extension.GFM)) + +func parseRendered(md string) (ast.Node, []byte) { + src := []byte(md) + return gfm.Parser().Parse(text.NewReader(src)), src +} + +// rawHTML returns every raw-HTML fragment a real parser finds in md. ADR-0014's +// rule is that a rendered model holds none: an angle bracket the author typed +// reaches the page as text. +func rawHTML(t *testing.T, md string) []string { + t.Helper() + doc, src := parseRendered(md) + var out []string + segments := func(segs *text.Segments) { + for i := range segs.Len() { + s := segs.At(i) + out = append(out, string(src[s.Start:s.Stop])) + } + } + walk(t, doc, func(n ast.Node) ast.WalkStatus { + switch node := n.(type) { + case *ast.RawHTML: + segments(node.Segments) + case *ast.HTMLBlock: + segments(node.Lines()) + if node.HasClosure() { + out = append(out, string(src[node.ClosureLine.Start:node.ClosureLine.Stop])) + } + } + return ast.WalkContinue + }) + return out +} + +// textOutsideCode returns the document's text with every code span and code +// block skipped, walking the parse tree rather than rescanning backticks. A +// payload that appears here escaped the span that was supposed to hold it. +// +// A link destination is not part of it. A destination is percent-encoded by +// linkTarget, not escaped for prose, so an encoded payload would read as a +// false positive here; the destinations are asserted exactly, by hand, at the +// call sites that produce them. +func textOutsideCode(t *testing.T, md string) string { + t.Helper() + doc, src := parseRendered(md) + var b strings.Builder + walk(t, doc, func(n ast.Node) ast.WalkStatus { + switch node := n.(type) { + case *ast.CodeSpan, *ast.FencedCodeBlock, *ast.CodeBlock: + return ast.WalkSkipChildren + case *ast.Text: + b.Write(node.Segment.Value(src)) + b.WriteString("\n") + case *ast.AutoLink: + b.Write(node.URL(src)) + b.WriteString("\n") + } + return ast.WalkContinue + }) + return b.String() +} + +// links counts the link nodes in md. A destination that closed early and opened +// a second link changes the count, whatever the label says. +func links(t *testing.T, md string) int { + t.Helper() + doc, _ := parseRendered(md) + n := 0 + walk(t, doc, func(node ast.Node) ast.WalkStatus { + switch node.(type) { + case *ast.Link, *ast.AutoLink, *ast.Image: + n++ + } + return ast.WalkContinue + }) + return n +} + +// headings returns the text of every heading in md, in document order. A value +// that broke out of its line and opened a section shows up as an extra entry. +func headings(t *testing.T, md string) []string { + t.Helper() + doc, src := parseRendered(md) + var out []string + walk(t, doc, func(n ast.Node) ast.WalkStatus { + if h, ok := n.(*ast.Heading); ok { + out = append(out, nodeText(h, src)) + return ast.WalkSkipChildren + } + return ast.WalkContinue + }) + return out +} + +// nodeText concatenates the source text of n's inline descendants, code spans +// included. ast.Node.Text is deprecated, and it would drop the span that every +// entity heading is written in. +func nodeText(n ast.Node, src []byte) string { + var b strings.Builder + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + if t, ok := c.(*ast.Text); ok { + b.Write(t.Segment.Value(src)) + continue + } + b.WriteString(nodeText(c, src)) + } + return b.String() +} + +// tableRows returns the cell count of every row of every table in md, header +// row first. A pipe that escaped its cell changes a count; counting pipes in +// the raw line cannot tell an escaped one from a structural one. +func tableRows(t *testing.T, md string) []int { + t.Helper() + doc, _ := parseRendered(md) + var out []int + walk(t, doc, func(n ast.Node) ast.WalkStatus { + switch n.(type) { + case *extast.TableHeader, *extast.TableRow: + cells := 0 + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + if _, ok := c.(*extast.TableCell); ok { + cells++ + } + } + out = append(out, cells) + } + return ast.WalkContinue + }) + return out +} + +func walk(t *testing.T, doc ast.Node, visit func(ast.Node) ast.WalkStatus) { + t.Helper() + err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + return visit(n), nil + }) + if err != nil { + t.Fatalf("walking the parsed document: %v", err) + } +} + // render calls Render with sourceDir == outDir, the default beside-the-source // case every test in this file exercises except the ones specifically about // relativizing an import link against a different output directory (see @@ -377,69 +542,32 @@ func TestRenderImports_HostilePathCannotEscapeItsMarkup(t *testing.T) { } } - // Every breakout token that survives is inside a code span, where it is - // text. A bullet's only variable parts are the two spans and the - // percent-encoded destination, so removing the spans must leave the fixed - // skeleton and nothing else. - for _, line := range strings.Split(got, "\n") { - if !strings.HasPrefix(line, "- **") { - continue - } - bare := stripCodeSpans(line) - for _, tok := range []string{" 0 { + t.Errorf("a path reached the page as live HTML %q:\n%s", html, got) } - for _, line := range strings.Split(got, "\n") { - if strings.HasPrefix(line, "#") && strings.Contains(line, "Injected") { - t.Errorf("a path injected the heading %q:\n%s", line, got) + bare := textOutsideCode(t, got) + for _, tok := range []string{"= 0 && close+len(fence) < len(rest) && rest[close+len(fence)] == '`' { - // A longer run is not this fence's closer; keep looking. - next := strings.Index(rest[close+1:], fence) - if next < 0 { - close = -1 - break - } - close += 1 + next - } - if close < 0 { - b.WriteString(fence) // unclosed: the fence is literal text - continue - } - i += close + len(fence) - } - return b.String() -} - // TestCodeSpan_FencesAroundBackticks checks the CommonMark rule the imports // section leans on: a span's fence is longer than any backtick run inside it, so // a value holding backticks stays intact instead of closing the span early. @@ -478,3 +606,426 @@ func TestRenderEntity_SubtypeHierarchy(t *testing.T) { t.Errorf("expected parent to list its subtypes:\n%s", got) } } + +// TestProse_EscapesHTMLOutsideCodeSpans pins the escaping rule ADR-0014 +// records, case by case: angle brackets become character references outside a +// literal region and are left alone inside one, an ampersand is escaped only +// where it introduces a character reference, and Markdown is never touched. +// +// The "block" cases are the fields emitted as their own block — a description, +// a definition — where a code fence or an indented block is literal. Everything +// else lands inside a line, where it is not. +func TestProse_EscapesHTMLOutsideCodeSpans(t *testing.T) { + t.Parallel() + + cases := []struct { + name, in, want string + block bool + }{ + {name: "plain text", in: "A parking ticket.", want: "A parking ticket."}, + {name: "tag", in: "an tag", want: "an <img src=q onerror=alert(1)> tag"}, + {name: "markdown survives", in: "a `Ticket` with *emphasis*", want: "a `Ticket` with *emphasis*"}, + {name: "inside a code span", in: "a `` span", want: "a `` span"}, + {name: "in and out", in: " `` ", want: "<a> `` <c>"}, + {name: "unclosed span is text", in: "a ` tail", want: "a ` <b> tail"}, + {name: "double fence", in: "``a `` c`` ", want: "``a `` c`` <d>"}, + // An "&" is markup to nobody, so every one of these is left as the + // author typed it. A character reference renders as the character it + // names, which is what the author asked for; it can never become a tag, + // because a parser decodes it to text and re-escapes it on output. + {name: "bare ampersand", in: "R&D and a & b", want: "R&D and a & b"}, + {name: "named reference", in: "<pre>", want: "<pre>"}, + {name: "decimal reference", in: "<", want: "<"}, + {name: "hex reference", in: "<", want: "<"}, + {name: "reference-shaped but unterminated", in: "< and &", want: "< and &"}, + {name: "reference inside a code span", in: "`<`", want: "`<`"}, + // An angle bracket that opens no tag is not markup and is not touched. + // A Markdown parser renders it as the character it is. + {name: "comparison", in: "1 < 2 && 3 > 2", want: "1 < 2 && 3 > 2"}, + {name: "conceptual type", in: "map", want: "map"}, + {name: "multibyte passes through", in: "café — naïve ", want: "café — naïve <x>"}, + // An "&" inside a tag is escaped with it, so the whole tag reaches the + // page as the bytes the author typed. + {name: "ampersand inside a tag", in: ``, want: `<a href="?a=1&amp;b=2">`}, + + // Markdown structure is never escaped. Escaping it was the previous + // rule's undoing: a ">" turned into ">" stops opening a blockquote, + // so the indented code block inside collapsed into a paragraph and + // shipped its contents live (#37 H2). + { + name: "blockquote marker survives", + block: true, + in: "Quoted from the migration notes:\n\n> \n\nNothing after is affected.", + want: "Quoted from the migration notes:\n\n> \n\nNothing after is affected.", + }, + {name: "angle-bracket link destination", in: "[click]()", want: "[click]()"}, + {name: "autolink", in: "see ok", want: "see ok"}, + + // An HTML block opens on a tag that is not a complete one, and a value + // on a line can still begin a block: a scenario step is rendered as + // "1. " and then the step, so the step opens the list item's first + // block. Read only as inline content, "x", want: "<pre>x"}, + + // A backslash-escaped backtick is a literal character and opens + // nothing. The scanner this replaced read every backtick as a + // delimiter, so the tag between two of them shipped live (#37 F1). + { + name: "escaped backtick opens no span", + in: `Escaped tick: \` + "`" + ` then bold then \` + "`" + `.`, + want: `Escaped tick: \` + "`" + ` then <b>bold</b> then \` + "`" + `.`, + }, + // A code span cannot cross a paragraph break, so neither backtick + // opens one and the tag between them is ordinary inline HTML (#37 F2). + { + name: "span cannot cross a paragraph break", + block: true, + in: "A trailing ` marks it.\n\nLegacy importers emit .\n\nA second ` closes nothing.", + want: "A trailing ` marks it.\n\nLegacy importers emit <img src=x onerror=alert(1)>.\n\nA second ` closes nothing.", + }, + // Block-level code is literal in a block field: escaping there puts a + // visible "<" on the page, the fidelity bug ADR-0014 exists to + // avoid. A backtick fence survived the old scanner only by accident + // (#37 F3). + { + name: "tilde fence is verbatim", + block: true, + in: "Shape:\n\n~~~\n\n~~~", + want: "Shape:\n\n~~~\n\n~~~", + }, + { + name: "indented code block is verbatim", + block: true, + in: "Shape:\n\n ", + want: "Shape:\n\n ", + }, + { + name: "backtick fence is verbatim", + block: true, + in: "Shape:\n\n```\n\n```", + want: "Shape:\n\n```\n\n```", + }, + // Inside a line there is no block context to open, so what looks like + // a fence is text and the tag behind it is escaped. + {name: "fence opener inside a line", in: "```", want: "```<b>"}, + {name: "code fence info is not text", block: true, in: "```\nx\n```", want: "```<b>\nx\n```"}, + } + for _, tc := range cases { + got := escapeProse(tc.in, tc.block) + if got != tc.want { + t.Errorf("%s: escapeProse(%q, %t) = %q, want %q", tc.name, tc.in, tc.block, got, tc.want) + } + } +} + +// TestADR_0014_NoRawHTMLSurvivesRender is the whole-document form of the rule: +// take a model whose every prose field carries a payload that defeated the +// hand-rolled scanner, render it, and ask an independent CommonMark parser +// whether any raw HTML is left. This is the guard the old one should have been +// — it shared the scanner's model of Markdown, so it agreed with the bug. +func TestADR_0014_NoRawHTMLSurvivesRender(t *testing.T) { + t.Parallel() + + const tag = "" + esc := "`" + cases := []struct{ name, payload string }{ + {"plain tag", "Legacy importers emit " + tag + " in the notes."}, + // #37 F1: the escaped backtick opened a span the parser never sees. + {"escaped backtick", `A trailing \` + esc + ` marks it, then ` + tag + `, then \` + esc + `.`}, + // #37 F2: two bare backticks separated by a paragraph break. + {"paragraph break", "A trailing " + esc + " marks it.\n\n" + tag + " in the notes.\n\nA second " + esc + " here."}, + {"unclosed span", "A trailing " + esc + " then " + tag + "."}, + {"reference-encoded tag", "<img src=x onerror=alert(1)>"}, + {"html block", "
\ntext\n
"}, + // An HTML block opens on a tag that never completes, and a scenario step + // begins its list item's first block — so this is a block opener in one + // position and inline text in another. + {"unterminated closing tag", " 0 { + t.Errorf("raw HTML survived the render: %q\n%s", html, got) + } + }) + } +} + +// TestADR_0014_BlockCodeStaysVerbatim is the other half of ADR-0014: a code +// block in a block-level field must reach the page as the author wrote it. The +// old pass escaped inside a "~~~" fence and an indented block, putting the +// visible "<" on the page the rule exists to avoid (#37 F3). +func TestADR_0014_BlockCodeStaysVerbatim(t *testing.T) { + t.Parallel() + + const payload = "Shape:\n\n~~~\n\n~~~\n\nAnd indented:\n\n " + m := &model.Model{ + Entities: map[string]model.Entity{"Ticket": {Definition: payload}}, + } + got := render(m) + + if strings.Contains(got, "<policy") { + t.Errorf("escaped inside a code block, so the reader sees the escape:\n%s", got) + } + if !strings.Contains(got, "") || !strings.Contains(got, "") { + t.Errorf("a code block did not survive verbatim:\n%s", got) + } + // Verbatim is only safe because a real parser agrees it is code. + if html := rawHTML(t, got); len(html) > 0 { + t.Errorf("the code block reached the page as live HTML %q:\n%s", html, got) + } +} + +// TestADR_0014_ProseRendersHTMLAsText walks every prose-bearing field through a +// render and checks that raw HTML lands as visible text rather than as markup, +// while the Markdown those fields are written in still works. Before the fix +// each of these strings reached the document as live HTML. +func TestADR_0014_ProseRendersHTMLAsText(t *testing.T) { + t.Parallel() + + const tag = "" + m := &model.Model{ + Title: "Title " + tag, + Description: "Description " + tag, + Glossary: map[string]string{"Attendant": "Glossary " + tag}, + Enums: map[string]model.Enum{ + "Status": { + Description: "Enum " + tag, + Values: []model.EnumValue{{Name: "active", Definition: "Value " + tag}}, + }, + }, + Entities: map[string]model.Entity{ + "Ticket": { + Definition: "Definition " + tag + " with a `code ` and *emphasis*", + Derived: true, + Derivation: "Derivation " + tag, + Relationships: []model.Relationship{ + {Entity: "Policy", Cardinality: "1:n", Role: "Role " + tag, Note: "Note " + tag}, + }, + Attributes: []model.Attribute{ + {Name: "paid", Type: "map", Description: "Attribute " + tag}, + }, + Actions: []model.Action{ + {Name: "pay", Actor: "Attendant", Preserves: []string{"pre-" + tag}, Description: "Action " + tag}, + }, + Invariants: []model.Invariant{{ID: "ticket-paid", Statement: "Invariant " + tag}}, + }, + "Policy": {Definition: "A policy."}, + }, + Invariants: []model.Invariant{{ID: "model-rule", Statement: "Model invariant " + tag}}, + Scenarios: []model.Scenario{{ + Name: "Scenario " + tag, + Description: "Scenario description " + tag, + Actors: []string{"Actor " + tag}, + Steps: []string{"Step " + tag}, + InvariantsTouched: []string{"ticket-paid", "touched-" + tag}, + }}, + } + got := render(m) + + const escaped = "<img src=q onerror=alert(1)>" + // One occurrence per field above, plus the two invariant statements repeated + // under "Invariants touched" — every one of them escaped. + for _, field := range []string{ + "Title", "Description", "Glossary", "Enum", "Value", "Definition", + "Derivation", "Role", "Note", "Attribute", "Action", "Invariant", + "Model invariant", "Scenario", "Scenario description", "Actor", "Step", + "pre-", "touched-", + } { + if !strings.Contains(got, field+" "+escaped) && !strings.Contains(got, field+escaped) { + t.Errorf("%s field did not render its tag as text:\n%s", field, got) + } + } + if strings.Contains(got, tag) { + t.Errorf("a raw tag survived into the document:\n%s", got) + } + // The type column is prose too, and a conceptual type may hold angle + // brackets legitimately. " |") { + t.Errorf("expected the conceptual type verbatim:\n%s", got) + } + if html := rawHTML(t, got); len(html) > 0 { + t.Errorf("raw HTML survived the render: %q\n%s", html, got) + } + // Markdown in prose is the point of not escaping everything: the code span + // keeps its angle brackets literal and the emphasis still renders. + if !strings.Contains(got, "a `code ` and *emphasis*") { + t.Errorf("expected Markdown and its code span to survive intact:\n%s", got) + } +} + +// TestRender_UnnormalisedProseDoesNotPanic pins the guard in parseSource. +// goldmark measures a line's indent in columns and its length in bytes, so a +// final line a tab can outrun is taken for a blank one and the fenced-code-block +// parser indexes it at the -1 that marks one. Every input here lints clean and +// crashed `modelith render` with a Go stack trace before the trailing newline +// went in; the first is upstream yuin/goldmark#556's own repro, which v1.8.4 +// still panics on despite carrying the fix for it. +// +// Rendering has to stay sane, not merely survive: the payloads come back as the +// author typed them, and no raw HTML rides in on the workaround. +func TestRender_UnnormalisedProseDoesNotPanic(t *testing.T) { + t.Parallel() + + cases := []struct{ name, payload string }{ + {"upstream 556 repro", "*\n\t* \t~"}, + {"blockquote tab backtick", "A ticket.\n\n> \t`"}, + {"bare blockquote tab backtick", "> \t`"}, + {"blockquote tab tilde", "> \t~"}, + {"tab before a fence", "Shape:\n\n> \t```"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m := &model.Model{ + Description: tc.payload, + Entities: map[string]model.Entity{ + "Ticket": {Definition: tc.payload}, + }, + Scenarios: []model.Scenario{{Name: "Pay", Description: tc.payload}}, + } + got := render(m) + if !strings.Contains(got, tc.payload) { + t.Errorf("payload %q did not reach the page verbatim:\n%s", tc.payload, got) + } + if html := rawHTML(t, got); len(html) > 0 { + t.Errorf("raw HTML survived the render: %q\n%s", html, got) + } + }) + } +} + +// TestADR_0014_AssembledLineEscapesAsOneLine is the regression for the +// cross-field pairing in #37: a `role:` and a `note:` are separate schema +// fields that share one rendered line, so escaping them separately let a stray +// backtick in the role pair with the note's backticks and leave the note's tag +// outside every code span, live. Whether a span is open is a property of the +// finished line, so the line is what gets escaped. +func TestADR_0014_AssembledLineEscapesAsOneLine(t *testing.T) { + t.Parallel() + + const tag = "" + m := &model.Model{ + Glossary: map[string]string{"Term": "a ` stray then `" + tag + "` here"}, + Entities: map[string]model.Entity{ + "Team": { + Definition: "A team.", + Relationships: []model.Relationship{ + {Entity: "Member", Cardinality: "n:n", Role: "`Owner or `Member`", Note: "Rendered as `" + tag + "` in the UI"}, + }, + Actions: []model.Action{ + {Name: "add", Preserves: []string{"a ` stray"}, Description: "Shown as `" + tag + "` there"}, + }, + Invariants: []model.Invariant{{ID: "one ` tick", Statement: "Holds for `" + tag + "` rows"}}, + }, + "Member": {Definition: "A member."}, + }, + Scenarios: []model.Scenario{{ + Name: "Join", + Actors: []string{"a ` stray", "the `" + tag + "` operator"}, + }}, + } + got := render(m) + + if html := rawHTML(t, got); len(html) > 0 { + t.Errorf("a field paired with its neighbour and shipped live HTML %q:\n%s", html, got) + } + if bare := textOutsideCode(t, got); strings.Contains(bare, " 0 { + t.Errorf("a name reached the page as live HTML %q:\n%s", html, got) + } + bare := textOutsideCode(t, got) + for _, tok := range []string{"" become HTML character references, in that order so an +// ampersand the author wrote is not read as part of a reference this function +// introduced. Mermaid builds its labels as HTML: a raw "" is parsed as a +// tag and disappears from the rendered diagram with no diagnostic, while the +// reference form is decoded back to the literal character. This is an encoding, +// not a visible escape — the reader sees exactly what the author typed. func sanitize(s string) string { s = strings.ReplaceAll(s, "`", "") s = strings.ReplaceAll(s, "\"", "'") s = strings.ReplaceAll(s, "\\", "") s = strings.ReplaceAll(s, "[", "(") s = strings.ReplaceAll(s, "]", ")") + s = percentRunRE.ReplaceAllString(s, "%") + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\r", " ") s = strings.ReplaceAll(s, "\t", " ") diff --git a/internal/render/mermaid/mermaid_test.go b/internal/render/mermaid/mermaid_test.go index 193b4c4..210b35a 100644 --- a/internal/render/mermaid/mermaid_test.go +++ b/internal/render/mermaid/mermaid_test.go @@ -600,22 +600,22 @@ func TestERDedupesSemanticallyEqualInverses(t *testing.T) { } } -// TestERRole_HostileCharacters pins current output for a role that packs -// every hostile character sanitize knows about (backticks, quotes, brackets, a -// literal newline) alongside characters it does NOT neutralize: '<', '>', and -// '%%'. This golden pins the CURRENT (buggy) behavior on purpose, not the -// desired one: fixing issue #29 must update this golden as part of that fix, -// not treat the diff as a regression. This test pins only the generated `.mmd` -// source text produced by ER() — it does not verify how a Mermaid renderer -// interprets that text. +// TestERRole_HostileCharacters pins the output for a role that packs every +// hostile character sanitize knows about — backticks, quotes, brackets, a +// literal newline, '<', '>', and '%%'. // -// The role also embeds a `%%{init: ...}%%`-shaped substring. Separately, a -// real render of that shape through mmdc was confirmed to parse successfully -// while silently changing the whole diagram's theme and dropping this edge's -// label text — a more severe manifestation of #29 than plain character -// passthrough into the label. This golden pins that the substring reaches the -// generated source unescaped; it does not itself exercise or assert the -// renderer-level effect, which was only checked by hand outside this test. +// This golden previously pinned the pre-#29 behavior on purpose, with '<', '>' +// and '%%' reaching the diagram source untouched. The fix for #29 replaced it: +// the angle brackets are now character references and the doubled percent signs +// are collapsed to single ones, which is what the diff against the old golden +// shows. +// +// The test asserts the generated `.mmd` source only. What a Mermaid renderer +// does with that source was checked separately against mmdc 11: with the +// doubled signs, the `%%{init: ...}%%` substring parsed as a configuration +// directive, restyled the whole diagram and swallowed the label; with single +// signs the diagram keeps its default theme and the text stays in the label. +// `task mermaid-check` feeds this file to the real parser. func TestERRole_HostileCharacters(t *testing.T) { t.Parallel() role := "he said \"hi\" [bracket] `tick` {brace} #hash %%pct %%{init: {'theme':'forest'}}%%\nsecond line" @@ -673,3 +673,50 @@ func TestER_UndeclaredRelationshipTarget(t *testing.T) { }} assertGolden(t, m, "testdata/undeclared_target.golden.mmd") } + +// TestSanitize_NeutralizesMermaidMetacharacters is the regression for issue +// #29, case by case. A directive needs doubled percent signs and a tag needs a +// raw angle bracket, so neutralizing exactly those is what closes the gap while +// leaving an ordinary role — including a lone "%" and a lone "&" — readable. +func TestSanitize_NeutralizesMermaidMetacharacters(t *testing.T) { + t.Parallel() + + cases := []struct{ name, in, want string }{ + {"ordinary role", "owns", "owns"}, + {"directive", "%%{init:{'theme':'dark'}}%%", "%{init:{'theme':'dark'}}%"}, + {"comment", "before %% after", "before % after"}, + {"longer run", "%%%%x", "%x"}, + {"lone percent survives", "50% full", "50% full"}, + {"angle brackets", "", "<angle>"}, + {"tag", "", "<img src=q onerror=alert(1)>"}, + {"ampersand encoded", "R&D", "R&D"}, + // The ampersand is escaped before the angle brackets, so a reference the + // author wrote is not fused with one this function introduced: mermaid + // decodes it back to the four characters "<". + {"pre-escaped reference", "<x>", "&lt;x&gt;"}, + {"backticks and quotes still go", "`a` \"b\" [c]", "a 'b' (c)"}, + {"newlines still collapse", "a\nb\tc", "a b c"}, + } + for _, tc := range cases { + if got := sanitize(tc.in); got != tc.want { + t.Errorf("%s: sanitize(%q) = %q, want %q", tc.name, tc.in, got, tc.want) + } + } +} + +// TestERSelfRow_HostileRole guards that a self-referential relationship, which +// renders as a row inside the entity block rather than as an edge, goes through +// the same sanitizing. It is a second call site (selfComment), and #29's gap +// was equally reachable from it. +func TestERSelfRow_HostileRole(t *testing.T) { + t.Parallel() + m := &model.Model{Entities: map[string]model.Entity{ + "Node": {Definition: "n", Relationships: []model.Relationship{ + {Entity: "Node", Cardinality: "n:n", Role: "%%{init:{'theme':'dark'}}%% "}, + }}, + }} + got := ER(m) + if want := `Node self "n:n — %{init:{'theme':'dark'}}% <angle>"`; !strings.Contains(got, want) { + t.Errorf("expected %q in:\n%s", want, got) + } +} diff --git a/internal/render/mermaid/testdata/hostile_role.golden.mmd b/internal/render/mermaid/testdata/hostile_role.golden.mmd index e9e86b5..bcf0322 100644 --- a/internal/render/mermaid/testdata/hostile_role.golden.mmd +++ b/internal/render/mermaid/testdata/hostile_role.golden.mmd @@ -1,4 +1,4 @@ erDiagram A {} B {} - A ||--o{ B : "he said 'hi' (bracket) tick {brace} #hash %%pct %%{init: {'theme':'forest'}}%% second line" + A ||--o{ B : "he said 'hi' (bracket) tick {brace} <angle> #hash %pct %{init: {'theme':'forest'}}% second line" diff --git a/project-docs/adr/0014-prose-is-markdown-not-html.md b/project-docs/adr/0014-prose-is-markdown-not-html.md new file mode 100644 index 0000000..9ec897b --- /dev/null +++ b/project-docs/adr/0014-prose-is-markdown-not-html.md @@ -0,0 +1,209 @@ +# A prose field is Markdown, not HTML + +A `definition:`, `description:`, `role:`, `note:`, invariant `statement:` or +scenario step is rendered as Markdown, and its raw HTML is rendered as visible +text rather than interpreted. An angle bracket the author typed appears on the +page as an angle bracket. Markdown — emphasis, and the backticked entity names +these fields are written in throughout — keeps working. + +## Context + +The renderer wrote prose fields into the Markdown document verbatim. Markdown +allows inline HTML, so `` in a `definition:` became a +tag in the published page, and no lint severity flagged it. + +Passing Markdown through is deliberate and has to stay. `lint`'s backticked-term +check expects prose to name entities in code spans, the schema tells authors to +backtick entity names in a role and an invariant statement, and the worked +example relies on both. So the choice was never "escape prose or don't" — it was +where inside prose the line falls. + +Today the hole is cosmetic: you wrote the model you render. ADR-0010's vendoring +slice renders models copied from repositories this one does not control into +local docs and a published site. At that point the author of the string needs +commit access to *their* repo, not yours, and this becomes a trust boundary. +ADR-0011 keeps `render` offline, so nothing else stands between a vendored file +and the page. + +## Decision + +**Escape the bytes a parser calls markup, and leave every other byte alone.** +The rule is an allowlist of what to escape rather than of what to spare: the +byte ranges of the raw-HTML nodes — `ast.RawHTML`, `ast.HTMLBlock` — have their +`<`, `>` and `&` replaced by character references, and nothing else in the +string is touched. A character reference is text to every Markdown parser; it +cannot become a tag. + +**The inverse rule was tried first, and it corrupted the document.** Collecting +the literal regions and escaping everything outside them meant escaping Markdown +*structure*. A `>` is a blockquote marker, so `>` stopped opening one: the +indented code block inside the quote — which the same parse had just called +literal — collapsed into a paragraph and shipped its contents live. Escaping +changed how the text parsed, which invalidated the offsets it was writing at. It +also killed the `<...>` around a link destination, leaving a dead href. Escaping +only what a parser recognised as markup has no such feedback: it removes angle +brackets and never adds one, so it cannot disturb the parse it came from. + +**`github.com/yuin/goldmark` decides what is markup.** The first attempt tracked +code spans with a character scanner, and a scanner is not a parser: it read a +backslash-escaped `` \` `` as a delimiter and let a span run across a paragraph +break, calling raw HTML literal in two ways CommonMark never would. Each corner +was found by someone looking for it. The next one would have shipped live. + +**Only the parse is borrowed, never the rendering.** goldmark locates the markup +and the escaping writes back into the original bytes at those offsets. Running +goldmark's renderer would normalize and reflow prose the author wrote and +rewrite every committed `.md`; the parser is also assembled directly from +`parser.NewParser`, so the HTML renderer never enters the binary. + +**A code fence's info string counts as markup.** It reaches the page as a class +attribute rather than as text, and it is inert to block structure, so escaping +it cannot disturb the parse and it keeps an unclosed fence from carrying a tag +on its opening line. + +**A rendered line is escaped once, as the whole line.** Whether a backtick opens +a code span is a property of the finished line, not of the field it came from. A +`role:` and the `note:` beside it are separate schema fields that share one +bullet, and escaping them separately let a stray backtick in the role pair with +the note's backticks and leave the note's tag outside every span, live. So the +line is assembled first, generated markup included, and escaped as the reader's +parser will see it. Table cells stay separate, because GFM splits a row into +cells before it parses any of them. + +**Where a value lands decides how it is parsed.** A description emitted as its +own block can hold a code block. A value on a line always follows something — +`- `, `# `, `| ` — so a leading four-space indent or a ` ``` ` run cannot open a +code block there. A line-context value is therefore parsed behind a stand-in for +that text: parsed alone it would look like a code block, and a parser reports +nothing inside one as HTML. + +**An `&` outside a tag is left alone.** Escaping the ones that introduced a +character reference was fidelity, not safety — a reference decodes to a +character and is re-escaped on output, so it can never produce markup — and it +is not worth a rule that reaches beyond the markup. `R&D`, `a & b` and a query +string pass into the committed Markdown byte-for-byte. Inside a tag the `&` is +escaped along with the brackets, so the tag reaches the page as it was typed. + +The Mermaid renderer reaches the same contract by a different encoding. Mermaid +builds labels as HTML and decodes references back to characters, so `sanitize` +escapes *every* `&` along with `<` and `>` — a round trip that never surfaces, +rather than a visible escape. Both renderers answer the same question the same +way: the reader sees the characters the author typed. + +## Considered and rejected + +- **Harden the scanner.** Teach it backslash escapes, then blank lines, then + fences. That is writing a CommonMark parser one bug report at a time, and the + failure mode of getting it wrong is a live tag on a published page. +- **Escape everything outside the literal regions.** The parse says what is + literal, so the complement looked like a safe default. It is not: the + complement contains the document's structure, and escaping structure changes + the parse the offsets came from. See the Decision above. +- **Escape every `<` and `>`, code spans included.** Simple and safe, and it + puts a visible `<` in every model that backticks a generic type. The + fidelity loss is the thing this decision is about. +- **Render through goldmark and emit its output.** It would be correct by + construction and it would rewrite the author's prose — reflowed paragraphs, + normalized emphasis, churn in every committed `.md` on every upgrade of the + library. +- **Vendor goldmark's table transformer to parse as GFM.** The parser + configuration here is CommonMark; every reader's is GFM. GitHub and Docusaurus + both apply the GFM extensions, and GFM's table transformer can dissolve a + paragraph into a table, so a CommonMark code span that spanned a newline never + forms and its contents land live. `goldmark/extension` cannot be added for its + parsers alone: every file in it imports the root `goldmark` package for the + `Extend` signature, whose eager `defaultMarkdown` global drags `renderer` and + `renderer/html` into the binary — measured, 27 renderer symbols against zero + today. Closing it means vendoring the transformer into a renderer-free + package. + + Rejected on cost against threat. Reaching this needs a model author who is + hostile *and* already has commit access to the repository being rendered — + who could edit the generated `.md` directly instead. Carrying a vendored copy + of someone else's parser internals, with its own licence and upgrade burden, + is not worth closing a hole that only opens when the author is a stranger. + + **That changes when vendoring lands.** ADR-0010's slice 2 renders models from + repositories this one does not control, which is the first time the author of + a prose string is not already trusted. The decision there is to say so rather + than to armour the renderer: `modelith deps import` and `deps update` warn + that vendoring carries an injection risk and that models should only be + vendored from sources you trust. In practice vendoring is expected to be + between projects that already trust each other. If that assumption stops + holding, revisit this rejection before adding more escaping. + +## Consequences + +- **modelith takes its first parsing dependency.** It ships as a single static + binary, so this is not free: goldmark is pure Go (no `import "C"`, builds + under `CGO_ENABLED=0`), only its `ast`, `parser`, `text` and `util` packages + link in, and the binary grows from 6.26 MB to 6.89 MB. Its `net` imports are + `net/url` and, through it, `net/netip` — parsers that perform no I/O, which + `TestADR_0011_OfflinePackages` allows by name. The offline boundary is + unchanged. +- **Markdown structure survives, because none of it is escaped.** A blockquote + stays a blockquote, `[click]()` keeps its href, and + `` stays an autolink. +- **A code block in a block-level field reaches the page verbatim.** A `~~~` + fence or an indented block in a `description:` or `definition:` is code, and + is left alone. In a table cell or a list item the same text is not, because it + could not open a block there. +- **An angle bracket that opens no tag is not markup and is not escaped.** + `map` and `1 < 2 && 3 > 2` reach the committed `.md` as typed and + render as themselves. +- **An author who wrote `<` now sees `<`.** The reference is decoded on the + page rather than shown. This is the fidelity the `&` rule used to buy, given + up to keep the escaping inside the markup. +- **An entity `derivation:` is collapsed onto one line.** It renders after + `**Derived:** `, so it is a line, not a block, and is escaped as one. +- **An author cannot embed raw HTML in a model.** No model did, and a domain + model is not a place to hand-write markup. Someone who needs a construct + Markdown lacks has to ask for it, which is the right conversation to have. +- **goldmark is handed a trailing newline it did not get from the author.** It + compares an indent measured in columns against a line's length in bytes, so a + final line a tab can outrun is taken for a blank one and the fenced-code-block + parser indexes it at the -1 that marks one — a panic on a model that lints + clean. Upstream yuin/goldmark#556 is the same -1 by another path; v1.8.4 + carries that fix and still panics on #556's own repro, so there is no release + to upgrade to. Appending the line ending a document is defined to have moves + no offset inside the string. `TestRender_UnnormalisedProseDoesNotPanic` pins + it. +- **No committed golden changed.** Nothing in `examples/` or + `docs/05-parking-garage/` holds an angle bracket or an ampersand in a prose + field, so this landed as a pure behavior change with no output churn — which + also means the goldens do not demonstrate it. + `TestADR_0014_ProseRendersHTMLAsText` walks every prose-bearing field through + a render; `TestADR_0014_NoRawHTMLSurvivesRender` asks a second parser whether + anything survived; `TestADR_0014_BlockCodeStaysVerbatim` pins the other + direction; `TestADR_0014_AssembledLineEscapesAsOneLine` pins the shared line. + +## Not decided here + +Each of these is a different class from raw HTML. Naming them is the point: the +rule above is about what a parser calls a tag, and none of these is one. + +- **A link's scheme.** `[click](javascript:alert(1))` and the autolink + `` both pass through, because a destination is Markdown + and not raw HTML. The allowlist made the autolink form survive where the + earlier rule had incidentally broken it, so this is now the live question + rather than a latent one. Constraining schemes is its own decision, with its + own list of what to allow, and it is not taken here. +- **MDX.** The docs are built by Docusaurus, whose MDX parser reads `<` followed + by a name character as a JSX tag. `map` and `` + both fail an `@mdx-js/mdx@3` compile, while `1 < 2 && 3 > 2` and an escaped + `<img src=q>` both pass. A `{` in prose still reaches the build as an + expression. This is unchanged from before the escaping existed — the same + bytes reach the page as they did — but the escaping does not close it either. +- **A prose block that leaves a code fence open.** A `definition:` of + ` ``` ` and then `
` parses, on its own, as an unclosed + fenced block whose contents are code — so nothing in it is markup. In the + assembled document that fence pairs with the next one, which is the Mermaid + diagram's, and the tag lands outside any block and goes live. The field's own + parse is only authoritative while the field is self-contained, and an open + fence is exactly the case where it is not. The bytes are unchanged from before + the escaping existed. Refusing an unbalanced fence reads as a lint rule about + a malformed prose block rather than as a renderer escaping decision, and which + it should be is not settled here. +The parser-configuration gap — CommonMark here, GFM in every reader — *was* +open when this ADR was first written. It is now decided: see "Vendor goldmark's +table transformer to parse as GFM" under Considered and rejected.