Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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`
summary for visual separation. Opt out entirely via `metadata = false`
in `~/.config/termdown/config.toml`. See `docs/adr/0001-metadata-block-handling.md`.

### Changed
Expand All @@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the old settings aren't dropped silently — move the file to the new location
to clear it. A documented `config.example.toml` with every default ships in
the repo root.
- **Config parsing is now strict.** An unknown key (e.g. a typo like `bel` for
`bell`) or an invalid `theme` value is reported as a one-line warning and the
config falls back to defaults, instead of being silently ignored. `--theme`
likewise warns on an unrecognized value rather than quietly auto-detecting.

## [0.5.1] - 2026-05-26

Expand Down
13 changes: 7 additions & 6 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ The two display states for a metadata block in TUI mode:

Cat mode has no "expanded" state — only the one-line summary or nothing.

### `[metadata] show`
The single config knob (in `~/.config/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.
### `metadata`
The single top-level config knob (in `~/.config/termdown/config.toml`)
controlling whether frontmatter is visible at all. `metadata = true` (the
default, and the behavior when the key is absent) renders the [[metadata
one-line summary]] / expanded box; `metadata = false` hides the metadata block
in **both** cat and TUI. The pulldown-cmark metadata extensions are always
enabled internally regardless — it only gates rendering, never parsing.
See [[adr-0001-metadata-block-handling]].

### Heuristic parser
Expand Down
3 changes: 1 addition & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,11 @@ theme = "auto"
# CLI override: --no-bell.
bell = true

[metadata]
# Render YAML (`---`) / TOML (`+++`) frontmatter metadata blocks. When true
# (default), --cat and the TUI show a dim one-line summary, and the TUI `m`
# key expands it to a key/value box. When false, metadata is hidden entirely
# (it is still parsed, so it never leaks into body content).
show = true
metadata = true

[font.heading]
# Fonts for image-rendered headings (H1–H3). When unset, termdown uses
Expand Down
2 changes: 1 addition & 1 deletion docs/MARKDOWN_FEATURE_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; `<br/>` / `<hr/>` 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 `~/.config/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 = false` in `~/.config/termdown/config.toml`. |

## Enabled GFM extensions

Expand Down
13 changes: 13 additions & 0 deletions docs/adr/0001-metadata-block-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,16 @@ as body content and not as completely invisible noise:
and the `## Configuration` section of the README.
- "Use `title` field in TUI title bar" — sketched as a future enhancement,
not committed.

## Amendment — 2026-05-29

The config gate (decision #6) was originally implemented as a nested table
`[metadata] show = true`. Before the feature shipped in a release, it was
flattened to a top-level `metadata = true` boolean. Rationale: a single on/off
toggle does not earn its own `[section]`, and the nested form was inconsistent
with the sibling top-level switches (`theme`, `bell`) — the ADR itself rejected
extra metadata knobs as YAGNI, which undercut the only justification for the
table. Implemented as `Option<bool>` exactly like `bell`: `None` (key absent)
and `Some(true)` render, `Some(false)` hides. If functional frontmatter
features (e.g. piping `title` to the title bar) ever land, the knob can be
re-promoted to a `[metadata]` table at that point.
2 changes: 1 addition & 1 deletion src/cat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pub fn print(doc: &RenderedDoc, term_width: usize, colors: &Colors, config: &Con
let stdout = std::io::stdout();
let mut out = BufWriter::new(stdout.lock());

if config.metadata.show {
if config.metadata.unwrap_or(true) {
if let Some(meta) = &doc.metadata {
write_metadata_oneline(&mut out, meta, term_width);
let _ = writeln!(&mut out);
Expand Down
91 changes: 68 additions & 23 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ use std::path::{Path, PathBuf};
use serde::Deserialize;

#[derive(Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub font: FontSection,

/// Theme override: "dark", "light", or "auto" (default).
pub theme: Option<String>,
/// Color theme. `None` (key absent) means `Auto`. An unrecognized value is
/// a hard parse error surfaced by `load`, not a silent fallback to auto.
pub theme: Option<ThemeChoice>,

/// Vim-style edge bell: emit a terminal BEL when the user tries to scroll
/// past the top or bottom of the document. The terminal emulator decides
Expand All @@ -17,40 +19,49 @@ pub struct Config {
/// `--no-bell` overrides to `Some(false)`.
pub bell: Option<bool>,

#[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.
/// Mirrors `bell`: `None` (key absent) and `Some(true)` both render — the
/// one-line summary in cat / TUI-folded, with the TUI `m` key to expand.
/// `Some(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,
pub metadata: Option<bool>,
}

fn default_metadata_show() -> bool {
true
/// Color theme selection. `Auto` (the default when the key is absent) detects
/// the terminal background via OSC 11; `Dark` / `Light` force a palette.
#[derive(Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum ThemeChoice {
Auto,
Dark,
Light,
}

impl Default for MetadataSection {
fn default() -> Self {
Self {
show: default_metadata_show(),
impl std::str::FromStr for ThemeChoice {
type Err = ();

/// Parse a CLI `--theme` value. Mirrors the serde `rename_all = "lowercase"`
/// mapping so the flag and the config file accept exactly the same names.
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"auto" => Ok(Self::Auto),
"dark" => Ok(Self::Dark),
"light" => Ok(Self::Light),
_ => Err(()),
}
}
}

#[derive(Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)]
pub struct FontSection {
#[serde(default)]
pub heading: HeadingFontConfig,
}

#[derive(Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)]
pub struct HeadingFontConfig {
/// Font for Latin / ASCII text (sans-serif recommended, e.g. "Inter").
pub latin: Option<String>,
Expand Down Expand Up @@ -125,14 +136,48 @@ mod tests {
toml::from_str(example).expect("config.example.toml must remain valid TOML");

// The example spells out the *effective* defaults explicitly.
assert_eq!(parsed.theme.as_deref(), Some("auto"));
assert_eq!(parsed.theme, Some(ThemeChoice::Auto));
assert_eq!(parsed.bell, Some(true));
// `metadata.show` is a real struct default — assert the example tracks it.
assert_eq!(parsed.metadata.show, Config::default().metadata.show);
assert!(parsed.metadata.show);
// `metadata` mirrors `bell`: the example spells out the effective
// default explicitly as `Some(true)` (a missing key parses as `None`,
// which is also treated as "show").
assert_eq!(parsed.metadata, Some(true));
// Font overrides are commented out, so they must parse as unset.
assert!(parsed.font.heading.latin.is_none());
assert!(parsed.font.heading.cjk.is_none());
assert!(parsed.font.heading.emoji.is_none());
}

/// A valid `theme` deserializes to the matching enum; the field is optional.
#[test]
fn theme_parses_known_values_and_defaults_to_none() {
let parsed: Config = toml::from_str("theme = \"dark\"").unwrap();
assert_eq!(parsed.theme, Some(ThemeChoice::Dark));
assert_eq!(Config::default().theme, None);
}

/// An unrecognized `theme` is a hard error, not a silent fallback to auto —
/// `load` turns this into a one-line warning instead of dropping it.
#[test]
fn invalid_theme_value_is_rejected() {
assert!(toml::from_str::<Config>("theme = \"drak\"").is_err());
}

/// `deny_unknown_fields` catches typo'd keys (e.g. `bel` for `bell`) rather
/// than silently ignoring them.
#[test]
fn unknown_top_level_key_is_rejected() {
assert!(toml::from_str::<Config>("bel = false").is_err());
}

/// The CLI `--theme` parser accepts exactly the config file's value set.
#[test]
fn theme_choice_from_str_matches_serde_names() {
use std::str::FromStr;
assert_eq!(ThemeChoice::from_str("auto"), Ok(ThemeChoice::Auto));
assert_eq!(ThemeChoice::from_str("dark"), Ok(ThemeChoice::Dark));
assert_eq!(ThemeChoice::from_str("light"), Ok(ThemeChoice::Light));
assert!(ThemeChoice::from_str("Dark").is_err());
assert!(ThemeChoice::from_str("blue").is_err());
}
}
4 changes: 2 additions & 2 deletions src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub struct RenderedDoc {
pub images: Vec<HeadingImage>,
/// 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
/// regardless of [`config.metadata`]; renderers consult this field
/// directly and decide how (or whether) to display it.
pub metadata: Option<MetadataInfo>,
}
Expand Down Expand Up @@ -101,7 +101,7 @@ pub fn build(md: &str, config: &Config, theme: Theme) -> RenderedDoc {
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.
// The `metadata` 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);
Expand Down
35 changes: 23 additions & 12 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ mod tui;
use std::fs;
use std::io::{self, Read};

use crate::config::ThemeChoice;

use crossterm::tty::IsTty;
use terminal_size::{terminal_size, Width};

Expand Down Expand Up @@ -56,14 +58,24 @@ fn main() {
config.bell = Some(false);
}

// Parse --theme flag (takes precedence over config).
let cli_theme = args
.windows(2)
.find(|w| w[0] == "--theme")
.map(|w| w[1].clone());
// Parse --theme flag (takes precedence over config). An unrecognized value
// warns and falls through to config/auto instead of being silently
// swallowed, matching how the config file reports an invalid theme.
let cli_theme = args.windows(2).find(|w| w[0] == "--theme").and_then(|w| {
match w[1].parse::<ThemeChoice>() {
Ok(choice) => Some(choice),
Err(()) => {
eprintln!(
"Warning: ignoring invalid --theme value {:?}; expected auto, dark, or light",
w[1]
);
None
}
}
});

// Resolve theme: CLI flag > config file > auto-detect.
let theme = resolve_theme(cli_theme.as_deref(), config.theme.as_deref());
let theme = resolve_theme(cli_theme, config.theme);

// Collect file arg, skipping --theme and its value.
let file_arg = {
Expand Down Expand Up @@ -187,11 +199,10 @@ fn restore_termios(saved: &libc::termios) {
}
}

fn resolve_theme(cli: Option<&str>, config: Option<&str>) -> theme::Theme {
let value = cli.or(config).unwrap_or("auto");
match value {
"dark" => theme::Theme::Dark,
"light" => theme::Theme::Light,
_ => theme::detect(),
fn resolve_theme(cli: Option<ThemeChoice>, config: Option<ThemeChoice>) -> theme::Theme {
match cli.or(config).unwrap_or(ThemeChoice::Auto) {
ThemeChoice::Dark => theme::Theme::Dark,
ThemeChoice::Light => theme::Theme::Light,
ThemeChoice::Auto => theme::detect(),
}
}
6 changes: 3 additions & 3 deletions src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ struct DocEntry {
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.
/// `m` key. Has no effect when `config.metadata` is `Some(false)`.
metadata_expanded: bool,
}

Expand Down Expand Up @@ -295,7 +295,7 @@ fn event_loop<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> io::Resu
size.width
};
app.term_size = (size.width, body_height);
let show_metadata = app.config.metadata.show;
let show_metadata = app.config.metadata.unwrap_or(true);
{
let active = app.active_mut();
if active.viewport.width != body_width || active.viewport.height != body_height {
Expand Down Expand Up @@ -469,7 +469,7 @@ fn handle_normal_key(app: &mut App, ev: &Event) -> io::Result<()> {
}
// 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 => {
input::Action::ToggleMetadata if app.config.metadata.unwrap_or(true) => {
let active = app.active_mut();
if active.doc.metadata.is_some() {
active.metadata_expanded = !active.metadata_expanded;
Expand Down
2 changes: 1 addition & 1 deletion src/tui/viewport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ impl Viewport {

/// (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.
/// from `Config.metadata` 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
Expand Down
4 changes: 2 additions & 2 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,9 +347,9 @@ fn xdg_config_home_is_honored_for_config_loading() {
);

// A `config.toml` placed at `$XDG_CONFIG_HOME/termdown/` must take effect:
// `show = false` hides the summary the baseline rendered.
// `metadata = false` hides the summary the baseline rendered.
let cfg = TempDir::new();
cfg.write("termdown/config.toml", "[metadata]\nshow = false\n");
cfg.write("termdown/config.toml", "metadata = false\n");
let configured = run_termdown(
&["--cat", path],
None,
Expand Down
Loading