From 6201b1e36df028e3f7cc281a6cec8fb262259e3b Mon Sep 17 00:00:00 2001 From: shawn Date: Thu, 28 May 2026 23:13:07 +0800 Subject: [PATCH 1/4] feat: parse and render markdown frontmatter metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YAML (---) and TOML (+++) frontmatter blocks at the top of a document are now parsed via pulldown-cmark's metadata extensions and never leak into body content. cat mode renders a single dim line: · metadata · title=…, author=…, tags=[…], … TUI mode shows the same line by default and adds a new `m` key to expand it into an inline key/value box. Disable entirely via `[metadata] show = false` in ~/.termdown/config.toml. Field extraction uses a line-based heuristic (no real YAML/TOML parser); see docs/adr/0001-metadata-block-handling.md for the rationale and rejected alternatives. Drive-by: fix flaky TempMarkdownFile uniqueness in tests/cli.rs by adding a process-local atomic counter — clock granularity alone collided once test parallelism grew. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 11 + CONTEXT.md | 51 +++++ TODO.md | 2 +- docs/MARKDOWN_FEATURE_COVERAGE.md | 4 +- docs/adr/0001-metadata-block-handling.md | 133 +++++++++++ .../specialized/metadata-malformed.ansi | 6 + .../expected/specialized/metadata-none.ansi | 11 + .../expected/specialized/metadata-toml.ansi | 4 + .../expected/specialized/metadata-yaml.ansi | 4 + fixtures/expected/supported-syntax.ansi | 10 +- fixtures/specialized/metadata-malformed.md | 15 ++ fixtures/specialized/metadata-none.md | 11 + fixtures/specialized/metadata-toml.md | 9 + fixtures/specialized/metadata-yaml.md | 9 + fixtures/supported-syntax.md | 5 +- src/cat.rs | 43 +++- src/config.rs | 26 +++ src/frontmatter.rs | 216 ++++++++++++++++++ src/layout.rs | 29 +++ src/main.rs | 3 +- src/tui/input.rs | 3 + src/tui/mod.rs | 138 ++++++++++- src/tui/search.rs | 1 + src/tui/viewport.rs | 128 ++++++++++- tests/cli.rs | 13 +- tests/snapshots.rs | 16 ++ 26 files changed, 870 insertions(+), 31 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-metadata-block-handling.md create mode 100644 fixtures/expected/specialized/metadata-malformed.ansi create mode 100644 fixtures/expected/specialized/metadata-none.ansi create mode 100644 fixtures/expected/specialized/metadata-toml.ansi create mode 100644 fixtures/expected/specialized/metadata-yaml.ansi create mode 100644 fixtures/specialized/metadata-malformed.md create mode 100644 fixtures/specialized/metadata-none.md create mode 100644 fixtures/specialized/metadata-toml.md create mode 100644 fixtures/specialized/metadata-yaml.md create mode 100644 src/frontmatter.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb9fb4..0959ae3 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. 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..7ca27a9 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,51 @@ +# 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, …` truncated to terminal width with +`…`. Identical in both `--cat` and TUI **folded** state. + +### 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..7505038 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..fca6597 --- /dev/null +++ b/docs/adr/0001-metadata-block-handling.md @@ -0,0 +1,133 @@ +# 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 prefixed with + `· metadata · `, truncated to terminal width with `…`. 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..ad8c3cc --- /dev/null +++ b/fixtures/expected/specialized/metadata-malformed.ansi @@ -0,0 +1,6 @@ +· metadata · description=>, key1=value1, key2=value2, title=Malformed Sample + +Body. The frontmatter above mixes a folded scalar and a nested mapping — +shapes that the line-based heuristic cannot fully parse. It should still +extract  description  and  title  while skipping the continuation lines, +and the document should not break. 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  ---  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/expected/specialized/metadata-toml.ansi b/fixtures/expected/specialized/metadata-toml.ansi new file mode 100644 index 0000000..b391c80 --- /dev/null +++ b/fixtures/expected/specialized/metadata-toml.ansi @@ -0,0 +1,4 @@ +· metadata · title=TOML Frontmatter Example, author=shawn, description=Standard… + +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..121f461 --- /dev/null +++ b/fixtures/expected/specialized/metadata-yaml.ansi @@ -0,0 +1,4 @@ +· metadata · title=YAML Frontmatter Example, author=shawn, description=Standard… + +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..b1e00d5 100644 --- a/fixtures/expected/supported-syntax.ansi +++ b/fixtures/expected/supported-syntax.ansi @@ -1,6 +1,4 @@ -──────────────────────────────────────────────────────────── - - +· metadata · title=Supported Syntax Showcase, author=termdown, tags=[markdown, … @@ -10,9 +8,9 @@ or has explicitly committed to supporting (see  TODO.md [ 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. diff --git a/fixtures/specialized/metadata-malformed.md b/fixtures/specialized/metadata-malformed.md new file mode 100644 index 0000000..c895c0b --- /dev/null +++ b/fixtures/specialized/metadata-malformed.md @@ -0,0 +1,15 @@ +--- +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 that the line-based heuristic cannot fully parse. It should still +extract `description` and `title` while skipping the continuation lines, +and the document should not break. 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..23a8196 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,38 @@ 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. +pub fn write_metadata_oneline(out: &mut W, meta: &MetadataInfo, term_width: usize) { + let prefix = "· metadata · "; + let body = frontmatter::format_pairs_inline(meta); + let body = truncate_to_width(&body, term_width.saturating_sub(display_width(prefix))); + let _ = writeln!(out, "{DIM_ON}{prefix}{body}{RESET}"); +} + +fn truncate_to_width(s: &str, max_cols: usize) -> String { + if max_cols == 0 { + return String::new(); + } + if display_width(s) <= max_cols { + return s.to_string(); + } + let ellipsis = "…"; + let budget = max_cols.saturating_sub(display_width(ellipsis)); + let mut acc = String::new(); + let mut width = 0; + for ch in s.chars() { + let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if width + cw > budget { + break; + } + acc.push(ch); + width += cw; + } + acc.push_str(ellipsis); + acc +} + /// 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..af21785 --- /dev/null +++ b/src/frontmatter.rs @@ -0,0 +1,216 @@ +//! 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; + +#[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 value = trimmed[idx + sep.len_utf8()..].trim(); + if key.is_empty() || value.is_empty() { + continue; + } + // Strip surrounding quotes from TOML values (and tolerated for YAML). + let value = strip_quotes(value); + // Reject keys that contain whitespace — usually means the line was a + // continuation of a multi-line value rather than its own field. + if key.chars().any(char::is_whitespace) { + 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() + } +} + +#[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"); + } +} 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..5c98740 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,14 @@ 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; } + input::Action::ToggleMetadata => { + 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 +753,98 @@ 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 => { + let prefix = "· metadata · "; + let body = crate::frontmatter::format_pairs_inline(meta); + let text = truncate_to_cols(&format!("{prefix}{body}"), 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) => { + 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()) + }; + // Right-pad key column to the longest key (capped) so values align. + let key_col = meta + .pairs + .iter() + .map(|(k, _)| k.chars().count()) + .max() + .unwrap_or(0) + .min(field_budget.saturating_sub(3)); + let key_pad = key_col.saturating_sub(k_text.chars().count()); + let line_body = format!("{}{}: ", k_text, " ".repeat(key_pad)); + let val_budget = field_budget.saturating_sub(line_body.chars().count()); + let value = truncate_to_cols(&v_text, val_budget); + let inside = format!("{line_body}{value}"); + let pad = field_budget.saturating_sub(inside.chars().count()); + 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,6 +965,20 @@ fn draw(frame: &mut ratatui::Frame, app: &App) { let mut rendered: Vec = Vec::new(); for vl in active.viewport.visible() { + 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; + } + let logical = &active.doc.lines[vl.logical_index]; // Heading spacer VisualLines reserve the rows below the main heading @@ -966,7 +1089,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 +1485,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..d9250a6 100644 --- a/src/tui/viewport.rs +++ b/src/tui/viewport.rs @@ -16,6 +16,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 meaningless 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 +40,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 +52,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 +89,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); @@ -123,6 +165,55 @@ 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 { + if !expanded { + return vec![VisualLine { + logical_index: 0, + byte_start: 0, + byte_end: 0, + is_spacer: false, + metadata_row: Some(MetadataVisualRow::Folded), + }]; + } + let row_count = if meta.has_pairs() { + meta.pairs.len() + } else { + 1 + }; + let mut out = Vec::with_capacity(row_count + 2); + out.push(VisualLine { + logical_index: 0, + byte_start: 0, + byte_end: 0, + is_spacer: false, + metadata_row: Some(MetadataVisualRow::ExpandedTop), + }); + for i in 0..row_count { + out.push(VisualLine { + logical_index: 0, + byte_start: 0, + byte_end: 0, + is_spacer: false, + metadata_row: Some(MetadataVisualRow::ExpandedField(i)), + }); + } + out.push(VisualLine { + logical_index: 0, + byte_start: 0, + byte_end: 0, + is_spacer: false, + metadata_row: Some(MetadataVisualRow::ExpandedBottom), + }); + out +} + fn wrap_all(lines: &[Line], width: u16) -> Vec { use crate::layout::LineKind; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -144,6 +235,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 +243,7 @@ fn wrap_all(lines: &[Line], width: u16) -> Vec { byte_start: 0, byte_end: 0, is_spacer: true, + metadata_row: None, }); } continue; @@ -162,6 +255,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 +281,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 +300,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 +315,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 +324,7 @@ fn wrap_all(lines: &[Line], width: u16) -> Vec { byte_start: 0, byte_end: 0, is_spacer: false, + metadata_row: None, }); } } @@ -276,6 +374,7 @@ mod tests { lines, headings: vec![], images: vec![], + metadata: None, } } @@ -283,7 +382,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 +400,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 +409,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 +445,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); @@ -381,9 +481,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 +510,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 +533,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 +555,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..265144b 100644 --- a/tests/snapshots.rs +++ b/tests/snapshots.rs @@ -67,3 +67,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"); +} From b82b0f9e3f912714bf67ee3d7134483b247ade27 Mon Sep 17 00:00:00 2001 From: shawn Date: Thu, 28 May 2026 23:43:13 +0800 Subject: [PATCH 2/4] feat(metadata): bracket-wrap the summary and add a trailing blank row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folded one-line summary becomes: [metadata · key=value, key=value, …] Truncation now preserves the closing `]` after the ellipsis (`…]`), so the chip never looks unterminated. Both --cat and TUI folded state use the same string. After the metadata block (folded or expanded), emit one blank row so the body element below isn't visually crammed against the chip / inline box. Drive-by: tests/snapshots.rs check_snapshot now sanitizes `/` out of fixture names when composing the temp-file path, so specialized/ fixtures can write their actual output for diffing. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 8 +-- CONTEXT.md | 6 ++- docs/MARKDOWN_FEATURE_COVERAGE.md | 2 +- docs/adr/0001-metadata-block-handling.md | 7 +-- .../specialized/metadata-malformed.ansi | 2 +- .../expected/specialized/metadata-toml.ansi | 2 +- .../expected/specialized/metadata-yaml.ansi | 2 +- fixtures/expected/supported-syntax.ansi | 2 +- src/cat.rs | 18 ++++--- src/tui/mod.rs | 30 ++++++++++- src/tui/viewport.rs | 50 ++++++++++++------- tests/snapshots.rs | 3 +- 12 files changed, 90 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0959ae3..de1f4ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. Opt out entirely via - `[metadata] show = false` in `~/.termdown/config.toml`. See - `docs/adr/0001-metadata-block-handling.md`. + (`[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 diff --git a/CONTEXT.md b/CONTEXT.md index 7ca27a9..218f47b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -22,8 +22,10 @@ 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, …` truncated to terminal width with -`…`. Identical in both `--cat` and TUI **folded** state. +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: diff --git a/docs/MARKDOWN_FEATURE_COVERAGE.md b/docs/MARKDOWN_FEATURE_COVERAGE.md index 7505038..9ea2753 100644 --- a/docs/MARKDOWN_FEATURE_COVERAGE.md +++ b/docs/MARKDOWN_FEATURE_COVERAGE.md @@ -18,7 +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`. | +| 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 diff --git a/docs/adr/0001-metadata-block-handling.md b/docs/adr/0001-metadata-block-handling.md index fca6597..26d3707 100644 --- a/docs/adr/0001-metadata-block-handling.md +++ b/docs/adr/0001-metadata-block-handling.md @@ -29,9 +29,10 @@ as body content and not as completely invisible noise: `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 prefixed with - `· metadata · `, truncated to terminal width with `…`. No real YAML/TOML - parser is introduced. + `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). diff --git a/fixtures/expected/specialized/metadata-malformed.ansi b/fixtures/expected/specialized/metadata-malformed.ansi index ad8c3cc..e0418d6 100644 --- a/fixtures/expected/specialized/metadata-malformed.ansi +++ b/fixtures/expected/specialized/metadata-malformed.ansi @@ -1,4 +1,4 @@ -· metadata · description=>, key1=value1, key2=value2, title=Malformed Sample +[metadata · description=>, key1=value1, key2=value2, title=Malformed Sample] Body. The frontmatter above mixes a folded scalar and a nested mapping — shapes that the line-based heuristic cannot fully parse. It should still diff --git a/fixtures/expected/specialized/metadata-toml.ansi b/fixtures/expected/specialized/metadata-toml.ansi index b391c80..86cebe8 100644 --- a/fixtures/expected/specialized/metadata-toml.ansi +++ b/fixtures/expected/specialized/metadata-toml.ansi @@ -1,4 +1,4 @@ -· metadata · title=TOML Frontmatter Example, author=shawn, description=Standard… +[metadata · title=TOML Frontmatter Example, author=shawn, description=Standard…] 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 index 121f461..898d6b7 100644 --- a/fixtures/expected/specialized/metadata-yaml.ansi +++ b/fixtures/expected/specialized/metadata-yaml.ansi @@ -1,4 +1,4 @@ -· metadata · title=YAML Frontmatter Example, author=shawn, description=Standard… +[metadata · title=YAML Frontmatter Example, author=shawn, description=Standard…] 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 b1e00d5..e2a26cd 100644 --- a/fixtures/expected/supported-syntax.ansi +++ b/fixtures/expected/supported-syntax.ansi @@ -1,4 +1,4 @@ -· metadata · title=Supported Syntax Showcase, author=termdown, tags=[markdown, … +[metadata · title=Supported Syntax Showcase, author=termdown, tags=[markdown, …] diff --git a/src/cat.rs b/src/cat.rs index 23a8196..e230c1d 100644 --- a/src/cat.rs +++ b/src/cat.rs @@ -97,23 +97,28 @@ 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. +/// 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 prefix = "· metadata · "; let body = frontmatter::format_pairs_inline(meta); - let body = truncate_to_width(&body, term_width.saturating_sub(display_width(prefix))); - let _ = writeln!(out, "{DIM_ON}{prefix}{body}{RESET}"); + let full = format!("[metadata · {body}]"); + let text = truncate_keep_suffix(&full, term_width, "]"); + let _ = writeln!(out, "{DIM_ON}{text}{RESET}"); } -fn truncate_to_width(s: &str, max_cols: usize) -> String { +/// Truncate `s` to fit in `max_cols`, appending `…` and preserving the +/// literal `suffix` (e.g. `"]"`) at the end when truncation is needed. +/// When `s` already fits, returned as-is. +fn truncate_keep_suffix(s: &str, max_cols: usize, suffix: &str) -> String { if max_cols == 0 { return String::new(); } if display_width(s) <= max_cols { return s.to_string(); } + let suffix_w = display_width(suffix); let ellipsis = "…"; - let budget = max_cols.saturating_sub(display_width(ellipsis)); + let budget = max_cols.saturating_sub(display_width(ellipsis) + suffix_w); let mut acc = String::new(); let mut width = 0; for ch in s.chars() { @@ -125,6 +130,7 @@ fn truncate_to_width(s: &str, max_cols: usize) -> String { width += cw; } acc.push_str(ellipsis); + acc.push_str(suffix); acc } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5c98740..3540a89 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -767,9 +767,9 @@ fn render_metadata_row( match role { viewport::MetadataVisualRow::Folded => { - let prefix = "· metadata · "; let body = crate::frontmatter::format_pairs_inline(meta); - let text = truncate_to_cols(&format!("{prefix}{body}"), body_cols); + let full = format!("[metadata · {body}]"); + let text = truncate_to_cols_keep_suffix(&full, body_cols, "]"); RLine::from(RSpan::styled(text, dim)) } viewport::MetadataVisualRow::ExpandedTop => { @@ -845,6 +845,32 @@ fn truncate_to_cols(s: &str, max_cols: usize) -> String { acc } +/// Like `truncate_to_cols` but, when truncation happens, preserves a literal +/// `suffix` at the very end. Used to keep the closing `]` on the folded +/// metadata chip visible after the ellipsis. +fn truncate_to_cols_keep_suffix(s: &str, max_cols: usize, suffix: &str) -> String { + use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + if UnicodeWidthStr::width(s) <= max_cols { + return s.to_string(); + } + let suffix_w = UnicodeWidthStr::width(suffix); + // Reserve 1 col for `…` + `suffix_w` for the kept tail. + let budget = max_cols.saturating_sub(suffix_w + 1); + 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 > budget { + break; + } + acc.push(ch); + width += cw; + } + acc.push('…'); + acc.push_str(suffix); + acc +} + fn clipped_spans( line: &layout::Line, byte_start: usize, diff --git a/src/tui/viewport.rs b/src/tui/viewport.rs index d9250a6..738a4ec 100644 --- a/src/tui/viewport.rs +++ b/src/tui/viewport.rs @@ -173,43 +173,55 @@ fn metadata_visual_lines( meta: &crate::frontmatter::MetadataInfo, expanded: bool, ) -> Vec { + let mut out = Vec::new(); if !expanded { - return vec![VisualLine { + out.push(VisualLine { logical_index: 0, byte_start: 0, byte_end: 0, is_spacer: false, metadata_row: Some(MetadataVisualRow::Folded), - }]; - } - let row_count = if meta.has_pairs() { - meta.pairs.len() + }); } else { - 1 - }; - let mut out = Vec::with_capacity(row_count + 2); - out.push(VisualLine { - logical_index: 0, - byte_start: 0, - byte_end: 0, - is_spacer: false, - metadata_row: Some(MetadataVisualRow::ExpandedTop), - }); - for i in 0..row_count { + let row_count = if meta.has_pairs() { + meta.pairs.len() + } else { + 1 + }; + out.reserve(row_count + 2); + out.push(VisualLine { + logical_index: 0, + byte_start: 0, + byte_end: 0, + is_spacer: false, + metadata_row: Some(MetadataVisualRow::ExpandedTop), + }); + for i in 0..row_count { + out.push(VisualLine { + logical_index: 0, + byte_start: 0, + byte_end: 0, + is_spacer: false, + metadata_row: Some(MetadataVisualRow::ExpandedField(i)), + }); + } out.push(VisualLine { logical_index: 0, byte_start: 0, byte_end: 0, is_spacer: false, - metadata_row: Some(MetadataVisualRow::ExpandedField(i)), + metadata_row: Some(MetadataVisualRow::ExpandedBottom), }); } + // 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(VisualLine { logical_index: 0, byte_start: 0, byte_end: 0, - is_spacer: false, - metadata_row: Some(MetadataVisualRow::ExpandedBottom), + is_spacer: true, + metadata_row: None, }); out } diff --git a/tests/snapshots.rs b/tests/snapshots.rs index 265144b..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: {}", From 838b661a4df8adcd9e80854ab2f95db79b63239c Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 29 May 2026 00:21:25 +0800 Subject: [PATCH 3/4] fix(ci): match fixture paths in subdirectories for line-ending rules The `fixtures/expected/*.ansi` and `fixtures/*.md` patterns only matched top-level files, so the new `fixtures/expected/specialized/*.ansi` snapshots were checked out with CRLF on Windows and the snapshot tests failed. Recurse with `**`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitattributes | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From f537f40c69394282665ec7b20d9792f0227b8224 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 29 May 2026 09:37:34 +0800 Subject: [PATCH 4/4] fix(metadata): harden frontmatter rendering per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings on PR #13: - Use a sentinel logical_index (NO_LOGICAL) for metadata visual rows so search-jump and heading navigation no longer mis-resolve a real line 0 to a metadata row; guard ToC highlight and reorder draw()'s is_spacer check so a metadata-only doc can't panic on doc.lines[0]. - Share the folded `[metadata · …]` summary + width-correct truncation via frontmatter::folded_summary so cat and TUI folded state can't drift. - Compute the expanded-box layout in display columns (not char counts) so CJK/wide chars keep the border aligned. - Make `m` a true no-op when `[metadata] show = false`. - Skip empty quoted values (`key: ""`) so they don't render as `key=`. - Correct the malformed fixture's prose: nested keys are lifted to top-level, not skipped; regenerate its golden snapshot. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specialized/metadata-malformed.ansi | 8 +- fixtures/specialized/metadata-malformed.md | 8 +- src/cat.rs | 32 +--- src/frontmatter.rs | 80 +++++++++- src/tui/mod.rs | 68 ++++---- src/tui/viewport.rs | 147 ++++++++++++------ 6 files changed, 214 insertions(+), 129 deletions(-) diff --git a/fixtures/expected/specialized/metadata-malformed.ansi b/fixtures/expected/specialized/metadata-malformed.ansi index e0418d6..92b1bff 100644 --- a/fixtures/expected/specialized/metadata-malformed.ansi +++ b/fixtures/expected/specialized/metadata-malformed.ansi @@ -1,6 +1,8 @@ [metadata · description=>, key1=value1, key2=value2, title=Malformed Sample] Body. The frontmatter above mixes a folded scalar and a nested mapping — -shapes that the line-based heuristic cannot fully parse. It should still -extract  description  and  title  while skipping the continuation lines, -and the document should not break. +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-malformed.md b/fixtures/specialized/metadata-malformed.md index c895c0b..e038358 100644 --- a/fixtures/specialized/metadata-malformed.md +++ b/fixtures/specialized/metadata-malformed.md @@ -10,6 +10,8 @@ title: Malformed Sample --- Body. The frontmatter above mixes a folded scalar and a nested mapping — -shapes that the line-based heuristic cannot fully parse. It should still -extract `description` and `title` while skipping the continuation lines, -and the document should not break. +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/src/cat.rs b/src/cat.rs index e230c1d..282b298 100644 --- a/src/cat.rs +++ b/src/cat.rs @@ -100,40 +100,10 @@ fn write_line( /// 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 body = frontmatter::format_pairs_inline(meta); - let full = format!("[metadata · {body}]"); - let text = truncate_keep_suffix(&full, term_width, "]"); + let text = frontmatter::folded_summary(meta, term_width); let _ = writeln!(out, "{DIM_ON}{text}{RESET}"); } -/// Truncate `s` to fit in `max_cols`, appending `…` and preserving the -/// literal `suffix` (e.g. `"]"`) at the end when truncation is needed. -/// When `s` already fits, returned as-is. -fn truncate_keep_suffix(s: &str, max_cols: usize, suffix: &str) -> String { - if max_cols == 0 { - return String::new(); - } - if display_width(s) <= max_cols { - return s.to_string(); - } - let suffix_w = display_width(suffix); - let ellipsis = "…"; - let budget = max_cols.saturating_sub(display_width(ellipsis) + suffix_w); - let mut acc = String::new(); - let mut width = 0; - for ch in s.chars() { - let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); - if width + cw > budget { - break; - } - acc.push(ch); - width += cw; - } - acc.push_str(ellipsis); - acc.push_str(suffix); - acc -} - /// 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/frontmatter.rs b/src/frontmatter.rs index af21785..713ed4a 100644 --- a/src/frontmatter.rs +++ b/src/frontmatter.rs @@ -6,6 +6,7 @@ //! `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 { @@ -59,15 +60,17 @@ pub fn parse(raw: &str, kind: MetadataKind) -> MetadataInfo { continue; }; let key = trimmed[..idx].trim(); - let value = trimmed[idx + sep.len_utf8()..].trim(); - if key.is_empty() || value.is_empty() { + 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 (and tolerated for YAML). - let value = strip_quotes(value); - // Reject keys that contain whitespace — usually means the line was a - // continuation of a multi-line value rather than its own field. - if key.chars().any(char::is_whitespace) { + // 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())); @@ -113,6 +116,40 @@ pub fn format_pairs_inline(info: &MetadataInfo) -> String { } } +/// 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::*; @@ -213,4 +250,33 @@ mod tests { 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/tui/mod.rs b/src/tui/mod.rs index 3540a89..6ba5fd0 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -467,7 +467,9 @@ 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; } - input::Action::ToggleMetadata => { + // 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; @@ -767,9 +769,9 @@ fn render_metadata_row( match role { viewport::MetadataVisualRow::Folded => { - let body = crate::frontmatter::format_pairs_inline(meta); - let full = format!("[metadata · {body}]"); - let text = truncate_to_cols_keep_suffix(&full, body_cols, "]"); + // 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 => { @@ -785,6 +787,7 @@ fn render_metadata_row( 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); @@ -794,20 +797,23 @@ fn render_metadata_row( } else { ("metadata".to_string(), meta.fallback_oneline.clone()) }; - // Right-pad key column to the longest key (capped) so values align. + // 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, _)| k.chars().count()) + .map(|(k, _)| UnicodeWidthStr::width(k.as_str())) .max() .unwrap_or(0) .min(field_budget.saturating_sub(3)); - let key_pad = key_col.saturating_sub(k_text.chars().count()); + 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(line_body.chars().count()); + 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(inside.chars().count()); + let pad = field_budget.saturating_sub(UnicodeWidthStr::width(inside.as_str())); let row = format!("│ {inside}{} │", " ".repeat(pad)); RLine::from(RSpan::styled(row, dim)) } @@ -845,32 +851,6 @@ fn truncate_to_cols(s: &str, max_cols: usize) -> String { acc } -/// Like `truncate_to_cols` but, when truncation happens, preserves a literal -/// `suffix` at the very end. Used to keep the closing `]` on the folded -/// metadata chip visible after the ellipsis. -fn truncate_to_cols_keep_suffix(s: &str, max_cols: usize, suffix: &str) -> String { - use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - if UnicodeWidthStr::width(s) <= max_cols { - return s.to_string(); - } - let suffix_w = UnicodeWidthStr::width(suffix); - // Reserve 1 col for `…` + `suffix_w` for the kept tail. - let budget = max_cols.saturating_sub(suffix_w + 1); - 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 > budget { - break; - } - acc.push(ch); - width += cw; - } - acc.push('…'); - acc.push_str(suffix); - acc -} - fn clipped_spans( line: &layout::Line, byte_start: usize, @@ -1005,16 +985,18 @@ fn draw(frame: &mut ratatui::Frame, app: &App) { continue; } - let logical = &active.doc.lines[vl.logical_index]; - // 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, @@ -1051,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 diff --git a/src/tui/viewport.rs b/src/tui/viewport.rs index 738a4ec..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)] @@ -17,7 +23,7 @@ pub struct VisualLine { /// 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 meaningless for these rows — `draw()` consults + /// `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, } @@ -126,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 @@ -146,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 @@ -173,15 +182,19 @@ 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(VisualLine { - logical_index: 0, - byte_start: 0, - byte_end: 0, - is_spacer: false, - metadata_row: Some(MetadataVisualRow::Folded), - }); + out.push(row(Some(MetadataVisualRow::Folded), false)); } else { let row_count = if meta.has_pairs() { meta.pairs.len() @@ -189,40 +202,16 @@ fn metadata_visual_lines( 1 }; out.reserve(row_count + 2); - out.push(VisualLine { - logical_index: 0, - byte_start: 0, - byte_end: 0, - is_spacer: false, - metadata_row: Some(MetadataVisualRow::ExpandedTop), - }); + out.push(row(Some(MetadataVisualRow::ExpandedTop), false)); for i in 0..row_count { - out.push(VisualLine { - logical_index: 0, - byte_start: 0, - byte_end: 0, - is_spacer: false, - metadata_row: Some(MetadataVisualRow::ExpandedField(i)), - }); + out.push(row(Some(MetadataVisualRow::ExpandedField(i)), false)); } - out.push(VisualLine { - logical_index: 0, - byte_start: 0, - byte_end: 0, - is_spacer: false, - metadata_row: Some(MetadataVisualRow::ExpandedBottom), - }); + 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(VisualLine { - logical_index: 0, - byte_start: 0, - byte_end: 0, - is_spacer: true, - metadata_row: None, - }); + out.push(row(None, true)); out } @@ -472,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};