feat: syntax-highlight YAML frontmatter (keys/values/markers/comments)
8817cc29ec73341aea92ed8204679afa21a91174
humdrum <me@humdrum.me> · 2026-06-28 09:17
parent b379b91c
2 files changed
internal/editor/scanner.go +47 −1
@@ -55,8 +55,9 @@ if st.inFrontmatter {
if trimmed == "---" {
st.inFrontmatter = false
st.frontmatterDone = true
+ return wholeLine(line, muted)
}
- return wholeLine(line, muted)
+ return scanFrontmatter(line, th)
}
if row == 0 && trimmed == "---" {
st.inFrontmatter = true
@@ -90,6 +91,51 @@ if line == "" {
return nil
}
return []Span{{Text: line, Style: style}}
+}
+
+// scanFrontmatter highlights one YAML frontmatter line, keeping every character
+// visible: the spans concatenate back to the raw line exactly.
+// - "# comment" -> whole line muted
+// - " - item" -> leading ws + dash (ListMarker) + item (Text)
+// - "key: value" -> "key:" (Accent) + " value" (Text)
+// - anything else -> whole line Text
+func scanFrontmatter(line string, th theme.Theme) []Span {
+ muted := lipgloss.NewStyle().Foreground(th.Blockquote)
+ accent := lipgloss.NewStyle().Foreground(th.Accent)
+ plain := lipgloss.NewStyle().Foreground(th.Text)
+ marker := lipgloss.NewStyle().Foreground(th.ListMarker)
+
+ trimmed := strings.TrimSpace(line)
+
+ // Comment line.
+ if strings.HasPrefix(trimmed, "#") {
+ return wholeLine(line, muted)
+ }
+
+ // List item: optional leading whitespace, then "- ...".
+ if loc := listRe.FindStringIndex(line); loc != nil && strings.HasPrefix(trimmed, "-") {
+ mk := line[:loc[1]] // leading ws + "- "
+ rest := line[loc[1]:] // item text
+ spans := []Span{{Text: mk, Style: marker}}
+ if rest != "" {
+ spans = append(spans, Span{Text: rest, Style: plain})
+ }
+ return spans
+ }
+
+ // key: value — split at the first colon. Keep the colon with the key.
+ if idx := strings.IndexByte(line, ':'); idx >= 0 {
+ key := line[:idx+1] // includes the colon
+ rest := line[idx+1:] // remainder (may start with a space)
+ spans := []Span{{Text: key, Style: accent}}
+ if rest != "" {
+ spans = append(spans, Span{Text: rest, Style: plain})
+ }
+ return spans
+ }
+
+ // Bare line (e.g. a continuation): plain text.
+ return wholeLine(line, plain)
}
// scanInline emits styled spans for inline constructs, keeping all markup
internal/editor/scanner_test.go +50 −3
@@ -82,10 +82,12 @@ func TestScanLeadingFrontmatter(t *testing.T) {
th := theme.FlexokiDark()
lines := []string{"---", "title: x", "---", "body"}
out := ScanLines(lines, th)
- if out[1][0].Style.GetForeground() != th.Blockquote {
- t.Errorf("frontmatter body not muted")
+ if out[0][0].Style.GetForeground() != th.Muted {
+ t.Errorf("opening --- not muted")
}
- // after the closing ---, normal text resumes
+ if out[1][0].Style.GetForeground() != th.Accent {
+ t.Errorf("frontmatter key not accent-colored")
+ }
if out[3][0].Style.GetForeground() != th.Text {
t.Errorf("post-frontmatter line should be plain text")
}
@@ -119,3 +121,48 @@ if spanText(out[0]) != "- **x**" {
t.Errorf("list line text altered: %q", spanText(out[0]))
}
}
+
+func TestScanFrontmatterHighlightsKeysAndValues(t *testing.T) {
+ th := theme.FlexokiDark()
+ lines := []string{"---", "title: Hello", "tags:", " - one", " - two", "# a comment", "---", "body"}
+ out := ScanLines(lines, th)
+
+ // Char-for-char preservation across every frontmatter line.
+ for i, raw := range lines {
+ if got := spanText(out[i]); got != raw {
+ t.Errorf("line %d: span text %q != raw %q", i, got, raw)
+ }
+ }
+ // Opening and closing --- are muted.
+ if out[0][0].Style.GetForeground() != th.Muted {
+ t.Errorf("opening --- not muted")
+ }
+ if out[6][0].Style.GetForeground() != th.Muted {
+ t.Errorf("closing --- not muted")
+ }
+ // "title:" key portion is Accent; the value is Text.
+ if out[1][0].Style.GetForeground() != th.Accent {
+ t.Errorf("key not Accent-colored, got %v", out[1][0].Style.GetForeground())
+ }
+ if last := out[1][len(out[1])-1]; last.Style.GetForeground() != th.Text {
+ t.Errorf("value not Text-colored, got %v", last.Style.GetForeground())
+ }
+ // List item marker "-" is ListMarker-colored.
+ foundMarker := false
+ for _, sp := range out[3] {
+ if sp.Style.GetForeground() == th.ListMarker {
+ foundMarker = true
+ }
+ }
+ if !foundMarker {
+ t.Errorf("list item dash not ListMarker-colored")
+ }
+ // Comment line is muted.
+ if out[5][0].Style.GetForeground() != th.Muted {
+ t.Errorf("comment not muted")
+ }
+ // After the closing ---, normal text resumes.
+ if out[7][0].Style.GetForeground() != th.Text {
+ t.Errorf("post-frontmatter line should be plain text")
+ }
+}