diff --git a/CHANGELOG.md b/CHANGELOG.md
index a33a9e3..cdb33bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
diff --git a/CONTEXT.md b/CONTEXT.md
index 4255325..21a430c 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -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
diff --git a/config.example.toml b/config.example.toml
index 858cf7f..effc011 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -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
diff --git a/docs/MARKDOWN_FEATURE_COVERAGE.md b/docs/MARKDOWN_FEATURE_COVERAGE.md
index cc69197..42ffa27 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 `~/.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
diff --git a/docs/adr/0001-metadata-block-handling.md b/docs/adr/0001-metadata-block-handling.md
index 66c2d80..a901d69 100644
--- a/docs/adr/0001-metadata-block-handling.md
+++ b/docs/adr/0001-metadata-block-handling.md
@@ -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` 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.
diff --git a/src/cat.rs b/src/cat.rs
index 282b298..97b48e2 100644
--- a/src/cat.rs
+++ b/src/cat.rs
@@ -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);
diff --git a/src/config.rs b/src/config.rs
index cd6f9f1..743ddd6 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -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,
+ /// 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,
/// 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
@@ -17,40 +19,49 @@ pub struct Config {
/// `--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.
+ /// 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,
}
-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 {
+ 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,
@@ -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::("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::("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());
+ }
}
diff --git a/src/layout.rs b/src/layout.rs
index fd3c908..6f75710 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -13,7 +13,7 @@ pub struct RenderedDoc {
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
+ /// regardless of [`config.metadata`]; renderers consult this field
/// directly and decide how (or whether) to display it.
pub metadata: Option,
}
@@ -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);
diff --git a/src/main.rs b/src/main.rs
index 9c7ce68..0d4371e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -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};
@@ -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::() {
+ 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 = {
@@ -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, config: Option) -> theme::Theme {
+ match cli.or(config).unwrap_or(ThemeChoice::Auto) {
+ ThemeChoice::Dark => theme::Theme::Dark,
+ ThemeChoice::Light => theme::Theme::Light,
+ ThemeChoice::Auto => theme::detect(),
}
}
diff --git a/src/tui/mod.rs b/src/tui/mod.rs
index 6ba5fd0..084ec26 100644
--- a/src/tui/mod.rs
+++ b/src/tui/mod.rs
@@ -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,
}
@@ -295,7 +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 show_metadata = app.config.metadata.unwrap_or(true);
{
let active = app.active_mut();
if active.viewport.width != body_width || active.viewport.height != body_height {
@@ -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;
diff --git a/src/tui/viewport.rs b/src/tui/viewport.rs
index 5410f17..b7c030a 100644
--- a/src/tui/viewport.rs
+++ b/src/tui/viewport.rs
@@ -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
diff --git a/tests/cli.rs b/tests/cli.rs
index f7d08eb..86096cc 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -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,