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(`
`)
+ 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 {