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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`mds fmt`** — an opinionated, safety-gated auto-formatter for `.mds` templates. Every
rewrite is guaranteed compile-equivalent: a runtime safety gate re-compiles the formatted
source and refuses to write if it would change compiled output (`mds::formatter_invariant`)
rather than silently corrupting a template. Normalizes CRLF to LF everywhere (including
inside frontmatter and code fences), collapses runs of two or more consecutive blank lines
to a single blank line, strips trailing whitespace on directive lines, and ensures exactly
one final newline — while leaving body-text trailing whitespace (Markdown hard breaks),
blank-line structure within frontmatter and code fences, and the byte-for-byte content of
`@message`/`@define` bodies untouched. Supports a single file,
a directory (recursive, including `_`-prefixed partials), or stdin (`-`, as a filter);
`--check` exits non-zero without writing when anything would change, and `--diff` prints a
unified diff (colorized on a TTY) without writing. New public `mds-core` API:
`format_str` / `format_str_with`. (#60)

- **Native Python bindings** (`crates/mds-python`, PyO3 + maturin), to be distributed
as `mdscript` on PyPI. Seven functions — `compile`, `compile_file`,
`compile_virtual`, `check`, `check_file`, `check_virtual`, and `scan_imports` —
Expand Down
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ pythonize = "=0.29.0"
# would enforce this acceptance in CI (suggested follow-up, out of scope for this batch).
notify = "8"
ctrlc = "3.5"
# similar: pure-Rust diffing (mds-cli `fmt --diff`). Apache-2.0 (MIT-compatible, no
# copyleft). 3.1.1 released 2026-05-23 (~40d before this pin) — past the routine 30d
# soak window. rust-version = "1.85" in its published Cargo.toml, within the workspace
# MSRV (1.88). Only the default features ("std", "text") are used.
similar = "3.1"

# panic = "unwind" is required workspace-wide because mds-wasm and mds-napi use
# catch_unwind at the JS boundary to convert panics into structured JS errors.
Expand Down
58 changes: 56 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Unlike general-purpose template engines, MDS is Markdown-native: no delimiters t
mds build [FILE|DIR] [OPTIONS] Compile an MDS template or directory to Markdown / JSON
mds watch [FILE|DIR] [OPTIONS] Watch and auto-recompile on save
mds check [FILE|DIR] [OPTIONS] Validate without rendering
mds fmt [FILE|DIR] [OPTIONS] Reformat MDS file(s) in place (opinionated, safety-gated)
mds init [FILENAME] Create a starter MDS file

Global options:
Expand All @@ -96,15 +97,24 @@ Watch-only options:
The watcher self-heals after a watched dir/root is deleted and
recreated; --poll-interval controls how quickly it detects recovery.

Fmt options:
--check Read-only: exit non-zero if any file would change; never writes
--diff Read-only: print a unified diff of pending changes; never writes
(colorized only when stdout is a terminal). Combines with --check —
--diff controls what's printed, --check controls the exit code.

Exit codes:
0 Success (or clean Ctrl+C in watch mode)
1 Template error (syntax, undefined variable, arity mismatch)
0 Success (or clean Ctrl+C in watch mode; or a clean `fmt --check` / `fmt --diff` preview)
1 Template error (syntax, undefined variable, arity mismatch), or `fmt --check` found a
file that would change
2 I/O error (file not found, not an MDS file), or invalid CLI argument (clap parse error)
3 Resource limit exceeded
```

**Directory mode** (`mds build <dir>` / `mds check <dir>`): every non-partial `.mds` file under the directory is compiled. `_`-prefixed files are partials — tracked as dependencies but never emitted to their own output. Output mirrors the source subtree (e.g. `src/a/b/foo.mds` → `dist/a/b/foo.md`). Symlinks are rejected. Errors are per-file and do not abort the run; a summary is printed and the exit code is non-zero if any file fails. Stale output files (compiled outputs with no corresponding source) are cleaned up automatically. The output extension is intrinsic: `.md` for Markdown templates, `.json` for templates with `@message` blocks.

`mds fmt <dir>` follows the same directory-mode conventions (recursive, symlinks rejected, continue-on-error, non-zero exit summary) with one deliberate difference: it formats `_`-prefixed **partials too** — formatting rewrites source, not compiled output, and a partial's source is just as much a candidate for reformatting as any other file.

### Live preview with `mds watch`

Watch a single file and recompile whenever it (or any of its imports) changes:
Expand Down Expand Up @@ -143,6 +153,49 @@ shared partial rebuilds **all transitive importers** automatically.
- Ctrl+C exits with code 0 and prints `Stopped watching.`
- `--vars` file is reloaded from disk on every rebuild; edits to it trigger a recompile.

### Formatting with `mds fmt`

An opinionated, safety-gated auto-formatter. Every rewrite is guaranteed **compile-equivalent**:
before writing anything, `mds fmt` re-compiles the formatted source and refuses to write if it
would change compiled output — a formatter bug surfaces as a clean error, never a silent
corruption of your template.

```bash
mds fmt template.mds # format a file in place
mds fmt . # format every .mds file recursively (partials included)
mds fmt --check template.mds # exit 1 if the file would change; never writes — for CI
mds fmt --diff template.mds # print a unified diff of pending changes; never writes
mds fmt --check --diff . # show diffs for every file that would change; exit 1 if any would
echo 'Hello {name}!' | mds fmt - # format from stdin, write to stdout; creates no file
```

What it normalizes:

- CRLF → LF, everywhere (including inside frontmatter and code fences)
- Two or more consecutive blank lines collapse to a single blank line; leading blank lines in the body are removed entirely
- Trailing whitespace on `@if`/`@for`/`@define`/… directive lines is stripped
- Exactly one final newline (empty or whitespace-only input formats to an empty file)

What it deliberately leaves untouched:

- Trailing whitespace on body-text content lines, including whitespace-only "blank" lines — two
trailing spaces are a Markdown hard line break, and stripping them (anywhere in body text,
including a stray blank line) can change rendered output
- Blank-line structure within frontmatter and code fences — CRLF is normalized there (the same
as everywhere else in the file), but blank lines are not collapsed inside these regions
- The byte-for-byte content of `@message`/`@define` bodies — neither CRLF normalization nor
blank-line collapsing is applied inside them, because these bodies bypass the compiler's own
whitespace normalization and their content reaches compiled output verbatim

Directory mode formats every `.mds` file recursively, **including `_`-prefixed partials**,
continuing past per-file errors and printing a summary
(`N formatted, M unchanged, K failed`, or `N would reformat, K failed` under `--check`). A file
is only written (and its mtime touched) when its content actually changes. Status lines and
summaries go to stderr; `--diff` output and stdin filter-mode content go to stdout; `--quiet`
suppresses status but never errors. Reads a `fmt` section from `mds.json`
(`{"fmt": {"sort_frontmatter_keys": true}}`) for forward compatibility — the field doesn't drive
any formatting behavior yet; frontmatter key sorting is deferred to a future version.

## Bundler Integration

Import `.mds` templates directly in Vite, Rollup, Webpack, and Rspack projects:
Expand Down Expand Up @@ -206,6 +259,7 @@ try {
```rust
let output = mds::compile(Path::new("template.mds"), None)?;
let output = mds::compile_str("---\nname: World\n---\nHello {name}!\n")?;
let formatted = mds::format_str("Hello {name}!\n")?;
```

## Examples
Expand Down
1 change: 1 addition & 0 deletions crates/mds-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ serde_json = { workspace = true }
miette = { workspace = true, features = ["fancy"] }
notify = { workspace = true }
ctrlc = { workspace = true }
similar = { workspace = true }

[dev-dependencies]
tempfile = { workspace = true }
Expand Down
82 changes: 82 additions & 0 deletions crates/mds-cli/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,53 @@ use serde::Deserialize;
pub(crate) struct MdsConfig {
#[serde(default)]
pub(crate) build: BuildConfig,
/// Loaded (and validated — a malformed `fmt` section still fails config
/// loading) for forward-compatibility, but not yet consulted by `mds fmt`
/// — see `FmtConfig`.
#[allow(
dead_code,
reason = "scaffolding for a rule not implemented until a future version"
)]
#[serde(default)]
pub(crate) fmt: FmtConfig,
}

#[derive(Debug, Default, Deserialize)]
pub(crate) struct BuildConfig {
pub(crate) output_dir: Option<String>,
}

/// Forward-compatibility scaffolding for `mds fmt` configuration.
///
/// `sort_frontmatter_keys` is not wired into any formatting behavior yet — the
/// v1 ruleset (R1-R4, see `mds-core`'s `formatter` module) deliberately defers
/// frontmatter key sorting to a future version, and there is intentionally no
/// matching CLI flag (a no-op flag would be a clippy/UX liability). The field
/// exists now purely so `{"fmt": {"sort_frontmatter_keys": false}}` parses
/// cleanly today and this won't need a breaking `mds.json` schema change once
/// the rule ships.
#[derive(Debug, Deserialize)]
pub(crate) struct FmtConfig {
#[allow(
dead_code,
reason = "scaffolding for a rule not implemented until a future version"
)]
#[serde(default = "default_sort_frontmatter_keys")]
pub(crate) sort_frontmatter_keys: bool,
}

impl Default for FmtConfig {
fn default() -> Self {
Self {
sort_frontmatter_keys: default_sort_frontmatter_keys(),
}
}
}

fn default_sort_frontmatter_keys() -> bool {
true
}

/// Maximum allowed size for `mds.json` (1 MB) to prevent runaway memory use.
const MAX_CONFIG_SIZE: u64 = 1024 * 1024;

Expand Down Expand Up @@ -971,6 +1011,7 @@ mod tests {
build: BuildConfig {
output_dir: Some("build".to_string()),
},
..Default::default()
},
PathBuf::from("/project"),
));
Expand All @@ -989,4 +1030,45 @@ mod tests {
"-o should win over mds.json config"
);
}

// ── T5: malformed fmt config fails config loading ─────────────────────────

#[test]
fn fmt_config_malformed_bool_field_fails_loading() {
// `FmtConfig.sort_frontmatter_keys` is a `bool`. Supplying a string
// value must cause `serde_json` to reject the config, so `load_config`
// returns `Err` rather than silently using the default. This ensures a
// bad `mds.json` is reported loudly rather than quietly ignored.
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("mds.json"),
r#"{"fmt": {"sort_frontmatter_keys": "not-a-bool"}}"#,
)
.unwrap();

let result = load_config(dir.path());
assert!(
result.is_err(),
"a malformed fmt config (wrong type for sort_frontmatter_keys) must fail config loading"
);
}

#[test]
fn fmt_config_valid_section_loads_cleanly() {
// Complement to the malformed test: a well-typed fmt section must parse
// without error and produce the expected field value.
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("mds.json"),
r#"{"fmt": {"sort_frontmatter_keys": false}}"#,
)
.unwrap();

let result = load_config(dir.path()).expect("valid fmt config must load");
let (config, _) = result.expect("mds.json must be found");
assert!(
!config.fmt.sort_frontmatter_keys,
"sort_frontmatter_keys: false must deserialize correctly"
);
}
}
Loading
Loading