diff --git a/CHANGELOG.md b/CHANGELOG.md
index de1f4ef..a33a9e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,7 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(`[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`.
+ in `~/.config/termdown/config.toml`. See `docs/adr/0001-metadata-block-handling.md`.
+
+### Changed
+- **Config location moved to the XDG path.** termdown now reads
+ `~/.config/termdown/config.toml` (or `$XDG_CONFIG_HOME/termdown/config.toml`)
+ instead of `~/.termdown/config.toml`. If a legacy `~/.termdown/config.toml`
+ is found while the new path is empty, termdown prints a one-line warning so
+ 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.
## [0.5.1] - 2026-05-26
diff --git a/CONTEXT.md b/CONTEXT.md
index 218f47b..4255325 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -36,7 +36,7 @@ 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 `~/.termdown/config.toml`) controlling whether
+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
diff --git a/README.md b/README.md
index 81699a4..73b6d49 100644
--- a/README.md
+++ b/README.md
@@ -76,7 +76,7 @@ curl -fsSL https://raw.githubusercontent.com/rrbe/termdown/master/uninstall.sh |
```sh
rm $(which termdown)
-rm -rf ~/.termdown
+rm -rf ~/.config/termdown
```
@@ -139,7 +139,10 @@ TUI mode requires a file path; stdin input is not supported.
## Configuration
-termdown reads configuration from `~/.termdown/config.toml`.
+termdown reads configuration from `~/.config/termdown/config.toml` (or
+`$XDG_CONFIG_HOME/termdown/config.toml` if `XDG_CONFIG_HOME` is set). All
+settings are optional; see [`config.example.toml`](config.example.toml) for a
+copy-pasteable file with every default.
```toml
# Theme: "auto" (default), "dark", or "light"
diff --git a/README_CN.md b/README_CN.md
index 4e6a533..6eb9ee3 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -69,7 +69,7 @@ curl -fsSL https://raw.githubusercontent.com/rrbe/termdown/master/uninstall.sh |
```sh
rm $(which termdown)
-rm -rf ~/.termdown
+rm -rf ~/.config/termdown
```
@@ -131,7 +131,9 @@ TUI 模式需要指定文件路径,不支持从 stdin 读取。
## 配置
-配置文件位于 `~/.termdown/config.toml`。
+配置文件位于 `~/.config/termdown/config.toml`(若设置了 `XDG_CONFIG_HOME`,则为
+`$XDG_CONFIG_HOME/termdown/config.toml`)。所有配置项均为可选;仓库根目录的
+[`config.example.toml`](config.example.toml) 提供了一份包含全部默认值、可直接复制的示例。
```toml
# 主题:"auto"(默认)、"dark" 或 "light"
diff --git a/config.example.toml b/config.example.toml
new file mode 100644
index 0000000..858cf7f
--- /dev/null
+++ b/config.example.toml
@@ -0,0 +1,40 @@
+# termdown configuration
+#
+# Copy this file to:
+# ~/.config/termdown/config.toml
+# (or $XDG_CONFIG_HOME/termdown/config.toml when XDG_CONFIG_HOME is set)
+#
+# Every value below is termdown's built-in default, so an empty or missing
+# config behaves exactly like this file. Uncomment and edit only what you
+# want to change.
+
+# Color theme: "auto" (default), "dark", or "light".
+# "auto" detects the terminal background via OSC 11.
+theme = "auto"
+
+# Vim-style edge bell: emit a terminal BEL when you scroll past the top or
+# bottom of the document. The terminal emulator decides the visible effect
+# (audible beep, title-bar 🔔, dock bounce, …). Default true.
+# 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
+
+[font.heading]
+# Fonts for image-rendered headings (H1–H3). When unset, termdown uses
+# platform defaults and falls back to an embedded SourceSerif4 font. Body
+# text always uses your terminal's own font, not these.
+
+# Latin / ASCII heading font (sans-serif recommended).
+# latin = "Inter"
+
+# CJK heading font.
+# cjk = "LXGW WenKai"
+
+# Emoji / symbol fallback font for headings.
+# emoji = "Apple Color Emoji"
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 5178227..633e31d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -5,7 +5,7 @@
```
src/
├── main.rs CLI entry point, arg parsing, terminal state management
-├── config.rs ~/.termdown/config.toml loading (serde)
+├── config.rs ~/.config/termdown/config.toml loading (serde)
├── font.rs Font resolution, caching, CJK/Latin detection
├── style.rs HeadingStyle, ANSI constants, display width utilities
├── render.rs Text measurement, glyph drawing, PNG encoding, Kitty protocol
@@ -145,7 +145,7 @@ On UNIX, `main.rs` temporarily disables terminal echo before rendering and resto
## Configuration
-`~/.termdown/config.toml` is deserialized via serde:
+`~/.config/termdown/config.toml` is deserialized via serde:
```
Config
diff --git a/docs/MARKDOWN_FEATURE_COVERAGE.md b/docs/MARKDOWN_FEATURE_COVERAGE.md
index 9ea2753..cc69197 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 `~/.config/termdown/config.toml`. |
## Enabled GFM extensions
diff --git a/docs/TUI_MODE_DESIGN.md b/docs/TUI_MODE_DESIGN.md
index a2c64e1..d24fc47 100644
--- a/docs/TUI_MODE_DESIGN.md
+++ b/docs/TUI_MODE_DESIGN.md
@@ -31,7 +31,7 @@ if you want the "why this stack"; this doc is the "what we're building".
- Future evolution (documented but not implemented in v1):
- Automatic mode when output is a TTY and the rendered document
exceeds terminal height (git-log-style).
- - `[tui]` section in `~/.termdown/config.toml` to opt into automatic
+ - `[tui]` section in `~/.config/termdown/config.toml` to opt into automatic
mode or override defaults.
Single binary, no cargo feature flag. TUI code is always compiled in;
diff --git a/docs/adr/0001-metadata-block-handling.md b/docs/adr/0001-metadata-block-handling.md
index 26d3707..66c2d80 100644
--- a/docs/adr/0001-metadata-block-handling.md
+++ b/docs/adr/0001-metadata-block-handling.md
@@ -42,7 +42,7 @@ as body content and not as completely invisible noise:
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
+ in `~/.config/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.
@@ -128,7 +128,8 @@ as body content and not as completely invisible noise:
## Open follow-ups
-- Migration of config dir from `~/.termdown/` to XDG `~/.config/termdown/`
- is unrelated and explicitly out of scope here.
+- Migration of the config dir from `~/.termdown/` to XDG `~/.config/termdown/`
+ is handled alongside this change (same branch); see `config.example.toml`
+ and the `## Configuration` section of the README.
- "Use `title` field in TUI title bar" — sketched as a future enhancement,
not committed.
diff --git a/src/config.rs b/src/config.rs
index cb3d2a3..cd6f9f1 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,4 +1,4 @@
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use serde::Deserialize;
@@ -61,8 +61,17 @@ pub struct HeadingFontConfig {
}
fn config_dir() -> Option {
+ // XDG Base Directory spec: prefer an absolute $XDG_CONFIG_HOME, otherwise
+ // fall back to ~/.config (USERPROFILE covers Windows, where HOME is often
+ // unset). The config file itself lives at `/termdown/config.toml`.
+ if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
+ let xdg = PathBuf::from(xdg);
+ if xdg.is_absolute() {
+ return Some(xdg.join("termdown"));
+ }
+ }
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
- Some(PathBuf::from(home).join(".termdown"))
+ Some(PathBuf::from(home).join(".config").join("termdown"))
}
pub fn load() -> Config {
@@ -76,6 +85,54 @@ pub fn load() -> Config {
eprintln!("Warning: invalid config at {}: {}", path.display(), e);
Config::default()
}),
- Err(_) => Config::default(),
+ Err(_) => {
+ warn_if_legacy_config(&path);
+ Config::default()
+ }
+ }
+}
+
+/// Nudge users upgrading from the pre-XDG layout: if nothing was found at the
+/// new path but a config still sits at the old `~/.termdown/config.toml`, warn
+/// so the settings aren't silently dropped. Stays silent on a clean install
+/// (no legacy file) and matches `uninstall.sh`'s legacy-cleanup awareness.
+fn warn_if_legacy_config(new_path: &Path) {
+ let home = match std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
+ Some(home) => home,
+ None => return,
+ };
+ let legacy = PathBuf::from(home).join(".termdown").join("config.toml");
+ if legacy.is_file() {
+ eprintln!(
+ "Warning: ignoring legacy config at {}; move it to {}",
+ legacy.display(),
+ new_path.display()
+ );
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// `config.example.toml` is the user-facing source of truth for defaults,
+ /// so guard it against bit-rot: it must always parse, and its explicit
+ /// values must match what termdown treats as the built-in defaults.
+ #[test]
+ fn example_config_parses_and_matches_defaults() {
+ let example = include_str!("../config.example.toml");
+ let parsed: Config =
+ 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.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);
+ // 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());
}
}
diff --git a/src/main.rs b/src/main.rs
index 7f7ef18..9c7ce68 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -37,7 +37,7 @@ fn main() {
println!("By default, passing FILE opens it in the interactive TUI.");
println!("Piped/redirected stdout and stdin input automatically use cat mode.");
println!();
- println!("Config: ~/.termdown/config.toml");
+ println!("Config: ~/.config/termdown/config.toml");
return;
}
diff --git a/tests/cli.rs b/tests/cli.rs
index ef48426..f7d08eb 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -19,6 +19,13 @@ fn run_termdown(
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
+ // Isolate config loading from the developer/CI environment so a real
+ // ~/.config/termdown/config.toml (or $XDG_CONFIG_HOME) can't leak in.
+ // Callers may re-set any of these via `envs`.
+ command.env_remove("HOME");
+ command.env_remove("USERPROFILE");
+ command.env_remove("XDG_CONFIG_HOME");
+
if stdin.is_some() {
command.stdin(Stdio::piped());
}
@@ -122,6 +129,54 @@ impl Drop for TempMarkdownFile {
}
}
+/// A throwaway directory tree, cleaned up on drop. Files are written at
+/// caller-chosen relative paths so the same helper can stage either an XDG
+/// config root (`termdown/config.toml`) or a fake `$HOME` (`.termdown/...`).
+struct TempDir {
+ root: PathBuf,
+}
+
+impl TempDir {
+ fn new() -> Self {
+ 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 seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let root = std::env::temp_dir().join(format!(
+ "termdown-cfg-{}-{}-{}",
+ std::process::id(),
+ unique,
+ seq
+ ));
+ fs::create_dir_all(&root).expect("failed to create temp dir");
+ Self { root }
+ }
+
+ fn write(&self, rel: &str, contents: &str) {
+ let path = self.root.join(rel);
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).expect("failed to create temp subdir");
+ }
+ fs::write(path, contents).expect("failed to write temp file");
+ }
+
+ fn path_str(&self) -> &str {
+ self.root.to_str().expect("temp path should be valid UTF-8")
+ }
+}
+
+impl Drop for TempDir {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.root);
+ }
+}
+
+// Body is a plain paragraph (not a heading) so it renders as text under
+// ghostty rather than as a Kitty-graphics image.
+const FRONTMATTER_DOC: &str = "---\ntitle: Hello\nauthor: Me\n---\n\nPlain body text.\n";
+
#[test]
fn help_flag_prints_usage() {
let output = run_termdown(&["--help"], None, &[], &[]);
@@ -130,7 +185,7 @@ fn help_flag_prints_usage() {
assert!(output.status.success());
assert!(stdout.contains("Render Markdown with large-font headings in the terminal"));
assert!(stdout.contains("Usage:"));
- assert!(stdout.contains("Config: ~/.termdown/config.toml"));
+ assert!(stdout.contains("Config: ~/.config/termdown/config.toml"));
assert!(stderr_text(&output).trim().is_empty());
}
@@ -272,3 +327,82 @@ fn cat_flag_forces_cat_output_with_file() {
assert!(output.status.success(), "stderr: {}", stderr_text(&output));
assert!(stdout.contains("plain content"), "stdout was: {stdout:?}");
}
+
+#[test]
+fn xdg_config_home_is_honored_for_config_loading() {
+ let doc = TempMarkdownFile::new(FRONTMATTER_DOC);
+ let path = doc.path().to_str().expect("path should be valid UTF-8");
+
+ // Baseline: with no config at all, the folded metadata summary is shown.
+ let baseline = run_termdown(&["--cat", path], None, &[("TERM_PROGRAM", "ghostty")], &[]);
+ let baseline_out = strip_ansi(&stdout_text(&baseline));
+ assert!(
+ baseline.status.success(),
+ "stderr: {}",
+ stderr_text(&baseline)
+ );
+ assert!(
+ baseline_out.contains("[metadata ·"),
+ "baseline should show the metadata summary, was: {baseline_out:?}"
+ );
+
+ // A `config.toml` placed at `$XDG_CONFIG_HOME/termdown/` must take effect:
+ // `show = false` hides the summary the baseline rendered.
+ let cfg = TempDir::new();
+ cfg.write("termdown/config.toml", "[metadata]\nshow = false\n");
+ let configured = run_termdown(
+ &["--cat", path],
+ None,
+ &[
+ ("TERM_PROGRAM", "ghostty"),
+ ("XDG_CONFIG_HOME", cfg.path_str()),
+ ],
+ &[],
+ );
+ let configured_out = strip_ansi(&stdout_text(&configured));
+ assert!(
+ configured.status.success(),
+ "stderr: {}",
+ stderr_text(&configured)
+ );
+ assert!(
+ !configured_out.contains("[metadata"),
+ "config should hide the metadata summary, was: {configured_out:?}"
+ );
+ assert!(
+ configured_out.contains("Plain body text"),
+ "body should still render, was: {configured_out:?}"
+ );
+}
+
+#[test]
+fn legacy_config_location_triggers_migration_warning() {
+ // A fake $HOME that still holds the pre-XDG `~/.termdown/config.toml` but
+ // has no config at the new XDG path. termdown should ignore the legacy
+ // file and warn the user to move it, instead of failing silently.
+ let home = TempDir::new();
+ home.write(".termdown/config.toml", "theme = \"dark\"\n");
+
+ let doc = TempMarkdownFile::new("hello\n");
+ let path = doc.path().to_str().expect("path should be valid UTF-8");
+ let output = run_termdown(
+ &["--cat", path],
+ None,
+ &[("TERM_PROGRAM", "ghostty"), ("HOME", home.path_str())],
+ &[],
+ );
+
+ assert!(output.status.success(), "stderr: {}", stderr_text(&output));
+ let stderr = stderr_text(&output);
+ assert!(
+ stderr.contains("ignoring legacy config"),
+ "expected a migration warning, stderr was: {stderr:?}"
+ );
+ // Match the legacy dir name only (not a `/`-joined path) so the assertion
+ // holds on Windows, where `Path::display` uses `\` separators. The new
+ // path is `.configtermdown`, which never contains `.termdown`.
+ assert!(
+ stderr.contains(".termdown"),
+ "warning should name the legacy path, stderr was: {stderr:?}"
+ );
+}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 1544985..2f210c6 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -7,8 +7,9 @@ pub fn binary_path() -> &'static str {
/// Run the compiled termdown binary against `path` in a controlled test
/// environment: ghostty-like terminal (so kitty graphics emission is enabled),
-/// dark theme, and `HOME`/`USERPROFILE` cleared so a developer's
-/// `~/.termdown/config.toml` can't leak into the test. Returns raw stdout
+/// dark theme, and `HOME`/`USERPROFILE`/`XDG_CONFIG_HOME` cleared so a
+/// developer's `~/.config/termdown/config.toml` can't leak into the test.
+/// Returns raw stdout
/// bytes; callers decide whether to treat it as UTF-8 or scan for kitty APC
/// payloads.
pub fn run_termdown(path: &Path) -> Vec {
@@ -19,6 +20,7 @@ pub fn run_termdown(path: &Path) -> Vec {
.env("TERM_PROGRAM", "ghostty")
.env_remove("HOME")
.env_remove("USERPROFILE")
+ .env_remove("XDG_CONFIG_HOME")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
diff --git a/uninstall.sh b/uninstall.sh
index ea979d4..beadf5e 100755
--- a/uninstall.sh
+++ b/uninstall.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# termdown uninstaller
#
-# Removes the installed binary and deletes the config directory (~/.termdown).
+# Removes the installed binary and deletes the config directory (~/.config/termdown).
# Never invokes sudo; if the binary's location is not writable, prints a clear
# hint and exits.
#
@@ -10,7 +10,7 @@
#
# Environment variables:
# TERMDOWN_INSTALL_DIR location of the binary (default: auto-detect via `command -v`)
-# TERMDOWN_KEEP_CONFIG set to 1 to keep ~/.termdown (default: remove it)
+# TERMDOWN_KEEP_CONFIG set to 1 to keep the config dir (default: remove it)
set -euo pipefail
@@ -43,10 +43,22 @@ EOF
fi
if [ "${TERMDOWN_KEEP_CONFIG:-0}" = "1" ]; then
- info "Keeping config directory (~/.termdown)"
+ info "Keeping config directory (~/.config/termdown)"
else
+ # Mirror the binary's XDG logic (see src/config.rs): honor $XDG_CONFIG_HOME
+ # only when it is an absolute path, otherwise fall back to ~/.config.
+ if [ -n "${XDG_CONFIG_HOME:-}" ] && [[ "$XDG_CONFIG_HOME" == /* ]]; then
+ CONFIG_DIR="$XDG_CONFIG_HOME/termdown"
+ else
+ CONFIG_DIR="$HOME/.config/termdown"
+ fi
+ if [ -d "$CONFIG_DIR" ]; then
+ info "Removing config directory (${CONFIG_DIR})"
+ rm -rf "$CONFIG_DIR"
+ fi
+ # Clean up the older config location too, if present.
if [ -d "$HOME/.termdown" ]; then
- info "Removing config directory (~/.termdown)"
+ info "Removing legacy config directory (~/.termdown)"
rm -rf "$HOME/.termdown"
fi
fi