diff --git a/.gitattributes b/.gitattributes
index e35006b..1342d23 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,2 @@
-fixtures/expected/*.ansi binary
-fixtures/*.md text eol=lf
+fixtures/expected/**/*.ansi binary
+fixtures/**/*.md text eol=lf
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bbb9fb4..de1f4ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+- **Markdown frontmatter support.** YAML (`---`) and TOML (`+++`) metadata
+ blocks at the top of a document are now parsed and never leak into body
+ content. `--cat` renders a dim one-line summary
+ (`[metadata · key=value, …]`); TUI shows the same line and can expand it
+ to an inline key/value box with the new `m` key. A blank row follows the
+ summary for visual separation. Opt out entirely via `[metadata] show = false`
+ in `~/.termdown/config.toml`. See `docs/adr/0001-metadata-block-handling.md`.
+
## [0.5.1] - 2026-05-26
### Added
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..218f47b
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,53 @@
+# termdown — Context & Glossary
+
+Single-context project. This file is the canonical glossary for domain terms
+that appear in code, ADRs, and conversations about termdown. Keep entries short.
+If you're tempted to add a term that future maintainers can derive from the
+code itself (struct names, file layout, etc.), don't — only put **shared
+vocabulary** here.
+
+## Glossary
+
+### Frontmatter
+A block of metadata written at the **very beginning** of a Markdown file, fenced
+by either `---` (YAML syntax) or `+++` (TOML syntax). Used by static site
+generators (Jekyll, Hugo, Zola), note apps (Obsidian, Logseq), and agent skill
+files (Anthropic, Cursor) to attach structured fields (title, author, tags,
+name, description, …) to a document. Not part of CommonMark or GFM. Termdown
+supports both YAML and TOML fences.
+
+Synonym: **metadata block**. The two terms are interchangeable in this
+project — `frontmatter` is the user-facing word, `MetadataBlock` is the
+pulldown-cmark event name.
+
+### Metadata one-line summary
+The single dim line termdown renders in place of a parsed frontmatter block.
+Format: `[metadata · key=value, key=value, …]` — wrapped in square brackets,
+truncated to terminal width with the closing `]` preserved after the ellipsis.
+Identical in both `--cat` and TUI **folded** state. Followed by one blank row
+for visual separation from the body.
+
+### Folded / Expanded (TUI metadata)
+The two display states for a metadata block in TUI mode:
+- **Folded** (default): one dim line — the [[metadata one-line summary]].
+- **Expanded**: an inline box listing each key/value on its own row, pushing
+ body content down. Triggered by the `m` key. Second `m` collapses back.
+
+Cat mode has no "expanded" state — only the one-line summary or nothing.
+
+### `[metadata] show`
+The single config knob (in `~/.termdown/config.toml`) controlling whether
+frontmatter is visible at all. `show = true` (default) renders the [[metadata
+one-line summary]] / expanded box; `show = false` hides the metadata block in
+**both** cat and TUI. The pulldown-cmark metadata extensions are always
+enabled internally regardless — `show` only gates rendering, never parsing.
+See [[adr-0001-metadata-block-handling]].
+
+### Heuristic parser
+The line-based key/value extractor used to turn a raw frontmatter block into
+the one-line summary. Does **not** depend on a real YAML/TOML parser; splits
+each non-blank line on the first `:` (YAML) or `=` (TOML) and trims. If zero
+valid key/value pairs are extracted, falls back to a raw single-line join of
+the block. Rationale: keeps the dependency surface small for a use case
+(quick visual summary) where parse fidelity doesn't matter.
+See [[adr-0001-metadata-block-handling]].
diff --git a/TODO.md b/TODO.md
index 32d9b23..6414dd1 100644
--- a/TODO.md
+++ b/TODO.md
@@ -5,7 +5,7 @@
- 本地跑 `cargo install cargo-msrv && cargo msrv find`,二分出最低能编译的版本
- 同步更新 `Cargo.toml` 的 `rust-version` 和 `README.md` 里的 "Requires Rust X.Y+"
- 在 `.github/workflows/ci.yml` 加一个 `msrv` job(`cargo check --all-targets` on pinned toolchain),防止以后 PR 悄悄抬高 MSRV
-- [ ] 测试 markdown metadata 支持
+- [x] 测试 markdown metadata 支持
- [ ] 检测文件变化
- [x] 文件到顶、末尾时,播放声音提示,增加喇叭icon
- [ ] t 开启目录时,支持左右等方向键在目录和内容之间切换,并可以有一些界面上的 focus 提示
diff --git a/docs/MARKDOWN_FEATURE_COVERAGE.md b/docs/MARKDOWN_FEATURE_COVERAGE.md
index 09498c7..9ea2753 100644
--- a/docs/MARKDOWN_FEATURE_COVERAGE.md
+++ b/docs/MARKDOWN_FEATURE_COVERAGE.md
@@ -18,6 +18,7 @@ Audit of `src/markdown.rs` against pulldown-cmark 0.13 and common Markdown exten
| Horizontal rule | ✓ | |
| HTML blocks | ✓ | Rendered verbatim as a dim preformatted block; HTML comments dropped |
| Inline HTML | ⚠ | Format tags (`b`/`strong`, `i`/`em`, `u`, `s`/`del`/`strike`, `code`/`kbd`) map to ANSI; `
` / `
` handled; comments dropped; unknown tags stripped but their content is preserved. Attributes (e.g. `style="color:red"`, `href`) are not interpreted. |
+| YAML / TOML frontmatter | ✓ | Parsed via pulldown-cmark's metadata-block extensions. Rendered as a dim one-line summary (`[metadata · key=value, …]`) in `--cat`; foldable inline box in TUI (toggle with `m`). Heuristic key/value extraction. See `docs/adr/0001-metadata-block-handling.md`. Opt out via `[metadata] show = false` in `~/.termdown/config.toml`. |
## Enabled GFM extensions
@@ -37,7 +38,6 @@ Audit of `src/markdown.rs` against pulldown-cmark 0.13 and common Markdown exten
- **Math** `$...$` / `$$...$$` — `ENABLE_MATH` not set
- **Definition list** — not enabled
-- **YAML / TOML frontmatter** — not enabled; currently leaks into body
- **Smart punctuation** — not enabled
- **Wikilinks / Subscript / Superscript** — not enabled
@@ -50,6 +50,6 @@ Audit of `src/markdown.rs` against pulldown-cmark 0.13 and common Markdown exten
## Suggested priorities
-1. **High value (hit in everyday Markdown)**: skip frontmatter, footnotes, alert/admonition styling, GFM autolinks, at least a graceful fallback for HTML
+1. **High value (hit in everyday Markdown)**: footnotes, alert/admonition styling, GFM autolinks, at least a graceful fallback for HTML
2. **Differentiating (plays to termdown's Kitty-graphics strength)**: Mermaid rendering, real image rendering, code-block syntax highlighting
3. **Nice to have**: math (KaTeX → image), smart punctuation, definition list
diff --git a/docs/adr/0001-metadata-block-handling.md b/docs/adr/0001-metadata-block-handling.md
new file mode 100644
index 0000000..26d3707
--- /dev/null
+++ b/docs/adr/0001-metadata-block-handling.md
@@ -0,0 +1,134 @@
+# ADR 0001 — Metadata block (frontmatter) handling
+
+- **Date**: 2026-05-28
+- **Status**: Accepted
+- **Owners**: shawn
+
+## Context
+
+Markdown files in the wild — agent skill files (Anthropic/Cursor), static-site
+posts (Jekyll/Hugo/Zola), and notes (Obsidian/Logseq) — commonly start with a
+**frontmatter** block fenced by `---` (YAML) or `+++` (TOML). Termdown today
+does not enable pulldown-cmark's metadata-block extensions, so the opening
+fence is parsed as a horizontal rule and the YAML body collapses into a setext
+H2 heading — which then gets rasterized into a large PNG via the Kitty
+graphics pipeline. The result is loud, ugly, and useless.
+
+Documented prior state: `docs/MARKDOWN_FEATURE_COVERAGE.md:40` notes
+"YAML / TOML frontmatter — not enabled; currently leaks into body";
+`fixtures/supported-syntax.md:13-15` calls it out as a known roadmap item.
+
+This ADR records how termdown will handle metadata blocks going forward.
+
+## Decision
+
+Termdown will treat frontmatter as a **first-class lightweight element**, not
+as body content and not as completely invisible noise:
+
+1. **Parsing**: Enable `ENABLE_YAML_STYLE_METADATA_BLOCKS` **and**
+ `ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS` on the pulldown-cmark parser.
+ Capture the raw block text via the `MetadataBlock` event pair.
+2. **One-line summary**: A heuristic parser splits the raw block into
+ `key=value, …` pairs, joined into a single dim line wrapped as
+ `[metadata · key=value, …]`, truncated to terminal width — the closing
+ `]` is preserved after the truncation ellipsis. No real YAML/TOML parser
+ is introduced.
+3. **Fallback**: If the heuristic extracts zero valid key/value pairs, fall
+ back to a raw single-line join of the block content (so something useful is
+ still shown for malformed or exotic input).
+4. **Cat mode**: Render exactly the one-line summary at the document's top.
+5. **TUI mode**: Render the same one-line summary by default (**folded**). The
+ `m` key toggles to an **expanded** inline box listing each key/value on its
+ own row. The box is part of the scrolling content (not pinned), not part
+ of search, and not part of the Table of Contents. Default state is folded.
+6. **Config gate**: A single boolean knob `[metadata] show` (default `true`)
+ in `~/.termdown/config.toml`. When `false`, frontmatter is **completely
+ hidden** in both cat and TUI; `m` becomes a no-op. The pulldown extensions
+ remain enabled — `show` gates rendering only, never parsing, so frontmatter
+ never leaks back into body regardless of config.
+
+## Alternatives considered
+
+### Visibility (what to show)
+
+- **Hide completely, glow-style**. glow (the most-used CLI Markdown renderer)
+ silently strips frontmatter — no chip, no line, gone. Simplest possible
+ implementation. *Rejected*: termdown is positioned as a richer
+ terminal-reader experience; silently dropping authored metadata sacrifices
+ the agent-skill / Hugo-blog reading use case where the title/author/date
+ is genuinely informative.
+- **Render as a `yaml` code block**. Keeps the original text fully visible.
+ *Rejected*: defeats the goal — the whole reason this is annoying today is
+ that the raw text dominates the top of the document.
+- **One-line summary only, no expanded state**. Always-folded, no `m` key.
+ *Rejected*: users opening agent-skill files often want to see all fields at
+ once; the expanded state addresses that without burning screen real estate
+ by default.
+
+### Parsing strategy
+
+- **Full YAML parser** (e.g. `yaml-rust2`, `saphyr` — `serde_yaml` is
+ unmaintained). *Rejected*: adds a non-trivial dependency (~100–200 KB to
+ the binary) for a feature whose output is a 1-line summary. Termdown is a
+ terminal reader, not a YAML validator. The 5% of real-world files with
+ multi-line strings or deep nesting are not the target audience.
+- **Treat each block as opaque text, no parsing**. *Rejected*: rejected as a
+ primary mode (loses the `key=value` structure that makes the summary
+ readable) but **accepted as the fallback** when the heuristic fails.
+
+### Config surface
+
+- **No config knob**. *Rejected*: at least one knob is justified for users who
+ consistently don't want any frontmatter UI noise (the glow-style preference).
+- **Multiple knobs** (`default_state`, `cat_format`, …). *Rejected*: YAGNI.
+ The `m` key already handles per-document fold/expand preference; a single
+ master switch covers the only opt-out we can name a real user for.
+
+### Key binding
+
+- `m` is unused in the current TUI map (`src/tui/input.rs`), short, and a
+ natural mnemonic. Alternatives considered: `z`-style vim folds (multi-key
+ chord, overkill), `F` for fold (less obvious mnemonic).
+
+## Consequences
+
+- **New behavior visible in every fixture that has frontmatter.**
+ `fixtures/supported-syntax.md` already carries one; its golden snapshot
+ `fixtures/expected/supported-syntax.ansi` will change in the implementation
+ PR — reviewers should expect a large diff there and verify the new top
+ line reads `· metadata · …`.
+- **New keybinding** in TUI: `m`. Must be added to the `?` help screen.
+- **New config field** in `config.toml`. Backward compatible — missing field
+ defaults to `true`. Schema change in `src/config.rs`.
+- **No new dependencies**. Both pulldown-cmark options already ship in the
+ pinned 0.13 release.
+- **Edge cases handled implicitly by pulldown-cmark**: stray `---` mid-doc
+ is still a horizontal rule; frontmatter requires column 1, line 1; missing
+ closing fence consumes to EOF (rare; documented behavior, not
+ specially handled).
+- **What's explicitly out of scope**: parsing frontmatter fields for
+ *functional* use (e.g. piping `title` to the TUI title bar, using `tags`
+ for filtering). Tracked separately if and when demand emerges.
+
+## Test plan
+
+- Snapshot diff on existing `fixtures/supported-syntax.md` (mutates golden).
+- New focused fixtures under `fixtures/specialized/`:
+ - `metadata-yaml.md` — standard YAML block, exercises heuristic happy path.
+ - `metadata-toml.md` — standard TOML block, proves both syntaxes work.
+ - `metadata-malformed.md` — multi-line / nested values that trip the
+ heuristic, exercises fallback.
+ - `metadata-none.md` — body containing mid-document `---` thematic
+ breaks but no frontmatter; regression guard against false positives.
+- Unit tests for the heuristic in `src/frontmatter.rs` (or wherever it lands):
+ empty block, single field, multiple fields, value containing `:` / `=`,
+ zero-valid-pairs → fallback.
+- TUI fold/expand toggle: manual verification, not automated. Pure render
+ state, no branching logic worth automating.
+
+## Open follow-ups
+
+- Migration of config dir from `~/.termdown/` to XDG `~/.config/termdown/`
+ is unrelated and explicitly out of scope here.
+- "Use `title` field in TUI title bar" — sketched as a future enhancement,
+ not committed.
diff --git a/fixtures/expected/specialized/metadata-malformed.ansi b/fixtures/expected/specialized/metadata-malformed.ansi
new file mode 100644
index 0000000..92b1bff
--- /dev/null
+++ b/fixtures/expected/specialized/metadata-malformed.ansi
@@ -0,0 +1,8 @@
+[2m[metadata · description=>, key1=value1, key2=value2, title=Malformed Sample][0m
+
+Body. The frontmatter above mixes a folded scalar and a nested mapping —
+shapes the line-based heuristic cannot fully understand. It skips the folded
+continuation lines (which have no separator), but it cannot tell that [38;5;213m[48;5;236m key1 [0m /
+[38;5;213m[48;5;236m key2 [0m are nested under [38;5;213m[48;5;236m nested [0m, so it lifts them to top-level keys. The point
+of this fixture is that the heuristic degrades gracefully and the document does
+not break — not that the summary is semantically perfect.
diff --git a/fixtures/expected/specialized/metadata-none.ansi b/fixtures/expected/specialized/metadata-none.ansi
new file mode 100644
index 0000000..2af0710
--- /dev/null
+++ b/fixtures/expected/specialized/metadata-none.ansi
@@ -0,0 +1,11 @@
+
+
+This file has no YAML or TOML metadata block. The [38;5;213m[48;5;236m --- [0m line further down
+should be rendered as a normal horizontal rule, [1mnot[0m as the opening
+fence of a frontmatter block.
+
+Some body text before the rule.
+
+[2m────────────────────────────────────────────────────────────[0m
+
+Some body text after the rule.
diff --git a/fixtures/expected/specialized/metadata-toml.ansi b/fixtures/expected/specialized/metadata-toml.ansi
new file mode 100644
index 0000000..86cebe8
--- /dev/null
+++ b/fixtures/expected/specialized/metadata-toml.ansi
@@ -0,0 +1,4 @@
+[2m[metadata · title=TOML Frontmatter Example, author=shawn, description=Standard…][0m
+
+Body content follows. The TOML frontmatter above should be parsed the same
+way as YAML and rendered as a one-line summary.
diff --git a/fixtures/expected/specialized/metadata-yaml.ansi b/fixtures/expected/specialized/metadata-yaml.ansi
new file mode 100644
index 0000000..898d6b7
--- /dev/null
+++ b/fixtures/expected/specialized/metadata-yaml.ansi
@@ -0,0 +1,4 @@
+[2m[metadata · title=YAML Frontmatter Example, author=shawn, description=Standard…][0m
+
+Body content follows. The YAML frontmatter above should appear as a single
+dim summary line, not as a horizontal rule or stray paragraph.
diff --git a/fixtures/expected/supported-syntax.ansi b/fixtures/expected/supported-syntax.ansi
index 591af69..e2a26cd 100644
--- a/fixtures/expected/supported-syntax.ansi
+++ b/fixtures/expected/supported-syntax.ansi
@@ -1,6 +1,4 @@
-[2m────────────────────────────────────────────────────────────[0m
-
-
+[2m[metadata · title=Supported Syntax Showcase, author=termdown, tags=[markdown, …][0m
@@ -10,9 +8,9 @@ or has explicitly committed to supporting (see [38;5;213m[48;5;236m TODO.md [
change a
renderer feature, you should expect to update the snapshot of [3mthis[23m file.
-The YAML frontmatter above is part of the "markdown metadata" roadmap feature.
-It
-currently leaks into the rendered output and should one day be hidden.
+The YAML frontmatter above is parsed and rendered as a single dim summary line
+in [38;5;213m[48;5;236m --cat [0m, or as a collapsible inline box in TUI (toggle with [38;5;213m[48;5;236m m [0m). It does
+not leak into body content.
diff --git a/fixtures/specialized/metadata-malformed.md b/fixtures/specialized/metadata-malformed.md
new file mode 100644
index 0000000..e038358
--- /dev/null
+++ b/fixtures/specialized/metadata-malformed.md
@@ -0,0 +1,17 @@
+---
+description: >
+ this is a long
+ paragraph value spread
+ across multiple lines
+nested:
+ key1: value1
+ key2: value2
+title: Malformed Sample
+---
+
+Body. The frontmatter above mixes a folded scalar and a nested mapping —
+shapes the line-based heuristic cannot fully understand. It skips the folded
+continuation lines (which have no separator), but it cannot tell that `key1` /
+`key2` are nested under `nested`, so it lifts them to top-level keys. The point
+of this fixture is that the heuristic degrades gracefully and the document does
+not break — not that the summary is semantically perfect.
diff --git a/fixtures/specialized/metadata-none.md b/fixtures/specialized/metadata-none.md
new file mode 100644
index 0000000..7d79d4e
--- /dev/null
+++ b/fixtures/specialized/metadata-none.md
@@ -0,0 +1,11 @@
+# Plain Document, No Frontmatter
+
+This file has no YAML or TOML metadata block. The `---` line further down
+should be rendered as a normal horizontal rule, **not** as the opening
+fence of a frontmatter block.
+
+Some body text before the rule.
+
+---
+
+Some body text after the rule.
diff --git a/fixtures/specialized/metadata-toml.md b/fixtures/specialized/metadata-toml.md
new file mode 100644
index 0000000..fe2a6a2
--- /dev/null
+++ b/fixtures/specialized/metadata-toml.md
@@ -0,0 +1,9 @@
++++
+title = "TOML Frontmatter Example"
+author = "shawn"
+description = "Standard TOML metadata block, fenced with +++."
+draft = false
++++
+
+Body content follows. The TOML frontmatter above should be parsed the same
+way as YAML and rendered as a one-line summary.
diff --git a/fixtures/specialized/metadata-yaml.md b/fixtures/specialized/metadata-yaml.md
new file mode 100644
index 0000000..1ed95eb
--- /dev/null
+++ b/fixtures/specialized/metadata-yaml.md
@@ -0,0 +1,9 @@
+---
+title: YAML Frontmatter Example
+author: shawn
+description: Standard YAML metadata block — heuristic happy path.
+tags: [markdown, frontmatter, test]
+---
+
+Body content follows. The YAML frontmatter above should appear as a single
+dim summary line, not as a horizontal rule or stray paragraph.
diff --git a/fixtures/supported-syntax.md b/fixtures/supported-syntax.md
index df9cfb2..22aadd3 100644
--- a/fixtures/supported-syntax.md
+++ b/fixtures/supported-syntax.md
@@ -11,8 +11,9 @@ This fixture is the **single source of truth** for syntax termdown currently sup
or has explicitly committed to supporting (see `TODO.md`). When you add or change a
renderer feature, you should expect to update the snapshot of *this* file.
-The YAML frontmatter above is part of the "markdown metadata" roadmap feature. It
-currently leaks into the rendered output and should one day be hidden.
+The YAML frontmatter above is parsed and rendered as a single dim summary line
+in `--cat`, or as a collapsible inline box in TUI (toggle with `m`). It does
+not leak into body content.
## 1. Headings
diff --git a/src/cat.rs b/src/cat.rs
index 17e1515..282b298 100644
--- a/src/cat.rs
+++ b/src/cat.rs
@@ -3,6 +3,8 @@
use std::io::{BufWriter, Write};
+use crate::config::Config;
+use crate::frontmatter::{self, MetadataInfo};
use crate::layout::{Color, Line, LineKind, RenderedDoc, Span, Style};
use crate::render;
use crate::style::{
@@ -10,10 +12,17 @@ use crate::style::{
STRIKETHROUGH_ON, UNDERLINE_OFF, UNDERLINE_ON,
};
-pub fn print(doc: &RenderedDoc, term_width: usize, colors: &Colors) {
+pub fn print(doc: &RenderedDoc, term_width: usize, colors: &Colors, config: &Config) {
let stdout = std::io::stdout();
let mut out = BufWriter::new(stdout.lock());
+ if config.metadata.show {
+ if let Some(meta) = &doc.metadata {
+ write_metadata_oneline(&mut out, meta, term_width);
+ let _ = writeln!(&mut out);
+ }
+ }
+
let mut i = 0;
while i < doc.lines.len() {
let line = &doc.lines[i];
@@ -87,6 +96,14 @@ fn write_line(
}
}
+/// Render the folded one-line metadata summary used by both cat and the TUI's
+/// collapsed state: `[metadata · k=v, k=v, …]`, dimmed, truncated to fit
+/// while preserving the closing `]`.
+pub fn write_metadata_oneline(out: &mut W, meta: &MetadataInfo, term_width: usize) {
+ let text = frontmatter::folded_summary(meta, term_width);
+ let _ = writeln!(out, "{DIM_ON}{text}{RESET}");
+}
+
/// Emit a consecutive run of `LineKind::CodeBlock` lines, padding each to the
/// longest line in the group so the background renders as a clean rectangle.
fn emit_code_block(out: &mut W, group: &[Line], colors: &Colors) {
diff --git a/src/config.rs b/src/config.rs
index 394d350..cb3d2a3 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -16,6 +16,32 @@ pub struct Config {
/// e.g. Ghostty's `bell-features`. `None` means default (on). CLI
/// `--no-bell` overrides to `Some(false)`.
pub bell: Option,
+
+ #[serde(default)]
+ pub metadata: MetadataSection,
+}
+
+#[derive(Deserialize, Clone)]
+pub struct MetadataSection {
+ /// Whether to render frontmatter (YAML `---` / TOML `+++` metadata blocks).
+ /// `true` (default) shows the one-line summary in cat / TUI-folded, and
+ /// allows the TUI `m` key to expand. `false` hides metadata entirely;
+ /// parsing still runs so the block never leaks into body content.
+ /// See `docs/adr/0001-metadata-block-handling.md`.
+ #[serde(default = "default_metadata_show")]
+ pub show: bool,
+}
+
+fn default_metadata_show() -> bool {
+ true
+}
+
+impl Default for MetadataSection {
+ fn default() -> Self {
+ Self {
+ show: default_metadata_show(),
+ }
+ }
}
#[derive(Deserialize, Default, Clone)]
diff --git a/src/frontmatter.rs b/src/frontmatter.rs
new file mode 100644
index 0000000..713ed4a
--- /dev/null
+++ b/src/frontmatter.rs
@@ -0,0 +1,282 @@
+//! Frontmatter (YAML / TOML metadata block) heuristic parser.
+//!
+//! We never feed the block to a real YAML/TOML parser. The block's destination
+//! is a single dim summary line (cat / TUI folded) or an inline expanded box
+//! (TUI), so fidelity beyond "key = value" doesn't matter. See
+//! `docs/adr/0001-metadata-block-handling.md`.
+
+use pulldown_cmark::MetadataBlockKind;
+use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MetadataKind {
+ Yaml,
+ Toml,
+}
+
+impl From for MetadataKind {
+ fn from(k: MetadataBlockKind) -> Self {
+ match k {
+ MetadataBlockKind::YamlStyle => MetadataKind::Yaml,
+ MetadataBlockKind::PlusesStyle => MetadataKind::Toml,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MetadataInfo {
+ pub kind: MetadataKind,
+ /// Parsed key/value pairs. Empty when the heuristic found zero conforming
+ /// lines; renderers fall back to [`Self::fallback_oneline`] in that case.
+ pub pairs: Vec<(String, String)>,
+ /// Raw block content joined with `, ` for fallback display.
+ pub fallback_oneline: String,
+}
+
+impl MetadataInfo {
+ /// True when the heuristic extracted at least one pair. False means the
+ /// renderer should use [`Self::fallback_oneline`] verbatim.
+ pub fn has_pairs(&self) -> bool {
+ !self.pairs.is_empty()
+ }
+}
+
+/// Parse a raw frontmatter block into key/value pairs. Splits each non-blank
+/// line on the first `:` (YAML) or `=` (TOML), trims, and keeps lines where
+/// both sides are non-empty. Comment lines (leading `#`) are skipped.
+pub fn parse(raw: &str, kind: MetadataKind) -> MetadataInfo {
+ let sep = match kind {
+ MetadataKind::Yaml => ':',
+ MetadataKind::Toml => '=',
+ };
+
+ let mut pairs = Vec::new();
+ for line in raw.lines() {
+ let trimmed = line.trim();
+ if trimmed.is_empty() || trimmed.starts_with('#') {
+ continue;
+ }
+ let Some(idx) = trimmed.find(sep) else {
+ continue;
+ };
+ let key = trimmed[..idx].trim();
+ let raw_value = trimmed[idx + sep.len_utf8()..].trim();
+ // Reject empty keys, and keys that contain whitespace — the latter
+ // usually means the line was a continuation of a multi-line value
+ // rather than its own field.
+ if key.is_empty() || key.chars().any(char::is_whitespace) {
+ continue;
+ }
+ // Strip surrounding quotes from TOML values (also tolerated for YAML),
+ // then require a non-empty value so `key: ""` doesn't yield `key=`.
+ let value = strip_quotes(raw_value);
+ if value.is_empty() {
+ continue;
+ }
+ pairs.push((key.to_string(), value.to_string()));
+ }
+
+ let fallback_oneline = raw
+ .lines()
+ .map(str::trim)
+ .filter(|l| !l.is_empty())
+ .collect::>()
+ .join(", ");
+
+ MetadataInfo {
+ kind,
+ pairs,
+ fallback_oneline,
+ }
+}
+
+fn strip_quotes(s: &str) -> String {
+ let bytes = s.as_bytes();
+ if bytes.len() >= 2
+ && (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"'
+ || bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
+ {
+ s[1..s.len() - 1].to_string()
+ } else {
+ s.to_string()
+ }
+}
+
+/// Compose the one-line summary text, NOT including the leading `· metadata · `
+/// marker or any width truncation. Callers add those.
+pub fn format_pairs_inline(info: &MetadataInfo) -> String {
+ if info.has_pairs() {
+ info.pairs
+ .iter()
+ .map(|(k, v)| format!("{k}={v}"))
+ .collect::>()
+ .join(", ")
+ } else {
+ info.fallback_oneline.clone()
+ }
+}
+
+/// The folded one-line summary, wrapped as `[metadata · k=v, …]`, truncated to
+/// `max_cols` display columns with the closing `]` preserved after the ellipsis.
+/// Shared verbatim by `--cat` and the TUI folded row so the two never diverge.
+pub fn folded_summary(info: &MetadataInfo, max_cols: usize) -> String {
+ let full = format!("[metadata · {}]", format_pairs_inline(info));
+ truncate_keep_suffix(&full, max_cols, "]")
+}
+
+/// Truncate `s` to `max_cols` display columns. When truncation is needed,
+/// append `…` followed by the literal `suffix` (e.g. `"]"`). Returns `s`
+/// unchanged when it already fits, and an empty string when `max_cols == 0`.
+pub fn truncate_keep_suffix(s: &str, max_cols: usize, suffix: &str) -> String {
+ if max_cols == 0 {
+ return String::new();
+ }
+ if s.width() <= max_cols {
+ return s.to_string();
+ }
+ let budget = max_cols.saturating_sub("…".width() + suffix.width());
+ let mut acc = String::new();
+ let mut width = 0;
+ for ch in s.chars() {
+ let cw = ch.width().unwrap_or(0);
+ if width + cw > budget {
+ break;
+ }
+ acc.push(ch);
+ width += cw;
+ }
+ acc.push('…');
+ acc.push_str(suffix);
+ acc
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn yaml_happy_path() {
+ let raw = "title: Hello\nauthor: shawn\ntags: [a, b]\n";
+ let info = parse(raw, MetadataKind::Yaml);
+ assert_eq!(
+ info.pairs,
+ vec![
+ ("title".into(), "Hello".into()),
+ ("author".into(), "shawn".into()),
+ ("tags".into(), "[a, b]".into()),
+ ]
+ );
+ assert!(info.has_pairs());
+ }
+
+ #[test]
+ fn toml_happy_path() {
+ let raw = "title = \"Hello\"\nauthor = 'shawn'\ncount = 42\n";
+ let info = parse(raw, MetadataKind::Toml);
+ assert_eq!(
+ info.pairs,
+ vec![
+ ("title".into(), "Hello".into()),
+ ("author".into(), "shawn".into()),
+ ("count".into(), "42".into()),
+ ]
+ );
+ }
+
+ #[test]
+ fn value_containing_separator() {
+ // YAML: only the first `:` separates key/value.
+ let raw = "url: https://example.com/path:8080\n";
+ let info = parse(raw, MetadataKind::Yaml);
+ assert_eq!(
+ info.pairs,
+ vec![("url".into(), "https://example.com/path:8080".into())]
+ );
+ }
+
+ #[test]
+ fn blank_and_comment_lines_skipped() {
+ let raw = "# a comment\n\ntitle: Hi\n# another\nauthor: x\n";
+ let info = parse(raw, MetadataKind::Yaml);
+ assert_eq!(
+ info.pairs,
+ vec![("title".into(), "Hi".into()), ("author".into(), "x".into()),]
+ );
+ }
+
+ #[test]
+ fn multiline_continuation_lines_skipped() {
+ // Heuristic rejects lines whose "key" contains whitespace (typical of
+ // YAML multi-line value continuations).
+ let raw = "description: >\n this is a long\n paragraph value\ntitle: T\n";
+ let info = parse(raw, MetadataKind::Yaml);
+ // `description: >` → key=description, value=">" (kept)
+ // ` this is a long` → key="this is a long" with spaces → skipped
+ // ` paragraph value` → same → skipped
+ // `title: T` → kept
+ assert_eq!(
+ info.pairs,
+ vec![
+ ("description".into(), ">".into()),
+ ("title".into(), "T".into()),
+ ]
+ );
+ }
+
+ #[test]
+ fn empty_block_yields_no_pairs() {
+ let info = parse("", MetadataKind::Yaml);
+ assert!(!info.has_pairs());
+ assert_eq!(info.fallback_oneline, "");
+ }
+
+ #[test]
+ fn unparseable_block_uses_fallback() {
+ // Lines with no separator at all — heuristic yields zero pairs.
+ let raw = "just\nsome\nrandom text\n";
+ let info = parse(raw, MetadataKind::Yaml);
+ assert!(!info.has_pairs());
+ assert_eq!(info.fallback_oneline, "just, some, random text");
+ }
+
+ #[test]
+ fn format_pairs_inline_uses_pairs_when_present() {
+ let info = parse("a: 1\nb: 2\n", MetadataKind::Yaml);
+ assert_eq!(format_pairs_inline(&info), "a=1, b=2");
+ }
+
+ #[test]
+ fn format_pairs_inline_falls_back_when_no_pairs() {
+ let info = parse("plain\ntext\n", MetadataKind::Yaml);
+ assert_eq!(format_pairs_inline(&info), "plain, text");
+ }
+
+ #[test]
+ fn empty_quoted_value_skipped() {
+ // `key: ""` strips to an empty value and must not yield `key=`.
+ let info = parse("title: \"\"\nauthor: x\n", MetadataKind::Yaml);
+ assert_eq!(info.pairs, vec![("author".into(), "x".into())]);
+ }
+
+ #[test]
+ fn folded_summary_fits_unchanged() {
+ let info = parse("a: 1\nb: 2\n", MetadataKind::Yaml);
+ assert_eq!(folded_summary(&info, 80), "[metadata · a=1, b=2]");
+ }
+
+ #[test]
+ fn folded_summary_truncates_keeping_bracket() {
+ let info = parse("title: A very long value indeed\n", MetadataKind::Yaml);
+ let out = folded_summary(&info, 20);
+ assert_eq!(out.width(), 20);
+ assert!(out.ends_with("…]"), "closing bracket must survive: {out}");
+ }
+
+ #[test]
+ fn truncate_keep_suffix_respects_wide_chars() {
+ // Each CJK char is two display columns wide.
+ let out = truncate_keep_suffix("[中文很长很长很长]", 10, "]");
+ assert!(out.width() <= 10, "got width {}: {out}", out.width());
+ assert!(out.ends_with("…]"));
+ }
+}
diff --git a/src/layout.rs b/src/layout.rs
index 2d6e801..fd3c908 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -2,6 +2,7 @@ use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use rayon::prelude::*;
use crate::config::Config;
+use crate::frontmatter::{self, MetadataInfo};
use crate::render::HeadingImage;
use crate::theme::Theme;
@@ -10,6 +11,11 @@ pub struct RenderedDoc {
pub lines: Vec,
pub headings: Vec,
pub images: Vec,
+ /// Parsed frontmatter, if the document opens with a YAML (`---`) or TOML
+ /// (`+++`) metadata block. The block content never appears in `lines`
+ /// regardless of [`config.metadata.show`]; renderers consult this field
+ /// directly and decide how (or whether) to display it.
+ pub metadata: Option,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -93,6 +99,11 @@ pub fn build(md: &str, config: &Config, theme: Theme) -> RenderedDoc {
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_TASKLISTS);
+ // Both metadata block flavors are always enabled — they only ever match
+ // at line 1 column 1, so they can't false-positive in regular content.
+ // The `[metadata] show` config knob gates *display*, not parsing.
+ opts.insert(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS);
+ opts.insert(Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS);
let parser = Parser::new_ext(md, opts);
let mut lines: Vec = Vec::new();
@@ -121,6 +132,9 @@ pub fn build(md: &str, config: &Config, theme: Theme) -> RenderedDoc {
let mut in_html_block = false;
let mut html_block_lines: Vec = Vec::new();
let mut in_item = false;
+ let mut in_metadata_block: Option = None;
+ let mut metadata_raw = String::new();
+ let mut metadata: Option = None;
// Helper to decide whether a blank line is needed before the next block.
// Returns true if a blank separator should be emitted.
@@ -253,6 +267,19 @@ pub fn build(md: &str, config: &Config, theme: Theme) -> RenderedDoc {
style: code_style,
});
}
+ Event::Start(Tag::MetadataBlock(kind)) => {
+ in_metadata_block = Some(kind);
+ metadata_raw.clear();
+ }
+ Event::End(TagEnd::MetadataBlock(_)) => {
+ if let Some(kind) = in_metadata_block.take() {
+ metadata = Some(frontmatter::parse(&metadata_raw, kind.into()));
+ }
+ metadata_raw.clear();
+ }
+ Event::Text(t) if in_metadata_block.is_some() => {
+ metadata_raw.push_str(&t);
+ }
Event::Text(t) => {
if heading_level > 0 {
heading_text.push_str(&t);
@@ -621,6 +648,7 @@ pub fn build(md: &str, config: &Config, theme: Theme) -> RenderedDoc {
lines,
headings,
images,
+ metadata,
}
}
@@ -894,6 +922,7 @@ mod tests {
}],
headings: vec![],
images: vec![],
+ metadata: None,
};
}
diff --git a/src/main.rs b/src/main.rs
index 8ee6337..7f7ef18 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
mod cat;
mod config;
mod font;
+mod frontmatter;
mod layout;
mod render;
mod style;
@@ -123,7 +124,7 @@ fn main() {
let saved_termios = needs_echo_suppression().then(disable_echo);
let doc = layout::build(&md, &config, theme);
- cat::print(&doc, term_width, &colors);
+ cat::print(&doc, term_width, &colors, &config);
#[cfg(unix)]
{
diff --git a/src/tui/input.rs b/src/tui/input.rs
index 5660438..f7aa6f7 100644
--- a/src/tui/input.rs
+++ b/src/tui/input.rs
@@ -23,6 +23,7 @@ pub enum Action {
Back,
Forward,
OpenHelp,
+ ToggleMetadata,
None,
}
@@ -64,6 +65,8 @@ pub fn map_normal(key: KeyEvent) -> Action {
KeyCode::Char('o') => Action::Back,
KeyCode::Char('i') => Action::Forward,
+ KeyCode::Char('m') => Action::ToggleMetadata,
+
_ => Action::None,
}
}
diff --git a/src/tui/mod.rs b/src/tui/mod.rs
index 78b60e9..6ba5fd0 100644
--- a/src/tui/mod.rs
+++ b/src/tui/mod.rs
@@ -50,6 +50,10 @@ struct DocEntry {
search: Option,
pending_g: bool,
toc_open: bool,
+ /// Whether the frontmatter metadata block (if any) is shown expanded as an
+ /// inline box. Default `false` = folded one-line summary. Toggled by the
+ /// `m` key. Has no effect when `config.metadata.show` is false.
+ metadata_expanded: bool,
}
struct App {
@@ -157,6 +161,7 @@ impl App {
search: None,
pending_g: false,
toc_open: false,
+ metadata_expanded: false,
};
// Refine image row estimates now that (a) the doc is populated and
// (b) we may already know the real terminal cell pixel height.
@@ -290,6 +295,7 @@ fn event_loop(terminal: &mut Terminal, app: &mut App) -> io::Resu
size.width
};
app.term_size = (size.width, body_height);
+ let show_metadata = app.config.metadata.show;
{
let active = app.active_mut();
if active.viewport.width != body_width || active.viewport.height != body_height {
@@ -298,7 +304,10 @@ fn event_loop(terminal: &mut Terminal, app: &mut App) -> io::Resu
// width change implicitly invalidates wrap via ensure_wrap's
// cache_width comparison.
}
- active.viewport.ensure_wrap(&active.doc);
+ let expanded = active.metadata_expanded;
+ active
+ .viewport
+ .ensure_wrap(&active.doc, show_metadata, expanded);
}
// Force a full redraw if state changed in a way that may leave
@@ -458,6 +467,16 @@ fn handle_normal_key(app: &mut App, ev: &Event) -> io::Result<()> {
// clear to avoid stale image pixels on the body side.
app.needs_full_redraw = true;
}
+ // No-op when metadata display is disabled — toggling would only
+ // churn the wrap cache and force a redraw with no visible change.
+ input::Action::ToggleMetadata if app.config.metadata.show => {
+ let active = app.active_mut();
+ if active.doc.metadata.is_some() {
+ active.metadata_expanded = !active.metadata_expanded;
+ active.viewport.invalidate_wrap();
+ app.needs_full_redraw = true;
+ }
+ }
input::Action::Back => {
if let Some(prev) = app.history.pop() {
app.forward.push(app.cursor);
@@ -736,6 +755,102 @@ fn visible_matches_for_line(
.collect()
}
+/// Render one VisualLine row that visualizes the document's frontmatter.
+/// The role determines what shows up: folded summary, expanded top/bottom
+/// border, or a single field row inside the expanded box.
+fn render_metadata_row(
+ meta: &crate::frontmatter::MetadataInfo,
+ role: viewport::MetadataVisualRow,
+ body_cols: usize,
+) -> RLine<'static> {
+ use ratatui::style::{Modifier, Style as RStyle};
+
+ let dim = RStyle::default().add_modifier(Modifier::DIM);
+
+ match role {
+ viewport::MetadataVisualRow::Folded => {
+ // Identical construction/truncation as `--cat` — shared so the two
+ // folded renderings can never drift apart.
+ let text = crate::frontmatter::folded_summary(meta, body_cols);
+ RLine::from(RSpan::styled(text, dim))
+ }
+ viewport::MetadataVisualRow::ExpandedTop => {
+ // Width follows the body area, capped so the box doesn't dominate
+ // narrow terminals; minimum 12 for a sensible visual.
+ let inner_w = expanded_box_width(body_cols);
+ let title = " metadata ";
+ let mut s = String::from("┌─");
+ s.push_str(title);
+ let remaining = inner_w.saturating_sub(s.chars().count() + 1).max(1);
+ s.push_str(&"─".repeat(remaining));
+ s.push('┐');
+ RLine::from(RSpan::styled(s, dim))
+ }
+ viewport::MetadataVisualRow::ExpandedField(idx) => {
+ use unicode_width::UnicodeWidthStr;
+ let inner_w = expanded_box_width(body_cols);
+ // 2 border chars + 1 leading space + 1 trailing space = 4 chrome.
+ let field_budget = inner_w.saturating_sub(4);
+ let (k_text, v_text) = if meta.has_pairs() {
+ let (k, v) = &meta.pairs[idx];
+ (k.clone(), v.clone())
+ } else {
+ ("metadata".to_string(), meta.fallback_oneline.clone())
+ };
+ // All widths are display columns (not char counts) so CJK / wide
+ // characters in keys or values keep the box border aligned.
+ // Right-pad the key column to the widest key (capped) so values align.
+ let key_col = meta
+ .pairs
+ .iter()
+ .map(|(k, _)| UnicodeWidthStr::width(k.as_str()))
+ .max()
+ .unwrap_or(0)
+ .min(field_budget.saturating_sub(3));
+ let key_pad = key_col.saturating_sub(UnicodeWidthStr::width(k_text.as_str()));
+ let line_body = format!("{}{}: ", k_text, " ".repeat(key_pad));
+ let val_budget =
+ field_budget.saturating_sub(UnicodeWidthStr::width(line_body.as_str()));
+ let value = truncate_to_cols(&v_text, val_budget);
+ let inside = format!("{line_body}{value}");
+ let pad = field_budget.saturating_sub(UnicodeWidthStr::width(inside.as_str()));
+ let row = format!("│ {inside}{} │", " ".repeat(pad));
+ RLine::from(RSpan::styled(row, dim))
+ }
+ viewport::MetadataVisualRow::ExpandedBottom => {
+ let inner_w = expanded_box_width(body_cols);
+ let mut s = String::from("└");
+ s.push_str(&"─".repeat(inner_w.saturating_sub(2)));
+ s.push('┘');
+ RLine::from(RSpan::styled(s, dim))
+ }
+ }
+}
+
+fn expanded_box_width(body_cols: usize) -> usize {
+ body_cols.clamp(12, 80)
+}
+
+fn truncate_to_cols(s: &str, max_cols: usize) -> String {
+ use unicode_width::UnicodeWidthChar;
+ if max_cols == 0 {
+ return String::new();
+ }
+ let mut width = 0;
+ let mut acc = String::new();
+ for ch in s.chars() {
+ let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
+ if width + cw > max_cols.saturating_sub(1) {
+ // Reserve 1 col for the ellipsis.
+ acc.push('…');
+ return acc;
+ }
+ acc.push(ch);
+ width += cw;
+ }
+ acc
+}
+
fn clipped_spans(
line: &layout::Line,
byte_start: usize,
@@ -856,16 +971,32 @@ fn draw(frame: &mut ratatui::Frame, app: &App) {
let mut rendered: Vec = Vec::new();
for vl in active.viewport.visible() {
- let logical = &active.doc.lines[vl.logical_index];
+ if let Some(role) = vl.metadata_row {
+ let body_cols = active.viewport.width as usize;
+ rendered.push(render_metadata_row(
+ active
+ .doc
+ .metadata
+ .as_ref()
+ .expect("metadata_row visual line requires doc.metadata to be Some"),
+ role,
+ body_cols,
+ ));
+ continue;
+ }
// Heading spacer VisualLines reserve the rows below the main heading
// line so the kitty image's cell footprint matches the viewport row
- // budget. Render as empty — the image paints over them.
+ // budget. Render as empty — the image paints over them. Checked before
+ // indexing `doc.lines` so the metadata block's trailing blank (whose
+ // `logical_index` is a sentinel) never dereferences a real line.
if vl.is_spacer {
rendered.push(RLine::from(Vec::::new()));
continue;
}
+ let logical = &active.doc.lines[vl.logical_index];
+
let matches = visible_matches_for_line(
active.search.as_ref(),
vl.logical_index,
@@ -902,7 +1033,15 @@ fn draw(frame: &mut ratatui::Frame, app: &App) {
.viewport
.visible()
.first()
- .map(|vl| vl.logical_index)
+ // Metadata rows carry a sentinel `logical_index`; treat them as the
+ // document top (logical 0) so the ToC doesn't select the last heading.
+ .map(|vl| {
+ if vl.logical_index == viewport::NO_LOGICAL {
+ 0
+ } else {
+ vl.logical_index
+ }
+ })
.and_then(|top| {
active
.doc
@@ -966,7 +1105,14 @@ const HELP_SECTIONS: &[(&str, &[(&str, &str)])] = &[
("o i", "back / forward in history"),
],
),
- ("Other", &[("?", "toggle this help"), ("q Ctrl-C", "quit")]),
+ (
+ "Other",
+ &[
+ ("m", "toggle metadata fold (if frontmatter)"),
+ ("?", "toggle this help"),
+ ("q Ctrl-C", "quit"),
+ ],
+ ),
];
/// Intrinsic `(width, height)` of the help popup including its border,
@@ -1355,9 +1501,9 @@ mod help_popup_tests {
h > 2,
"height should include at least one content row, got {h}"
);
- // Sanity: the popup is small enough to fit inside a typical 80x24 terminal.
+ // Sanity: the popup is small enough to fit inside a typical 80x25 terminal.
assert!(w <= 80);
- assert!(h <= 24);
+ assert!(h <= 25);
}
#[test]
diff --git a/src/tui/search.rs b/src/tui/search.rs
index 6defe44..b96a570 100644
--- a/src/tui/search.rs
+++ b/src/tui/search.rs
@@ -108,6 +108,7 @@ mod tests {
.collect(),
headings: vec![],
images: vec![],
+ metadata: None,
}
}
diff --git a/src/tui/viewport.rs b/src/tui/viewport.rs
index a5f0d97..5410f17 100644
--- a/src/tui/viewport.rs
+++ b/src/tui/viewport.rs
@@ -5,6 +5,12 @@
use crate::layout::{Line, RenderedDoc, Span};
+/// Sentinel `logical_index` for VisualLines that don't map to any `doc.lines`
+/// entry — the metadata block's rows and its trailing blank. Using `usize::MAX`
+/// (no real line ever reaches it) keeps logical→visual lookups used by search
+/// and heading navigation from matching real line 0.
+pub const NO_LOGICAL: usize = usize::MAX;
+
/// A wrapped visual line, pointing back to a logical `Line` and the byte
/// range of its content that this visual slice covers.
#[derive(Debug, Clone)]
@@ -16,6 +22,22 @@ pub struct VisualLine {
/// heading image's cell footprint matches the viewport's row count.
/// These rows render as blank and do not carry image placements.
pub is_spacer: bool,
+ /// Set on rows that visualize the document's frontmatter metadata block.
+ /// `logical_index` is [`NO_LOGICAL`] for these rows — `draw()` consults
+ /// `doc.metadata` instead. See `docs/adr/0001-metadata-block-handling.md`.
+ pub metadata_row: Option,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MetadataVisualRow {
+ /// Single dim line when collapsed.
+ Folded,
+ /// Top border of the expanded box.
+ ExpandedTop,
+ /// `usize` is the index into `MetadataInfo.pairs` (or 0 when falling back).
+ ExpandedField(usize),
+ /// Bottom border of the expanded box.
+ ExpandedBottom,
}
pub struct Viewport {
@@ -24,6 +46,8 @@ pub struct Viewport {
pub width: u16,
visual_lines: Vec,
cache_width: u16,
+ cache_metadata_expanded: bool,
+ cache_metadata_shown: bool,
}
impl Viewport {
@@ -34,16 +58,33 @@ impl Viewport {
width,
visual_lines: Vec::new(),
cache_width: 0,
+ cache_metadata_expanded: false,
+ cache_metadata_shown: false,
}
}
- /// (Re)compute the wrap cache if the width has changed since the last call.
- pub fn ensure_wrap(&mut self, doc: &RenderedDoc) {
- if self.cache_width == self.width && !self.visual_lines.is_empty() {
+ /// (Re)compute the wrap cache if width, metadata visibility, or fold state
+ /// have changed since the last call. `show_metadata` and `expanded` come
+ /// from `Config.metadata.show` and `DocEntry.metadata_expanded` respectively.
+ pub fn ensure_wrap(&mut self, doc: &RenderedDoc, show_metadata: bool, expanded: bool) {
+ if self.cache_width == self.width
+ && self.cache_metadata_expanded == expanded
+ && self.cache_metadata_shown == show_metadata
+ && !self.visual_lines.is_empty()
+ {
return;
}
- self.visual_lines = wrap_all(&doc.lines, self.width);
+ let mut lines = Vec::new();
+ if show_metadata {
+ if let Some(meta) = &doc.metadata {
+ lines.extend(metadata_visual_lines(meta, expanded));
+ }
+ }
+ lines.extend(wrap_all(&doc.lines, self.width));
+ self.visual_lines = lines;
self.cache_width = self.width;
+ self.cache_metadata_expanded = expanded;
+ self.cache_metadata_shown = show_metadata;
if self.visual_lines.is_empty() {
self.top = 0;
return;
@@ -54,6 +95,13 @@ impl Viewport {
}
}
+ /// Drop the wrap cache so the next `ensure_wrap` rebuilds it. Used when
+ /// state outside `width` changes (e.g. metadata fold toggle).
+ pub fn invalidate_wrap(&mut self) {
+ self.visual_lines.clear();
+ self.cache_width = 0;
+ }
+
/// Move `top` by `delta` visual lines, clamped to [0, max_top].
pub fn scroll_by(&mut self, delta: i32) {
let max_top = self.visual_lines.len().saturating_sub(self.height as usize);
@@ -84,9 +132,13 @@ impl Viewport {
let start_logical = self
.visual_lines
.get(after_visual)
- .map(|vl| vl.logical_index)
- .unwrap_or(0);
- let target = doc.headings.iter().find(|h| h.line_index > start_logical);
+ .map(|vl| vl.logical_index);
+ // On a metadata/sentinel row we're above the body, so the first heading
+ // is "next"; otherwise the first heading strictly after the current line.
+ let target = match start_logical {
+ Some(l) if l != NO_LOGICAL => doc.headings.iter().find(|h| h.line_index > l),
+ _ => doc.headings.first(),
+ };
if let Some(h) = target {
if let Some(vi) = self
.visual_lines
@@ -104,13 +156,12 @@ impl Viewport {
let start_logical = self
.visual_lines
.get(before_visual)
- .map(|vl| vl.logical_index)
- .unwrap_or(0);
- let target = doc
- .headings
- .iter()
- .rev()
- .find(|h| h.line_index < start_logical);
+ .map(|vl| vl.logical_index);
+ // A metadata/sentinel row sits above every heading, so nothing precedes it.
+ let target = match start_logical {
+ Some(l) if l != NO_LOGICAL => doc.headings.iter().rev().find(|h| h.line_index < l),
+ _ => None,
+ };
if let Some(h) = target {
if let Some(vi) = self
.visual_lines
@@ -123,6 +174,47 @@ impl Viewport {
}
}
+/// Build the VisualLines that visualize a document's frontmatter at the top
+/// of the viewport. One row when folded, top-border + N-field + bottom-border
+/// when expanded. When the heuristic produced no pairs, expanded still emits
+/// a single field row carrying the fallback string.
+fn metadata_visual_lines(
+ meta: &crate::frontmatter::MetadataInfo,
+ expanded: bool,
+) -> Vec {
+ // All metadata rows carry the sentinel logical index — they don't map to
+ // any `doc.lines` entry. See [`NO_LOGICAL`].
+ let row = |metadata_row: Option, is_spacer: bool| VisualLine {
+ logical_index: NO_LOGICAL,
+ byte_start: 0,
+ byte_end: 0,
+ is_spacer,
+ metadata_row,
+ };
+
+ let mut out = Vec::new();
+ if !expanded {
+ out.push(row(Some(MetadataVisualRow::Folded), false));
+ } else {
+ let row_count = if meta.has_pairs() {
+ meta.pairs.len()
+ } else {
+ 1
+ };
+ out.reserve(row_count + 2);
+ out.push(row(Some(MetadataVisualRow::ExpandedTop), false));
+ for i in 0..row_count {
+ out.push(row(Some(MetadataVisualRow::ExpandedField(i)), false));
+ }
+ out.push(row(Some(MetadataVisualRow::ExpandedBottom), false));
+ }
+ // Trailing blank row: separate the metadata block from whatever heading
+ // or paragraph follows. Rendered as empty via the existing `is_spacer`
+ // branch in `draw()`.
+ out.push(row(None, true));
+ out
+}
+
fn wrap_all(lines: &[Line], width: u16) -> Vec {
use crate::layout::LineKind;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
@@ -144,6 +236,7 @@ fn wrap_all(lines: &[Line], width: u16) -> Vec {
byte_start: 0,
byte_end: end,
is_spacer: false,
+ metadata_row: None,
});
for _ in 1..rows {
out.push(VisualLine {
@@ -151,6 +244,7 @@ fn wrap_all(lines: &[Line], width: u16) -> Vec {
byte_start: 0,
byte_end: 0,
is_spacer: true,
+ metadata_row: None,
});
}
continue;
@@ -162,6 +256,7 @@ fn wrap_all(lines: &[Line], width: u16) -> Vec {
byte_start: 0,
byte_end: line_byte_len(line),
is_spacer: false,
+ metadata_row: None,
});
continue;
}
@@ -187,6 +282,7 @@ fn wrap_all(lines: &[Line], width: u16) -> Vec {
byte_start: 0,
byte_end: text.len(),
is_spacer: false,
+ metadata_row: None,
});
continue;
}
@@ -205,6 +301,7 @@ fn wrap_all(lines: &[Line], width: u16) -> Vec {
byte_start,
byte_end: cur_byte,
is_spacer: false,
+ metadata_row: None,
});
byte_start = cur_byte;
cur_width = 0;
@@ -219,6 +316,7 @@ fn wrap_all(lines: &[Line], width: u16) -> Vec {
byte_start,
byte_end: text.len(),
is_spacer: false,
+ metadata_row: None,
});
} else if text.is_empty() {
// Empty logical line (e.g. a `Body` with no content) — emit one empty visual.
@@ -227,6 +325,7 @@ fn wrap_all(lines: &[Line], width: u16) -> Vec {
byte_start: 0,
byte_end: 0,
is_spacer: false,
+ metadata_row: None,
});
}
}
@@ -276,6 +375,7 @@ mod tests {
lines,
headings: vec![],
images: vec![],
+ metadata: None,
}
}
@@ -283,7 +383,7 @@ mod tests {
fn scroll_respects_bounds() {
let doc = make_doc(10);
let mut vp = Viewport::new(4, 40);
- vp.ensure_wrap(&doc);
+ vp.ensure_wrap(&doc, false, false);
assert_eq!(vp.top, 0);
vp.scroll_by(-3);
@@ -301,7 +401,7 @@ mod tests {
fn empty_doc_visible_is_empty() {
let doc = make_doc(0);
let mut vp = Viewport::new(4, 40);
- vp.ensure_wrap(&doc);
+ vp.ensure_wrap(&doc, false, false);
assert!(vp.visible().is_empty());
assert_eq!(vp.total_visual_lines(), 0);
}
@@ -310,7 +410,7 @@ mod tests {
fn height_exceeds_total_shows_all() {
let doc = make_doc(3);
let mut vp = Viewport::new(10, 40);
- vp.ensure_wrap(&doc);
+ vp.ensure_wrap(&doc, false, false);
assert_eq!(vp.visible().len(), 3);
// max_top = total - height = 3 - 10 = 0 (saturating)
vp.scroll_by(100);
@@ -346,9 +446,10 @@ mod tests {
lines,
headings,
images: vec![],
+ metadata: None,
};
let mut vp = Viewport::new(3, 40);
- vp.ensure_wrap(&doc);
+ vp.ensure_wrap(&doc, false, false);
vp.jump_to_next_heading(&doc, 0);
assert_eq!(vp.top, 3);
@@ -360,6 +461,72 @@ mod tests {
assert_eq!(vp.top, 3);
}
+ #[test]
+ fn heading_jump_accounts_for_metadata_rows() {
+ use crate::frontmatter::{MetadataInfo, MetadataKind};
+ use crate::layout::{HeadingEntry, Line, LineKind, Span, Style};
+
+ // A doc whose very first body line (logical 0) is a heading, preceded
+ // by a shown frontmatter block. The folded metadata occupies visual
+ // rows 0 (summary) + 1 (trailing blank), so the heading at logical 0
+ // lands on visual row 2 — jumps must resolve to the real heading, not
+ // a metadata row that also (formerly) carried logical_index 0.
+ let lines: Vec = (0..5)
+ .map(|i| Line {
+ spans: vec![Span::Text {
+ content: format!("row {i}"),
+ style: Style::default(),
+ }],
+ kind: LineKind::Body,
+ })
+ .collect();
+ let headings = vec![
+ HeadingEntry {
+ level: 1,
+ text: "A".into(),
+ line_index: 0,
+ },
+ HeadingEntry {
+ level: 1,
+ text: "B".into(),
+ line_index: 3,
+ },
+ ];
+ let doc = RenderedDoc {
+ lines,
+ headings,
+ images: vec![],
+ metadata: Some(MetadataInfo {
+ kind: MetadataKind::Yaml,
+ pairs: vec![("title".into(), "T".into())],
+ fallback_oneline: String::new(),
+ }),
+ };
+ let mut vp = Viewport::new(3, 40);
+ vp.ensure_wrap(&doc, true, false);
+
+ // Two metadata visual rows precede the body.
+ assert!(matches!(
+ vp.visual_lines[0].metadata_row,
+ Some(MetadataVisualRow::Folded)
+ ));
+ assert_eq!(vp.visual_lines[0].logical_index, NO_LOGICAL);
+
+ // From the metadata top row, "next heading" finds the first heading
+ // (logical 0), which sits at visual row 2.
+ vp.jump_to_next_heading(&doc, 0);
+ assert_eq!(vp.top, 2);
+
+ // Next from there moves to heading B (logical 3) at visual row 5.
+ vp.jump_to_next_heading(&doc, vp.top);
+ assert_eq!(vp.top, 5);
+
+ // Prev from a metadata/sentinel top row is a no-op (nothing above).
+ vp.top = 0;
+ vp.jump_to_prev_heading(&doc, 0);
+ assert_eq!(vp.top, 0);
+ }
+
#[test]
fn heading_jump_no_op_when_nothing_in_direction() {
use crate::layout::{HeadingEntry, Line, LineKind, Span, Style};
@@ -381,9 +548,10 @@ mod tests {
lines,
headings,
images: vec![],
+ metadata: None,
};
let mut vp = Viewport::new(3, 40);
- vp.ensure_wrap(&doc);
+ vp.ensure_wrap(&doc, false, false);
vp.top = 3;
// No heading after visual line 3 — expect top unchanged.
@@ -409,9 +577,10 @@ mod tests {
}],
headings: vec![],
images: vec![],
+ metadata: None,
};
let mut vp = Viewport::new(10, 20);
- vp.ensure_wrap(&doc);
+ vp.ensure_wrap(&doc, false, false);
assert!(
vp.total_visual_lines() > 1,
"expected multiple visual lines"
@@ -431,9 +600,10 @@ mod tests {
}],
headings: vec![],
images: vec![],
+ metadata: None,
};
let mut vp = Viewport::new(10, 20);
- vp.ensure_wrap(&doc);
+ vp.ensure_wrap(&doc, false, false);
assert_eq!(vp.total_visual_lines(), 1, "table lines should not wrap");
}
@@ -452,9 +622,10 @@ mod tests {
}],
headings: vec![],
images: vec![],
+ metadata: None,
};
let mut vp = Viewport::new(10, 20);
- vp.ensure_wrap(&doc);
+ vp.ensure_wrap(&doc, false, false);
// With max width 20 cols, 24 cols should split into 2 visual lines.
assert!(
vp.total_visual_lines() >= 2,
diff --git a/tests/cli.rs b/tests/cli.rs
index 6f9e77b..ef48426 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -91,12 +91,21 @@ struct TempMarkdownFile {
impl TempMarkdownFile {
fn new(contents: &str) -> Self {
+ // `nanos()` alone can collide between two parallel test threads on
+ // platforms with coarse-grained clock resolution; a monotonic counter
+ // on top guarantees uniqueness within the process.
+ static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should move forward")
.as_nanos();
- let path =
- std::env::temp_dir().join(format!("termdown-cli-{}-{}.md", std::process::id(), unique));
+ let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let path = std::env::temp_dir().join(format!(
+ "termdown-cli-{}-{}-{}.md",
+ std::process::id(),
+ unique,
+ seq
+ ));
fs::write(&path, contents).expect("failed to write temp markdown file");
Self { path }
diff --git a/tests/snapshots.rs b/tests/snapshots.rs
index 8125426..84f6156 100644
--- a/tests/snapshots.rs
+++ b/tests/snapshots.rs
@@ -49,7 +49,8 @@ fn check_snapshot(fixture: &str) {
let expected = fs::read_to_string(&expected_path).expect("expected file");
let actual = render(&md);
if actual != expected {
- let tmp = std::env::temp_dir().join(format!("termdown-snapshot-{fixture}.ansi"));
+ let safe = fixture.replace('/', "-");
+ let tmp = std::env::temp_dir().join(format!("termdown-snapshot-{safe}.ansi"));
fs::write(&tmp, &actual).expect("failed to write snapshot diff to temp file");
panic!(
"snapshot mismatch for {fixture}\n expected: {}\n actual written to: {}",
@@ -67,3 +68,19 @@ fn snapshot_supported_syntax() {
fn snapshot_unsupported_syntax() {
check_snapshot("unsupported-syntax");
}
+#[test]
+fn snapshot_metadata_yaml() {
+ check_snapshot("specialized/metadata-yaml");
+}
+#[test]
+fn snapshot_metadata_toml() {
+ check_snapshot("specialized/metadata-toml");
+}
+#[test]
+fn snapshot_metadata_malformed() {
+ check_snapshot("specialized/metadata-malformed");
+}
+#[test]
+fn snapshot_metadata_none() {
+ check_snapshot("specialized/metadata-none");
+}