diff --git a/assets/main.js b/assets/main.js index 6a32e94..0d5982a 100644 --- a/assets/main.js +++ b/assets/main.js @@ -228,7 +228,12 @@ if (headings.length > 0 && tocLinks.length > 0) { // ── Code copy buttons ── // Goldmark wraps highlighted code in .highlight > pre; plain code is just pre. +// Code blocks with a title or line highlights render with Chroma's +// table-mode line numbers, which emits a second
 for the gutter
+// column (no  inside). Skip it so it doesn't get its own (broken —
+// it would copy line numbers, not code) button.
 document.querySelectorAll('.prose pre').forEach(pre => {
+  if (!pre.querySelector('code')) return;
   const btn = document.createElement('button');
   btn.className = 'code-copy';
   btn.textContent = 'copy';
diff --git a/assets/theme.css b/assets/theme.css
index 8d95439..996374c 100644
--- a/assets/theme.css
+++ b/assets/theme.css
@@ -427,6 +427,72 @@ body {
   margin: 0;
 }
 
+/* ── Code block: title bar and line highlighting (#6) ── */
+.prose .code-block {
+  margin: 1.25rem 0;
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  overflow: hidden;
+  background: var(--code-bg);
+}
+
+.prose .code-block .code-title {
+  font-family: var(--font-mono);
+  font-size: 0.7rem;
+  font-weight: 500;
+  letter-spacing: 0.04em;
+  color: var(--text-2);
+  background: var(--bg-alt);
+  padding: 0.45rem 1.5rem;
+  border-bottom: 1px solid var(--border);
+  /* show the user a full filename even when the underlying string is long */
+  word-break: break-all;
+}
+
+.prose .code-block .code-body {
+  /* reset the outer .prose pre margin so the border doesn't double up */
+  margin: 0;
+}
+
+/* Chroma's line numbers are rendered inline (WithLineNumbers, no
+   LineNumbersInTable) — the "chroma" class lands on the 
 itself, not
+   on a wrapping 
, so there's a single element to reset here. */ +.prose .code-block .code-body .chroma { + margin: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +/* highlighted lines: a soft accent wash that works in both themes. + Chroma's own generated CSS already sets `.chroma .line { display: flex }` + (see chroma.css) so every line is a full-width flex row — we only need + to add the background on top of that, not redeclare layout. Overriding + display here (e.g. inline-block) breaks Chroma's layout for consecutive + highlighted lines. + + The wash bleeds into .prose pre's own 1.5rem horizontal padding (same + negative-margin/padding technique mkdocs-material's .hll and VitePress's + .highlighted use) so it reaches the card's edges instead of stopping at + the code text's own inset. + + Selector is deliberately as deep as the real DOM nesting (.prose + .code-block .code-body .chroma .highlight-line — 5 classes): Chroma's + own light-theme .hl rule is now scoped under :root:not([data-theme= + "dark"]) (see ChromaCSS), which makes it 4 classes deep, so anything + shallower than 5 here would lose to Chroma's grey and never show. */ +.prose .code-block .code-body .chroma .highlight-line { + background: var(--accent-dim); + margin: 0 -1.5rem; + padding: 0 1.5rem; +} + +[data-theme="dark"] .prose .code-block .code-body .chroma .highlight-line { + /* The light-theme accent-dim is too saturated for dark mode — dial + it back so token colours stay legible on top of it. */ + background: rgba(96, 165, 250, 0.08); +} + .code-copy { position: absolute; top: 0.6rem; diff --git a/docs/guide/03-markdown.md b/docs/guide/03-markdown.md index fe2f8cb..bf296ce 100644 --- a/docs/guide/03-markdown.md +++ b/docs/guide/03-markdown.md @@ -100,3 +100,84 @@ dark_code_theme = "github-dark" ``` Any [Chroma style](https://xyproto.github.io/splash/docs/) is valid. Light and dark themes are emitted as separate CSS classes, toggled by `[data-theme]` on ``. + +### Filename titles + +Add `title="…"` to the opening fence to render a filename label above the block: + +```go title="cmd/root.go" +package main + +func main() {} +``` + +Renders as a `
` with a `
cmd/root.go
` header bar. + +**Syntax:** + +````markdown +```go title="cmd/root.go" +package main +``` +```` + +The title can be wrapped in either single or double quotes. Use the opposite quote style if the title itself contains quotes (e.g. `title='has "quotes" in it'`). + +### Line highlighting + +Add `{n,m-p,…}` to the opening fence to highlight specific lines (1-indexed, counted from the first line of the block). Use a comma to list individual lines, and a hyphen to specify a range: + +```go {2,4-6} +line 1 +line 2 +line 3 +line 4 +line 5 +line 6 +``` + +Each highlighted line gets a `` class with a soft background wash and an accent bar to its left. + +**Syntax:** + +````markdown +```go {2,4-6} +line 1 +line 2 +line 3 +line 4 +line 5 +line 6 +``` +```` + +### Combining both + +The two attributes can be combined in any order: + +```python title="example.py" {1,3-4} +print("a") +print("b") +print("c") +print("d") +``` + +**Syntax:** + +````markdown +```python title="example.py" {1,3-4} +print("a") +print("b") +print("c") +print("d") +``` +```` + +### Behaviour notes + +- A plain code block (no `title=` or `{}`) is rendered exactly as before — the new extension is a no-op for the common case. +- Empty `title=""` is treated as no title. +- Invalid range tokens (`{notanumber}`) are silently ignored; the rest of the expression still applies. +- Out-of-range line numbers are silently ignored — no error, no broken page. +- Reverse ranges (`{5-2}`) are silently ignored. +- D2 diagram blocks (```` ```d2 ````) are caught by the D2 extension before the code-attribute one sees them; adding `title=` or `{}` to a D2 block does not change its behaviour. diff --git a/internal/parser/doc.go b/internal/parser/doc.go index 14e1ad4..ba77d56 100644 --- a/internal/parser/doc.go +++ b/internal/parser/doc.go @@ -18,4 +18,10 @@ // // - D2 diagrams: fenced code blocks with language "d2" are compiled to // inline SVG pairs (light + dark) using the D2 Go library. +// +// - Code-block title and line highlighting: fenced code blocks accept +// title="…" and {n,m-p} attributes on the opening fence. The title is +// rendered as a label bar above the block; the range is applied as a +// class="highlight-line" on the matching lines. Implementation lives in +// highlight.go (NewCodeAttrsExtension). package parser diff --git a/internal/parser/highlight.go b/internal/parser/highlight.go index d8f11c4..850278e 100644 --- a/internal/parser/highlight.go +++ b/internal/parser/highlight.go @@ -3,10 +3,20 @@ package parser import ( "bytes" "fmt" + "html" + "strconv" "strings" + "github.com/alecthomas/chroma/v2" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" + "github.com/yuin/goldmark" + goldmarkast "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" "github.com/engineervix/kwelea/internal/config" ) @@ -14,11 +24,29 @@ import ( // ChromaCSS generates a combined Chroma syntax-highlighting CSS string for // both the light and dark themes specified in themeCfg. // -// The light theme uses standard .chroma selectors. Dark-theme rules are -// prefixed with [data-theme="dark"] so they activate only when that attribute -// is set on , matching the toggle logic in the template. +// Both themes' rules are scoped — light to :root:not([data-theme="dark"]), +// dark to [data-theme="dark"] — so they activate only for their matching +// attribute value, matching the toggle logic in the template. +// +// Scoping the light rules (not just the dark ones) matters: a Chroma style +// commonly leaves some token types with no explicit colour, relying on the +// base foreground colour instead (e.g. github-dark has no entry for plain +// identifiers or punctuation). If light rules were left unscoped, that +// unscoped light-theme colour would directly match the element in dark +// mode too — and a rule that directly matches an element always wins over +// a colour the element would otherwise inherit from .chroma's own +// (correctly dark) base colour, regardless of [data-theme="dark"] +// specificity. The result was barely-legible dark-navy text on a dark +// background for any token the dark style doesn't explicitly colour. +// +// WithLineNumbers must match the options used by renderCodeBlockBody so +// this generates the matching ".ln"/"line" CSS — plain highlighted blocks +// never emit that markup, so the extra rules are simply unused for them. func ChromaCSS(themeCfg config.ThemeConfig) (string, error) { - formatter := chromahtml.New(chromahtml.WithClasses(true)) + formatter := chromahtml.New( + chromahtml.WithClasses(true), + chromahtml.WithLineNumbers(true), + ) lightStyle := styles.Get(themeCfg.LightCodeTheme) if lightStyle == nil { @@ -29,20 +57,19 @@ func ChromaCSS(themeCfg config.ThemeConfig) (string, error) { darkStyle = styles.Fallback } - var buf bytes.Buffer - - // Light theme — standard selectors. - if err := formatter.WriteCSS(&buf, lightStyle); err != nil { + var lightBuf bytes.Buffer + if err := formatter.WriteCSS(&lightBuf, lightStyle); err != nil { return "", fmt.Errorf("generating light Chroma CSS (%s): %w", themeCfg.LightCodeTheme, err) } - buf.WriteString("\n") - - // Dark theme — prefix every selector with [data-theme="dark"]. var darkBuf bytes.Buffer if err := formatter.WriteCSS(&darkBuf, darkStyle); err != nil { return "", fmt.Errorf("generating dark Chroma CSS (%s): %w", themeCfg.DarkCodeTheme, err) } + + var buf bytes.Buffer + buf.WriteString(prefixCSSSelectors(lightBuf.String(), `:root:not([data-theme="dark"])`)) + buf.WriteString("\n") buf.WriteString(prefixCSSSelectors(darkBuf.String(), `[data-theme="dark"]`)) return buf.String(), nil @@ -67,3 +94,576 @@ func prefixCSSSelectors(css, prefix string) string { } return strings.Join(result, "\n") } + +// ============================================================================= +// Code-block title and line highlighting (closes #6) +// ============================================================================= +// +// Extends fenced code blocks with two extra info-string attributes: +// +// go title="cmd/root.go" // filename label above the block +// go {2,4-6} // highlight lines 2 and 4-6 +// go title="x.go" {2,4-6} // both +// +// Implementation summary: +// +// - parseCodeBlockInfo() reads the info string after the language and +// returns a codeBlockAttrs struct with the parsed title and highlight +// ranges. Invalid expressions are silently ignored so that a stray +// character does not break the rest of the block. +// +// - codeAttrsTransformer runs as an AST transformer and replaces any +// FencedCodeBlock that has at least one custom attribute with a new +// CodeBlockNode. Plain blocks (no title, no highlights) are left +// untouched so goldmark-highlighting still renders them with zero +// overhead — the common case is unchanged. +// +// - codeAttrsNodeRenderer renders CodeBlockNode by formatting the source +// with Chroma using inline line numbers, post-processing the output +// to add class="highlight-line" to the matching +// rows, and wrapping the whole thing in
+// with an optional
above the
.
+
+// codeBlockAttrs holds the non-language attributes parsed from a fenced
+// code block's info string.
+type codeBlockAttrs struct {
+	title        string
+	highlight    []int // 1-indexed line numbers to highlight
+	highlightSet map[int]bool
+}
+
+// hasTitle reports whether a non-empty title was set.
+func (a codeBlockAttrs) hasTitle() bool { return a.title != "" }
+
+// hasHighlights reports whether at least one line highlight was set.
+func (a codeBlockAttrs) hasHighlights() bool { return len(a.highlight) > 0 }
+
+// parseCodeBlockInfo extracts title="…" and {n,m-p} attributes from a fenced
+// code block info string. The language token (first whitespace-delimited word)
+// is skipped. The function is tolerant: malformed expressions are silently
+// dropped so a stray character never breaks the rest of the block. Pass "" if
+// the input is empty.
+//
+// Recognised forms (after the language):
+//
+//	title="some text"      // title; quotes may be single or double
+//	{3,5-8}                // highlight lines 3 and 5 through 8 inclusive
+//	title="x.go" {2,4-6}   // both, in any order
+//
+// Whitespace separates attributes; the opening "{" must follow a space or
+// be at the start of the info string so it cannot collide with braces
+// embedded in titles.
+func parseCodeBlockInfo(info string) codeBlockAttrs {
+	var attrs codeBlockAttrs
+
+	rest := strings.TrimSpace(info)
+	if rest == "" {
+		return attrs
+	}
+
+	// Strip the leading language token (first whitespace-delimited word)
+	// if the info string contains whitespace. If the whole string is a
+	// single token (no whitespace) and starts with an attribute like
+	// title=… or {…}, treat the entire string as the attribute list —
+	// there is no real language in that case.
+	hasWS := strings.ContainsAny(rest, " \t")
+	if hasWS {
+		rest = stripLanguageToken(rest)
+	}
+
+	// Split on whitespace, but keep quoted title together. We scan by hand
+	// rather than using strings.Fields because we need to preserve the quoted
+	// string as a single token.
+	tokens := tokenizeInfo(rest)
+	for _, tok := range tokens {
+		switch {
+		case strings.HasPrefix(tok, "title="):
+			v, ok := unquoteAttr(tok[len("title="):])
+			if ok {
+				attrs.title = v
+			}
+		case strings.HasPrefix(tok, "{"):
+			attrs.highlight = append(attrs.highlight, parseHighlightRanges(tok)...)
+		}
+	}
+	if n := len(attrs.highlight); n > 0 {
+		attrs.highlightSet = make(map[int]bool, n)
+		for _, ln := range attrs.highlight {
+			attrs.highlightSet[ln] = true
+		}
+	}
+	return attrs
+}
+
+// tokenizeInfo splits an info string on whitespace while keeping title="…"
+// (with internal spaces) as a single token. Other than title=, no attribute
+// is allowed to contain spaces, so plain strings.Fields would split the rest.
+
+// stripLanguageToken removes the first whitespace-delimited token from s and
+// returns what remains, with surrounding whitespace trimmed. If s has no
+// whitespace, the entire input is treated as the "language" and the returned
+// string is empty — the caller is expected to fall back to using the full
+// input as attribute syntax in that case.
+func stripLanguageToken(s string) string {
+	for i := 0; i < len(s); i++ {
+		if s[i] == ' ' || s[i] == '\t' {
+			return strings.TrimSpace(s[i+1:])
+		}
+	}
+	return ""
+}
+
+// joinFencedLines concatenates the raw bytes of every line in a fenced
+// code block, preserving the newlines between them.
+func joinFencedLines(fcb *goldmarkast.FencedCodeBlock, src []byte) []byte {
+	var buf bytes.Buffer
+	n := fcb.Lines().Len()
+	for i := 0; i < n; i++ {
+		seg := fcb.Lines().At(i)
+		buf.Write(seg.Value(src))
+	}
+	return buf.Bytes()
+}
+
+func tokenizeInfo(s string) []string {
+	var out []string
+	i := 0
+	for i < len(s) {
+		// skip whitespace
+		for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
+			i++
+		}
+		if i >= len(s) {
+			break
+		}
+		// if this token starts with title=, read until the matching closing quote
+		if strings.HasPrefix(s[i:], "title=") {
+			j := i + len("title=")
+			// find opening quote
+			if j < len(s) && (s[j] == '"' || s[j] == '\'') {
+				quote := s[j]
+				j++
+				start := j
+				for j < len(s) && s[j] != quote {
+					j++
+				}
+				out = append(out, s[i:start]) // includes opening quote + content
+				if j < len(s) {
+					out[len(out)-1] = s[i : j+1] // include closing quote
+					j++
+				}
+				i = j
+				continue
+			}
+		}
+		// generic token: read until next whitespace
+		start := i
+		for i < len(s) && s[i] != ' ' && s[i] != '\t' {
+			i++
+		}
+		out = append(out, s[start:i])
+	}
+	return out
+}
+
+// unquoteAttr strips a single or double pair of quotes from around s. If the
+// surrounding quotes don't match (or are missing), the value is returned
+// verbatim and ok is false so the caller can decide to drop the attribute.
+func unquoteAttr(s string) (string, bool) {
+	if len(s) < 2 {
+		return "", false
+	}
+	first, last := s[0], s[len(s)-1]
+	if (first == '"' || first == '\'') && first == last {
+		return s[1 : len(s)-1], true
+	}
+	return "", false
+}
+
+// maxHighlightRangeLines caps how many lines a single {lo-hi} range may
+// expand to. Without this, a typo (e.g. {2-2000000000} instead of
+// {2-2000000}) would try to build a slice with billions of entries and hang
+// the build. Real fenced code blocks never come close to this many lines,
+// so a range this large is almost certainly a mistake and is dropped like
+// any other malformed input.
+const maxHighlightRangeLines = 10000
+
+// parseHighlightRanges parses a "{n,m-p,…}" expression and returns the
+// 1-indexed line numbers it contains. Invalid numbers and empty ranges
+// (e.g. {3-2}) are skipped; the rest of the expression still parses.
+func parseHighlightRanges(s string) []int {
+	var out []int
+	if !strings.HasPrefix(s, "{") || !strings.HasSuffix(s, "}") {
+		return out
+	}
+	inner := s[1 : len(s)-1]
+	if inner == "" {
+		return out
+	}
+	for _, part := range strings.Split(inner, ",") {
+		part = strings.TrimSpace(part)
+		if part == "" {
+			continue
+		}
+		if idx := strings.Index(part, "-"); idx >= 0 {
+			lo, errLo := strconv.Atoi(strings.TrimSpace(part[:idx]))
+			hi, errHi := strconv.Atoi(strings.TrimSpace(part[idx+1:]))
+			if errLo != nil || errHi != nil || lo < 1 || hi < lo || hi-lo+1 > maxHighlightRangeLines {
+				continue
+			}
+			for ln := lo; ln <= hi; ln++ {
+				out = append(out, ln)
+			}
+			continue
+		}
+		n, err := strconv.Atoi(part)
+		if err != nil || n < 1 {
+			continue
+		}
+		out = append(out, n)
+	}
+	return out
+}
+
+// ----- AST node -----
+
+// KindCodeBlock is the goldmark AST node kind for a fenced code block that
+// carries kwelea-specific attributes (title and/or line highlights).
+var KindCodeBlock = goldmarkast.NewNodeKind("CodeBlock")
+
+// CodeBlockNode is an AST block node that replaces a FencedCodeBlock during
+// the codeAttrsTransformer pass when the block's info string contains
+// title="…" or {n,m-p} attributes. The source bytes are stored verbatim
+// because Chroma tokenises from the raw source.
+type CodeBlockNode struct {
+	goldmarkast.BaseBlock
+	Language     string       // "" when no language was specified
+	Title        string       // "" when no title was specified
+	Highlight    []int        // 1-indexed lines to highlight (nil/empty when none)
+	HighlightSet map[int]bool // set form for O(1) membership
+	Source       []byte       // raw source bytes (lines joined with '\n')
+}
+
+// Kind returns KindCodeBlock, satisfying the goldmark ast.Node interface.
+func (n *CodeBlockNode) Kind() goldmarkast.NodeKind { return KindCodeBlock }
+
+// Dump writes a debug representation of the node to standard output,
+// satisfying the goldmark ast.Node interface.
+func (n *CodeBlockNode) Dump(source []byte, level int) {
+	goldmarkast.DumpHelper(n, source, level, map[string]string{
+		"Language":  n.Language,
+		"Title":     n.Title,
+		"Highlight": strconv.Itoa(len(n.Highlight)),
+	}, nil)
+}
+
+// ----- AST transformer -----
+
+// codeAttrsTransformer walks the parsed AST and replaces every
+// FencedCodeBlock that has at least one custom attribute with a
+// CodeBlockNode. Plain blocks are left untouched so goldmark-highlighting
+// still renders them with zero overhead.
+type codeAttrsTransformer struct{}
+
+// Transform walks the document once, collecting any FencedCodeBlock whose
+// info string contains kwelea attributes (title="…" or {n,m-p}). The walk
+// is read-only; the actual node replacement happens in a second pass so we
+// do not mutate the tree while iterating it.
+func (t *codeAttrsTransformer) Transform(doc *goldmarkast.Document, reader text.Reader, _ parser.Context) {
+	src := reader.Source()
+
+	type replacement struct{ old, newNode goldmarkast.Node }
+	var replacements []replacement
+
+	_ = goldmarkast.Walk(doc, func(n goldmarkast.Node, entering bool) (goldmarkast.WalkStatus, error) {
+		if !entering || n.Kind() != goldmarkast.KindFencedCodeBlock {
+			return goldmarkast.WalkContinue, nil
+		}
+		fcb := n.(*goldmarkast.FencedCodeBlock)
+
+		// Read the raw info string (the part after the opening fence).
+		var info []byte
+		if fcb.Info != nil {
+			info = fcb.Info.Segment.Value(src)
+		}
+		infoStr := string(info)
+
+		attrs := parseCodeBlockInfo(infoStr)
+		if !attrs.hasTitle() && !attrs.hasHighlights() {
+			return goldmarkast.WalkContinue, nil
+		}
+
+		lang := ""
+		if fcb.Language(src) != nil {
+			lang = string(fcb.Language(src))
+		}
+		// Goldmark treats the first whitespace-delimited token as the
+		// language. If the entire info string is a single token (no
+		// whitespace), goldmark reports that whole token as the
+		// language — so if it's itself a kwelea attribute (title= or
+		// {…}), it's not a real language and must be cleared.
+		if strings.ContainsAny(lang, "={") {
+			lang = ""
+		}
+
+		replacements = append(replacements, replacement{
+			old: fcb,
+			newNode: &CodeBlockNode{
+				Language:     lang,
+				Title:        attrs.title,
+				Highlight:    attrs.highlight,
+				HighlightSet: attrs.highlightSet,
+				Source:       joinFencedLines(fcb, src),
+			},
+		})
+		return goldmarkast.WalkContinue, nil
+	})
+
+	for _, r := range replacements {
+		if parent := r.old.Parent(); parent != nil {
+			parent.ReplaceChild(parent, r.old, r.newNode)
+		}
+	}
+}
+
+// ----- node renderer -----
+
+// codeAttrsRenderer renders CodeBlockNode entries. It uses Chroma directly
+// (rather than delegating to goldmark-highlighting) so it has full control
+// over the wrapper markup and can post-process the per-line classes to add
+// highlight-line where requested.
+type codeAttrsRenderer struct {
+	lightStyle string
+}
+
+// renderCodeBlock emits:
+//
+//	
+//
cmd/root.go
+//
+//
+// +// If there is no title, the code-title div is omitted and the body sits +// directly inside the figure so existing block styles keep applying. +func (r *codeAttrsRenderer) renderCodeBlock(w util.BufWriter, _ []byte, node goldmarkast.Node, entering bool) (goldmarkast.WalkStatus, error) { + if !entering { + return goldmarkast.WalkContinue, nil + } + n := node.(*CodeBlockNode) + + body, err := renderCodeBlockBody(n, r.lightStyle) + if err != nil { + // Fall back to a plain escaped
 so a Chroma failure never
+		// breaks the whole page. The error is swallowed; the
+		// non-decorated block still renders.
+		body = fmt.Sprintf(`
%s
`, html.EscapeString(string(n.Source))) + } + + langAttr := "" + if n.Language != "" { + langAttr = ` data-lang="` + html.EscapeString(n.Language) + `"` + } + + _, _ = io_WriteString(w, `
`) + if n.Title != "" { + _, _ = io_WriteString(w, `
`+html.EscapeString(n.Title)+`
`) + } + _, _ = io_WriteString(w, `
`) + _, _ = io_WriteString(w, body) + _, _ = io_WriteString(w, `
`) + return goldmarkast.WalkContinue, nil +} + +// renderCodeBlockBody formats source through Chroma with inline line +// numbers (WithLineNumbers, no LineNumbersInTable — the number sits inside +// the same per-line flex row as the code, not in a second
/
+// column; that guarantees gutter/code stay on identical rows and avoids
+// needing to fight table auto-layout for column widths), then
+// post-processes the output to add the highlight-line class to the
+//  rows matching n.Lines. The result is the inner HTML
+// of the 
— i.e. the chroma
, with one class edit per highlighted line. +// +// Only the light style is needed: WithClasses(true) means the actual +// colours come from the separate [data-theme="dark"] stylesheet generated +// by ChromaCSS, not from re-rendering with a dark chroma.Style (same +// reasoning as highlighting.WithStyle(themeCfg.LightCodeTheme) in +// newMarkdown). +func renderCodeBlockBody(n *CodeBlockNode, lightStyleName string) (string, error) { + style := styles.Get(lightStyleName) + if style == nil { + style = styles.Fallback + } + + var lexer chroma.Lexer + if n.Language != "" { + lexer = lexers.Get(n.Language) + } + if lexer == nil { + lexer = lexers.Fallback + } + lexer = chroma.Coalesce(lexer) + + // Build chroma HighlightLines ranges. Chroma's HighlightLines takes + // 1-based, inclusive line numbers (see the function's godoc), so we + // pass n.Highlight through unchanged. The set form n.HighlightSet + // uses the same 1-based numbering. + var hlRanges [][2]int + for _, ln := range n.Highlight { + hlRanges = append(hlRanges, [2]int{ln, ln}) + } + + opts := []chromahtml.Option{ + chromahtml.WithClasses(true), + chromahtml.WithLineNumbers(true), + } + if len(hlRanges) > 0 { + opts = append(opts, chromahtml.HighlightLines(hlRanges)) + } + formatter := chromahtml.New(opts...) + + iter, err := lexer.Tokenise(nil, string(n.Source)) + if err != nil { + return "", fmt.Errorf("tokenising code: %w", err) + } + var buf bytes.Buffer + if err := formatter.Format(&buf, style, iter); err != nil { + return "", fmt.Errorf("formatting chroma output: %w", err) + } + return injectHighlightClass(buf.String(), n.HighlightSet), nil +} + +// injectHighlightClass rewrites the per-line tags +// emitted by Chroma's line-numbering formatter to add the project-level +// highlight-line class. It does this by tracking the current 1-indexed +// line number and, for every it sees, checking +// membership in the highlight set. +// +// We do the counting manually rather than relying on the rendered line +// number text: the number sits in its own nested and is +// awkward to parse reliably, while appears exactly +// once per code line and is the canonical anchor for per-line decoration. +func injectHighlightClass(html string, lines map[int]bool) string { + if len(lines) == 0 { + return html + } + marker := []byte(`') + if close < 0 { + out.WriteString(html[spanStart:]) + break + } + ln++ + openTag := html[spanStart : spanEnd+close+1] + if lines[ln] { + out.WriteString(rewriteLineOpenTag(openTag)) + } else { + out.WriteString(openTag) + } + idx = spanEnd + close + 1 + } + return out.String() +} + +// rewriteLineOpenTag inserts " highlight-line" into the class attribute of +// a tag emitted by Chroma. The two forms we see +// from Chroma are: +// +// (bare) +// (chroma added "hl" for HighlightLines) +// +// The class value is whatever sits between the opening quote of class= +// and the closing `">` of the tag. We rebuild the tag so the rewritten +// version starts with "line highlight-line" plus any original classes. +func rewriteLineOpenTag(tag string) string { + if !strings.HasPrefix(tag, ``) { + return tag + } + // Strip the surrounding wrapper and re-emit it with + // highlight-line inserted as the second class. The interior is + // exactly the class attribute value (no other attributes are + // expected in the per-line span). + interior := tag[len(`, line hl">, or line highlight-line">. + // Drop the trailing `">` to get the class value. + if !strings.HasSuffix(interior, `">`) { + return tag + } + classValue := interior[:len(interior)-2] + // classValue now looks like: "line" or "line hl" or "line highlight-line". + // We rebuild so highlight-line comes right after "line". + if classValue == "line" { + return `` + } + if strings.HasPrefix(classValue, "line ") { + return `` + } + // Unknown form — leave it alone rather than risk corruption. + return tag +} + +// ----- extension ----- + +// codeAttrsExtension bundles the AST transformer and the node renderer. +type codeAttrsExtension struct { + lightStyle string +} + +// NewCodeAttrsExtension returns a goldmark.Extender that adds title and +// line-highlight support to fenced code blocks. The theme name matches the +// one used to build the chroma stylesheet (see ChromaCSS); only the light +// style is needed (see renderCodeBlockBody). +func NewCodeAttrsExtension(themeCfg config.ThemeConfig) goldmark.Extender { + return &codeAttrsExtension{ + lightStyle: themeCfg.LightCodeTheme, + } +} + +func (e *codeAttrsExtension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions( + parser.WithASTTransformers( + util.Prioritized(&codeAttrsTransformer{}, 200), + ), + ) + m.Renderer().AddOptions( + renderer.WithNodeRenderers( + util.Prioritized(&codeAttrsRenderer{ + lightStyle: e.lightStyle, + }, 200), + ), + ) +} + +// RegisterFuncs satisfies renderer.NodeRenderer. +func (r *codeAttrsRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindCodeBlock, r.renderCodeBlock) +} + +// io_WriteString is a tiny wrapper around w.WriteString that returns the +// error from the underlying BufWriter. The renderer only ignores errors +// anyway, but we keep the same shape as other goldmark renderers in the +// project. +func io_WriteString(w util.BufWriter, s string) (int, error) { + return w.WriteString(s) +} diff --git a/internal/parser/highlight_test.go b/internal/parser/highlight_test.go new file mode 100644 index 0000000..165c637 --- /dev/null +++ b/internal/parser/highlight_test.go @@ -0,0 +1,264 @@ +package parser + +import ( + "reflect" + "strings" + "testing" +) + +// ----- parseCodeBlockInfo ----- + +func TestParseCodeBlockInfoTitleOnly(t *testing.T) { + got := parseCodeBlockInfo(`go title="cmd/root.go"`) + if got.title != "cmd/root.go" { + t.Errorf("title: got %q want %q", got.title, "cmd/root.go") + } + if got.hasHighlights() { + t.Errorf("expected no highlights, got %v", got.highlight) + } +} + +func TestParseCodeBlockInfoHighlightsOnly(t *testing.T) { + got := parseCodeBlockInfo(`go {2,4-6}`) + if got.title != "" { + t.Errorf("title: got %q want empty", got.title) + } + want := []int{2, 4, 5, 6} + if !reflect.DeepEqual(got.highlight, want) { + t.Errorf("highlight: got %v want %v", got.highlight, want) + } +} + +func TestParseCodeBlockInfoTitleAndHighlights(t *testing.T) { + got := parseCodeBlockInfo(`python title="x.py" {1,3-4}`) + if got.title != "x.py" { + t.Errorf("title: got %q want %q", got.title, "x.py") + } + want := []int{1, 3, 4} + if !reflect.DeepEqual(got.highlight, want) { + t.Errorf("highlight: got %v want %v", got.highlight, want) + } +} + +func TestParseCodeBlockInfoOrderIndependent(t *testing.T) { + a := parseCodeBlockInfo(`go {1} title="a"`) + b := parseCodeBlockInfo(`go title="a" {1}`) + if a.title != b.title || !reflect.DeepEqual(a.highlight, b.highlight) { + t.Errorf("order changed result: a=%+v b=%+v", a, b) + } +} + +func TestParseCodeBlockInfoSingleQuotedTitle(t *testing.T) { + got := parseCodeBlockInfo(`sh title='has spaces'`) + if got.title != "has spaces" { + t.Errorf("single-quoted title: got %q want %q", got.title, "has spaces") + } +} + +func TestParseCodeBlockInfoUnquotedTitleDropped(t *testing.T) { + // title=foo (no quotes) is ambiguous; per the doc comment it is + // silently dropped to avoid false positives. + got := parseCodeBlockInfo(`go title=foo`) + if got.title != "" { + t.Errorf("unquoted title should be dropped, got %q", got.title) + } +} + +func TestParseCodeBlockInfoInvalidRangeTokens(t *testing.T) { + got := parseCodeBlockInfo(`go {abc,5,xyz,2-4}`) + want := []int{5, 2, 3, 4} + if !reflect.DeepEqual(got.highlight, want) { + t.Errorf("invalid tokens should be skipped, got %v want %v", got.highlight, want) + } +} + +func TestParseCodeBlockInfoEmpty(t *testing.T) { + got := parseCodeBlockInfo("") + if got.hasTitle() || got.hasHighlights() { + t.Errorf("empty input should yield no attrs, got %+v", got) + } +} + +func TestParseCodeBlockInfoLanguageOnly(t *testing.T) { + // A bare language with no other attributes must come out empty. + got := parseCodeBlockInfo("go") + if got.hasTitle() || got.hasHighlights() { + t.Errorf("language-only should yield no attrs, got %+v", got) + } +} + +// ----- parseHighlightRanges ----- + +func TestParseHighlightRangesSingleNumber(t *testing.T) { + got := parseHighlightRanges("{5}") + if !reflect.DeepEqual(got, []int{5}) { + t.Errorf("got %v want [5]", got) + } +} + +func TestParseHighlightRangesRangeExpands(t *testing.T) { + got := parseHighlightRanges("{3-5}") + if !reflect.DeepEqual(got, []int{3, 4, 5}) { + t.Errorf("got %v want [3 4 5]", got) + } +} + +func TestParseHighlightRangesMixed(t *testing.T) { + got := parseHighlightRanges("{1,3-4,7}") + if !reflect.DeepEqual(got, []int{1, 3, 4, 7}) { + t.Errorf("got %v want [1 3 4 7]", got) + } +} + +func TestParseHighlightRangesReverseDropped(t *testing.T) { + got := parseHighlightRanges("{5-2}") + if len(got) != 0 { + t.Errorf("reverse range should be empty, got %v", got) + } +} + +func TestParseHighlightRangesInvalidNumbersSkipped(t *testing.T) { + got := parseHighlightRanges("{abc,0,5,-1,3}") + // 0 and -1 are below 1 → dropped; "abc" → dropped; 5, 3 kept. + if !reflect.DeepEqual(got, []int{5, 3}) { + t.Errorf("got %v want [5 3]", got) + } +} + +func TestParseHighlightRangesBadBraces(t *testing.T) { + cases := []string{"5", "{5", "5}", "{}", "{ }"} + for _, c := range cases { + if got := parseHighlightRanges(c); len(got) != 0 { + t.Errorf("parseHighlightRanges(%q) = %v, want empty", c, got) + } + } +} + +func TestParseHighlightRangesOversizedRangeDropped(t *testing.T) { + // A typo like {2-2000000000} must not try to expand billions of line + // numbers — the whole range is dropped, same as any other malformed + // input. This must return promptly rather than hang. + got := parseHighlightRanges("{2-2000000000}") + if len(got) != 0 { + t.Errorf("oversized range should be dropped, got %d entries", len(got)) + } +} + +func TestParseHighlightRangesAtCapAllowed(t *testing.T) { + // A range exactly at the cap is still valid. + got := parseHighlightRanges("{1-10000}") + if len(got) != 10000 { + t.Errorf("expected 10000 entries at the cap, got %d", len(got)) + } + // One past the cap is dropped. + if got := parseHighlightRanges("{1-10001}"); len(got) != 0 { + t.Errorf("expected range one past the cap to be dropped, got %d entries", len(got)) + } +} + +// ----- injectHighlightClass ----- + +func TestInjectHighlightClassSkipsEmpty(t *testing.T) { + in := `x` + out := injectHighlightClass(in, nil) + if out != in { + t.Errorf("nil set should leave input untouched") + } +} + +func TestInjectHighlightClassAddsClass(t *testing.T) { + in := `a` + + `b` + out := injectHighlightClass(in, map[int]bool{2: true}) + if !strings.Contains(out, `b`) { + t.Errorf("second line should gain highlight-line; got:\n%s", out) + } + // First line should be untouched. + if !strings.Contains(out, `a`) { + t.Errorf("first line should be untouched; got:\n%s", out) + } +} + +func TestInjectHighlightClassPreservesChromaHl(t *testing.T) { + // When chroma's HighlightLines is on, the span already has "line hl". + // Our rewriter should add "highlight-line" without dropping "hl". + in := `a` + + `b` + out := injectHighlightClass(in, map[int]bool{1: true}) + if !strings.Contains(out, `a`) { + t.Errorf("hl should be preserved; got:\n%s", out) + } +} + +func TestInjectHighlightClassIdempotent(t *testing.T) { + // Running inject twice should be the same as running it once. + in := `x` + once := injectHighlightClass(in, map[int]bool{1: true}) + twice := injectHighlightClass(once, map[int]bool{1: true}) + if once != twice { + t.Errorf("not idempotent:\nonce: %s\ntwice: %s", once, twice) + } +} + +// ----- rewriteLineOpenTag ----- + +func TestRewriteLineOpenTagBare(t *testing.T) { + got := rewriteLineOpenTag(``) + want := `` + if got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestRewriteLineOpenTagWithHl(t *testing.T) { + got := rewriteLineOpenTag(``) + want := `` + if got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestRewriteLineOpenTagAlreadyHighlighted(t *testing.T) { + // If "highlight-line" is already there, leave the tag alone. + in := `` + if got := rewriteLineOpenTag(in); got != in { + t.Errorf("got %q want %q (no change)", got, in) + } +} + +func TestRewriteLineOpenTagUnrelatedSpanUntouched(t *testing.T) { + in := `` + if got := rewriteLineOpenTag(in); got != in { + t.Errorf("non-line span should be left alone; got %q", got) + } +} + +// ----- tokenizeInfo ----- + +// tokenizeInfo is invoked on the post-language portion of a fenced +// code-block info string. Tests below therefore pass strings without +// the leading language token. + +func TestTokenizeInfoKeepsQuotedTitle(t *testing.T) { + got := tokenizeInfo(`title="my file.go" {2}`) + want := []string{`title="my file.go"`, "{2}"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v want %v", got, want) + } +} + +func TestTokenizeInfoSingleQuotedTitle(t *testing.T) { + got := tokenizeInfo(`title='has "double" quotes'`) + want := []string{`title='has "double" quotes'`} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v want %v", got, want) + } +} + +func TestTokenizeInfoHandlesExtraSpaces(t *testing.T) { + got := tokenizeInfo(" {2,4} ") + want := []string{"{2,4}"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v want %v", got, want) + } +} diff --git a/internal/parser/parse.go b/internal/parser/parse.go index e9003dc..bdbcfbe 100644 --- a/internal/parser/parse.go +++ b/internal/parser/parse.go @@ -75,6 +75,7 @@ func extractAndStripH1(doc goldmarkast.Node, src []byte) string { // newMarkdown returns a goldmark.Markdown configured with all kwelea extensions: // - GFM (tables, strikethrough, linkify, task lists) // - Syntax highlighting using Chroma CSS classes (dual-theme via ChromaCSS) +// - Code-block title and line-highlight attributes (```go title="…" {2,4-6}) // - Admonitions (:::) // - D2 diagrams (```d2 fenced blocks) // - Auto-heading IDs for ToC extraction @@ -90,6 +91,7 @@ func newMarkdown(themeCfg config.ThemeConfig) goldmark.Markdown { chromahtml.WithClasses(true), ), ), + NewCodeAttrsExtension(themeCfg), Admonitions, NewD2Extension(), ), diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index 16280db..18f1051 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -308,6 +308,268 @@ func TestD2InvalidSourceShowsError(t *testing.T) { } } +// ----- Code block title and line highlights (#6) ----- + +func TestCodeBlockTitleOnly(t *testing.T) { + src := []byte("```go title=\"cmd/root.go\"\npackage main\n```\n") + html, _, _, err := Parse("test.md", src, defaultTheme) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + s := string(html) + if !strings.Contains(s, `
`) { + t.Errorf("expected
, got:\n%s", s) + } + if !strings.Contains(s, `
cmd/root.go
`) { + t.Errorf("expected code-title div with filename, got:\n%s", s) + } + // No highlight-line should appear in a title-only block. + if strings.Contains(s, "highlight-line") { + t.Errorf("title-only block should not contain highlight-line, got:\n%s", s) + } +} + +func TestCodeBlockLineHighlightOnly(t *testing.T) { + src := []byte("```go {2}\nline1\nline2\nline3\n```\n") + html, _, _, err := Parse("test.md", src, defaultTheme) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + s := string(html) + if !strings.Contains(s, `
`) { + t.Errorf("expected code-block wrapper even without title, got:\n%s", s) + } + if strings.Contains(s, "code-title") { + t.Errorf("highlight-only block should not contain code-title, got:\n%s", s) + } + // Exactly one line should carry the highlight-line class (line 2). + count := strings.Count(s, `class="line highlight-line`) + if count != 1 { + t.Errorf("expected exactly 1 highlight-line span, got %d:\n%s", count, s) + } + // And the highlighted line should contain the content of line 2. We + // don't assert the exact class string because chroma may also have + // added " hl" (line numbers in table mode), producing + // "line highlight-line hl". + if !strings.Contains(s, "line2") { + t.Errorf("expected line2 to appear in the highlighted line, got:\n%s", s) + } +} + +func TestCodeBlockTitleAndLineHighlights(t *testing.T) { + // Reproduces the example from issue #6. + src := []byte("```go title=\"cmd/root.go\" {2,4-6}\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\n```\n") + html, _, _, err := Parse("test.md", src, defaultTheme) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + s := string(html) + if !strings.Contains(s, `
`) { + t.Errorf("expected code-block wrapper, got:\n%s", s) + } + if !strings.Contains(s, `
cmd/root.go
`) { + t.Errorf("expected code-title, got:\n%s", s) + } + // 4 lines should be highlighted: 2 and 4, 5, 6. + count := strings.Count(s, `class="line highlight-line`) + if count != 4 { + t.Errorf("expected 4 highlight-line spans, got %d:\n%s", count, s) + } +} + +func TestCodeBlockPlainPassthrough(t *testing.T) { + // A code block with no title and no {} should NOT be wrapped in + // .code-block — the existing goldmark-highlighting path renders it + // unchanged, so the common case pays zero overhead. + src := []byte("```go\nfunc main() {}\n```\n") + html, _, _, err := Parse("test.md", src, defaultTheme) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + s := string(html) + if strings.Contains(s, "code-block") { + t.Errorf("plain code block should not be wrapped, got:\n%s", s) + } + if !strings.Contains(s, `
`) {
+		t.Errorf("plain code block should still be highlighted, got:\n%s", s)
+	}
+}
+
+func TestCodeBlockInvalidRangePartiallyParsed(t *testing.T) {
+	// An invalid token in the range is dropped; valid ones still apply.
+	// {notanumber,5} should yield just line 5.
+	src := []byte("```go {notanumber,5}\nx\ny\nz\nw\nv\n```\n")
+	html, _, _, err := Parse("test.md", src, defaultTheme)
+	if err != nil {
+		t.Fatalf("Parse error: %v", err)
+	}
+	s := string(html)
+	count := strings.Count(s, `class="line highlight-line`)
+	if count != 1 {
+		t.Errorf("expected 1 highlight-line span (only the valid '5'), got %d:\n%s", count, s)
+	}
+	// Line 5 is the last one ("v"). It lives between 
+	// and , so we just check for the character itself.
+	if !strings.Contains(s, ">v<") {
+		t.Errorf("expected the highlighted line to contain 'v', got:\n%s", s)
+	}
+}
+
+func TestCodeBlockTitleEscapesHTML(t *testing.T) {
+	// The title must be HTML-escaped; a raw "&" or "<" would break the
+	// document. The wrap-the-attribute-in-quotes form ensures the parser
+	// sees the literal string; the renderer must escape it.
+	cases := []struct {
+		name, title, wantSubstr string
+	}{
+		{"ampersand", `build & deploy.sh`, `build & deploy.sh`},
+		// Single-quoted title wrapping lets us embed double quotes
+		// without escaping.
+		{"single-quotes-around-double", `has "quotes" in it`, `has "quotes" in it`},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			// Pick the quote style based on whether the title contains
+			// the same kind of quote — using the other kind avoids the
+			// need for backslash-escaping the inner one.
+			quote := `"`
+			if strings.Contains(c.title, `"`) {
+				quote = `'`
+			}
+			src := []byte("```sh title=" + quote + c.title + quote + "\necho hi\n```\n")
+			html, _, _, err := Parse("test.md", src, defaultTheme)
+			if err != nil {
+				t.Fatalf("Parse error: %v", err)
+			}
+			s := string(html)
+			if !strings.Contains(s, c.wantSubstr) {
+				t.Errorf("expected escaped title %q in output, got:\n%s", c.wantSubstr, s)
+			}
+			// And the unescaped form must not appear in the title div.
+			if idx := strings.Index(s, `class="code-title">`); idx >= 0 {
+				end := strings.Index(s[idx:], ``)
+				if end < 0 {
+					t.Fatalf("malformed output: no  after code-title")
+				}
+				titleHTML := s[idx : idx+end]
+				if strings.Contains(titleHTML, c.title) {
+					t.Errorf("raw title %q appeared unescaped in %q", c.title, titleHTML)
+				}
+			}
+		})
+	}
+}
+
+func TestCodeBlockHighlightOutOfRange(t *testing.T) {
+	// Asking for line 99 on a 1-line block: the wrapper still renders,
+	// the block is still highlighted, but no line gets the class. The
+	// behaviour matches what a user would expect from a typo — silent
+	// ignore rather than a broken page.
+	src := []byte("```go {99}\nonly-one-line\n```\n")
+	html, _, _, err := Parse("test.md", src, defaultTheme)
+	if err != nil {
+		t.Fatalf("Parse error: %v", err)
+	}
+	s := string(html)
+	if !strings.Contains(s, `
b\n```\n") + html, _, _, err := Parse("test.md", src, defaultTheme) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + s := string(html) + if !strings.Contains(s, `class="d2-diagram"`) { + t.Errorf("expected d2-diagram wrapper, got:\n%s", s) + } + if strings.Contains(s, "code-block") { + t.Errorf("d2 block should not be wrapped in .code-block, got:\n%s", s) + } +} + +func TestCodeBlockRangeWithLeadingSpace(t *testing.T) { + // The {n,m-p} expression is preceded by whitespace; the parser must + // accept that. + src := []byte("```go {2,4-5}\n1\n2\n3\n4\n5\n```\n") + html, _, _, err := Parse("test.md", src, defaultTheme) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + s := string(html) + count := strings.Count(s, `class="line highlight-line`) + if count != 3 { + t.Errorf("expected 3 highlight-line spans (lines 2, 4, 5), got %d:\n%s", count, s) + } +} + +func TestCodeBlockTitleBeforeBraces(t *testing.T) { + // Order shouldn't matter. + src := []byte("```go {1} title=\"x.go\"\nfoo\nbar\n```\n") + html, _, _, err := Parse("test.md", src, defaultTheme) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + s := string(html) + if !strings.Contains(s, `
x.go
`) { + t.Errorf("expected code-title regardless of attribute order, got:\n%s", s) + } + if !strings.Contains(s, "highlight-line") { + t.Errorf("expected highlight-line regardless of attribute order, got:\n%s", s) + } +} + +func TestCodeBlockReverseRangeIgnored(t *testing.T) { + // {5-2} is an empty range; the whole expression should be dropped + // silently rather than raising an error. + src := []byte("```go {5-2}\n1\n2\n3\n4\n5\n```\n") + html, _, _, err := Parse("test.md", src, defaultTheme) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if strings.Contains(string(html), "highlight-line") { + t.Errorf("reverse range should produce no highlights, got:\n%s", html) + } +} + +func TestCodeBlockNoLanguageStillWorks(t *testing.T) { + // A block with no language but with a title should still render the + // wrapper. Chroma falls back to the plain-text lexer. + src := []byte("```title=\"snippet.txt\"\nplain text\n```\n") + html, _, _, err := Parse("test.md", src, defaultTheme) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + s := string(html) + if !strings.Contains(s, `data-lang=""`) && !strings.Contains(s, `
`) { + t.Errorf("expected code-block wrapper, got:\n%s", s) + } + if !strings.Contains(s, `code-title">snippet.txt`) { + t.Errorf("expected code-title for language-less block, got:\n%s", s) + } +} + // min is a small helper kept local to avoid Go version concerns. func min(a, b int) int { if a < b {