Introduction
mdt (manage markdown templates) helps library and tool maintainers keep README sections, source-doc comments, and docs-site content synchronized. Define content once in a template file, reference it from anywhere — READMEs, code comments, mdbook docs — and mdt keeps everything in sync.
The Problem
Documentation gets duplicated. Your README has installation instructions. Your library’s doc comment has the same instructions. Your mdbook repeats them again. When something changes, you update one place and forget the others. The docs drift apart, and users find conflicting information.
This happens constantly for library and tool maintainers:
- install instructions repeated in a root README, crate README, and package docs
- usage snippets duplicated between source-doc comments and a docs site
- version numbers, package names, and commands scattered across multiple files
- examples copied between markdown docs and source files
Manual synchronization doesn’t scale. Copy-pasting is error-prone. The more places the same content lives, the more likely it is to drift.
The Solution
mdt uses HTML comments as invisible template tags. You define content once in a source block inside a template file (*.t.md — the “t” stands for template). Then you place target tags wherever that content should appear. Running mdt update replaces the content between target tags with the source’s content.
The Problem
You have the same install instructions in three places:
readme.md:
## Installation
npm install my-lib
src/lib.rs:
#![allow(unused)]
fn main() {
//! ## Installation
//!
//! npm install my-lib
}
docs/getting-started.md:
## Installation
npm install my-lib
You update one. The others drift. CI doesn’t catch it.
The Fix
Define it once in a *.t.md template file (the “t” stands for template):
<!-- {@install} -->
npm install my-lib
<!-- {/install} -->
Use it everywhere:
<!-- {=install} -->
(replaced automatically)
<!-- {/install} -->
Run mdt update — all three files are in sync. Run mdt check in CI — drift is caught before merge.
See It in Practice
If you want concrete adoption examples instead of abstract syntax:
- read Proof of Value to see how this repository already keeps README content, Rust source docs, and mdBook pages synchronized
- read Migration Walkthrough for a before/after adoption path you can copy into your own project
Key Features
- Comment-based tags — HTML comments are invisible in rendered markdown, so your docs look clean
- Source file support — Target tags work inside code comments too (Rust, TypeScript, Python, Go, and more)
- Data interpolation — Pull values from
package.json,Cargo.toml, or any data file into your templates using{{ variable }}syntax - Transformers — Pipe content through filters like
trim,indent,prefix,codeBlockto adapt shared content for each context - CI-friendly —
mdt checkexits non-zero when docs are stale, with JSON and GitHub Actions output formats - Project diagnostics —
mdt infoandmdt doctorprovide project health, cache observability, and actionable remediation hints - Watch mode —
mdt update --watchauto-syncs on file changes during development - Human-first editor support —
mdt lspadds diagnostics, completions, hover, and code actions in your editor - Agent-friendly automation —
mdt mcpexposes the same documentation graph to AI assistants via the Model Context Protocol
Installation
Recommended for Node.js users
Install the CLI from npm:
npm install -g @m-d-t/cli
This installs the mdt command and pulls in the prebuilt binary package that matches your platform.
You can also run it without a global install:
npx @m-d-t/cli --help
This path is ideal for JavaScript and TypeScript projects that already use npm and do not want to install the Rust toolchain.
Recommended for most non-Rust users
Download the prebuilt binary for your platform from the latest GitHub release and place the mdt binary somewhere on your PATH.
This is the simplest option if you want to use mdt in a Python, Go, or other non-Rust project without installing the Rust toolchain first.
If you already use Cargo
Install the CLI from crates.io:
cargo install mdt_cli
This installs the mdt binary.
From source
Clone the repository and build from the workspace:
git clone https://github.com/ifiokjr/mdt.git
cd mdt
cargo install --path mdt_cli
As a library
To use the core engine in your own Rust project:
[dependencies]
mdt_core = "0.7.0"
Agent skill package
If you use Pi or another agent harness that supports the Agent Skills standard, install the official mdt skill package:
pi install npm:@m-d-t/skills
This teaches your coding agent how to work with mdt template syntax, MCP tools, CLI commands, transformers, and configuration. See Assistant Setup for more details.
Verify installation
mdt --help
You should see the available commands: init, check, update, list, info, doctor, assist, lsp, and mcp.
Quick Start
This walkthrough creates a small project that uses mdt to keep a README section and a Rust doc comment in sync from one source.
1. Initialize a project
Create a new directory and generate the starter files:
mkdir my-project && cd my-project
mdt init
This creates:
.templates/template.t.md— your starter provider filemdt.toml— a starter config with commented examples
The starter template contains:
<!-- {@greeting} -->
Hello from mdt! This is a source block.
<!-- {/greeting} -->
2. Add a README target
Create a readme.md that references the source:
# My Project
Welcome to my project.
<!-- {=greeting} -->
This will be replaced by mdt.
<!-- {/greeting} -->
The {=greeting} tag marks this as a target of the greeting provider.
3. Add a source-doc consumer
Create src/lib.rs with a doc comment consumer that reuses the same source:
#![allow(unused)]
fn main() {
//! <!-- {=greeting|trim|linePrefix:"//! "} -->
//!
//! This will be replaced by mdt.
//!
//! <!-- {/greeting} -->
pub fn hello() {}
}
The linePrefix:"//! " transformer adapts the source content so it becomes valid Rust doc comments.
Not using Rust? The same pattern works in other source files too — use a comment style and transformers that match your language.
4. Update
Run the update command:
mdt update
Output:
Updated 2 block(s) in 2 file(s).
Now both files are synchronized from the same source.
readme.md contains:
# My Project
Welcome to my project.
<!-- {=greeting} -->
Hello from mdt! This is a source block.
<!-- {/greeting} -->
And src/lib.rs contains:
#![allow(unused)]
fn main() {
//! <!-- {=greeting|trim|linePrefix:"//! "} -->
//!
//! Hello from mdt! This is a source block.
//!
//! <!-- {/greeting} -->
pub fn hello() {}
}
5. Check for staleness
Edit the source in .templates/template.t.md:
<!-- {@greeting} -->
Hello from mdt! This content has been updated.
<!-- {/greeting} -->
Now run the check command:
mdt check
Output:
Check failed.
render errors: 0
stale targets: 2
Stale targets:
block `greeting` at readme.md:5:1
block `greeting` at src/lib.rs:1:5
2 target block(s) are out of date. Run `mdt update` to fix.
The check command exits with a non-zero status code when blocks are stale, making it useful in CI pipelines.
6. See what changed
Use the --diff flag to see exactly what’s different:
mdt check --diff
This shows a colorized unified diff between the current target content and what the source would produce.
7. List all blocks
See all sources and targets in the project:
mdt list
Output:
Sources:
@greeting .templates/template.t.md (2 target(s))
Targets:
=greeting readme.md [linked]
=greeting src/lib.rs |trim|linePrefix [linked]
1 source(s), 2 target(s)
Next steps
- Read Proof of Value to see how this repository uses mdt across READMEs, Rust source docs, and mdBook pages
- Follow the Migration Walkthrough to convert repeated docs into a source-plus-consumer workflow
- Learn about sources and targets in depth
- Add data interpolation to pull values from project files
- Use transformers to adapt content for different contexts
- Set up CI integration to catch stale docs automatically
Assistant Setup
mdt’s official assistant profiles are intentionally lightweight: they provide ready-to-copy MCP configuration snippets and repo-local guidance, not a marketplace or plugin registry.
Recommended workflow
- Run
mdt assist <assistant>to print an official setup profile. - Copy the MCP snippet into your assistant’s configuration.
- Add the suggested repo-local guidance to your project instructions.
- Let the assistant inspect and synchronize docs through
mdt mcp.
Example
mdt assist claude
This prints:
- an MCP server configuration snippet that runs
mdt mcp - repo-local guidance such as reusing sources before creating new ones
- assistant-specific notes for the selected profile
Why this approach
The goal is to reduce setup friction without inventing a new extension ecosystem.
The first official profiles focus on:
- portable MCP configuration — the same
mdt mcpserver can be reused across assistants - repo-local guidance — your assistant should follow the same mdt workflow every time
- human-controlled adoption — you can copy, review, and customize the generated setup before using it
Repo-local guidance to keep
Regardless of assistant, keep guidance like this close to your project instructions:
- Prefer reuse before creation: run
mdt_find_reuseormdt_listbefore introducing a new source block. - Use
.templates/as the canonical template location. - Use
mdt_previewto inspect source and target output before syncing changes. - Run
mdt_checkafter documentation edits andmdt_updatewhen target blocks are stale.
Agent skill package
For Pi users, install the official mdt skill package to give your agent full knowledge of template syntax, MCP tools, CLI workflows, and configuration:
pi install npm:@m-d-t/skills
Or try it for a single session:
pi -e npm:@m-d-t/skills
The skill package teaches agents how to create and manage provider/consumer blocks, apply transformers for source-file doc comments, use MCP tools with best practices, and configure mdt.toml.
For project-level adoption, add it to .pi/settings.json so every contributor gets the skill automatically:
{
"packages": ["npm:@m-d-t/skills"]
}
Supported first-slice profiles
genericclaudecursorcopilotpi
As the project evolves, these profiles can grow into richer setup helpers, but the initial focus is pragmatic: make assistant setup reproducible and easy to adopt.
Proof of Value
If you want to know whether mdt is solving a real problem, this repository is the best example.
The project already uses source blocks from .templates/*.t.md to keep repeated content synchronized across multiple surfaces:
- root and crate READMEs
- crate-level Rust docs
- mdBook pages
That is the core value proposition in one repo: write shared content once, then fan it out wherever people actually read it.
1. README synchronization
The source block mdtCliUsage lives in .templates/overview.t.md.
It is consumed in multiple README-style surfaces:
That means the command list and diagnostics workflow stay aligned without copying edits by hand.
2. Source-doc synchronization
The source block mdtLspOverview also lives in .templates/api-and-install.t.md, but it fans out into both markdown and Rust source docs:
The source file uses a transformer chain so markdown content becomes Rust crate documentation comments:
#![allow(unused)]
fn main() {
//! <!-- {=mdtLspOverview|trim|linePrefix:"//! ":true} -->
//! <!-- {/mdtLspOverview} -->
}
The same pattern is used for:
This is the practical payoff: you do not maintain one explanation for README readers and a second explanation for API docs readers.
3. Docs-site synchronization
The mdBook docs also consume shared source blocks.
For example, mdtInlineBlocksGuide is reused in more than one docs page:
This keeps the conceptual explanation of inline blocks consistent across both a reference page and a guide page.
Why this matters
Without mdt, these edits drift in predictable ways:
- the README gets the newest wording
- the source-doc comment keeps an older explanation
- the docs site uses slightly different examples
- command lists diverge across pages
With mdt, one source update can refresh all of those targets in one run:
mdt update
mdt check
What to look at in this repo
If you are evaluating adoption, inspect these files together:
Shared sources
.templates/overview.t.mdand.templates/api-and-install.t.md
README targets
Source-doc consumers
Docs-site targets
The shortest convincing story
A good way to describe mdt to a teammate is:
We keep a few pieces of documentation repeated across our README, crate docs, and docs site.
mdtlets us define those pieces once, reuse them everywhere, and verify in CI that they never drift apart.
If that story matches your project, the tool is probably worth trying.
Migration Walkthrough
This walkthrough shows how to adopt mdt in a project that already has documentation drift.
The example is intentionally realistic: the same installation instructions appear in a README, a Rust doc comment, and a docs page.
Before: three copies to maintain
Imagine these three files already exist.
readme.md
## Installation
npm install my-lib
src/lib.rs
#![allow(unused)]
fn main() {
//! ## Installation
//!
//! npm install my-lib
}
docs/src/getting-started.md
## Installation
npm install my-lib
At first this seems harmless. Then the command changes to npm install my-lib@latest, or the project switches to pnpm, or you want to add a second setup note.
Now you have three edits to make, and one of them eventually gets missed.
After: one source, three targets
1. Initialize mdt
mdt init
This creates a starter template file at .templates/template.t.md.
2. Define one source
Add a source block to .templates/template.t.md:
<!-- {@install} -->
## Installation
npm install my-lib@latest
<!-- {/install} -->
3. Replace the README copy with a target
<!-- {=install} -->
Old copied content
<!-- {/install} -->
4. Replace the docs-page copy with a target
<!-- {=install} -->
Old copied content
<!-- {/install} -->
5. Replace the Rust doc comment with a transformed target
#![allow(unused)]
fn main() {
//! <!-- {=install|trim|linePrefix:"//! ":true} -->
//! Old copied content
//! <!-- {/install} -->
}
If your project uses source-file targets heavily, add padding settings in mdt.toml so formatters do not collapse content awkwardly:
[padding]
before = 0
after = 0
6. Sync everything
mdt update
After the update, all three places render from the same source.
What changed structurally
Before
- each surface owned its own copy
- wording changes required repeated manual edits
- CI could not reliably detect drift
After
- the source in
.templates/template.t.mdbecomes the source of truth - each surface keeps only a target tag
mdt checkcan fail CI when a target is stale
The day-two workflow
Once the migration is done, the maintenance loop is simple:
- edit the source block
- run
mdt update - run
mdt check - commit the synchronized result
That is the real adoption win: not just fewer edits, but a repeatable workflow that prevents drift from coming back.
A small migration strategy that works well
Do not try to template your entire docs set in one pass.
Start with content that is:
- repeated in 2 or more places
- easy to recognize when it drifts
- expensive or embarrassing when it diverges
Good first candidates:
- installation instructions
- support policy / compatibility notes
- API overview paragraphs
- badge/link sections
- CLI usage summaries
How to know the migration paid off
A migration is usually worth it when one of these becomes true:
- you can point to a source that replaced three or more manual copies
- CI now catches stale docs that previously slipped through
- README, source docs, and docs pages no longer need separate wording updates
If you want to see this pattern in a real codebase, inspect the repo-backed examples in Proof of Value.
How mdt Works
mdt follows a straightforward pipeline: scan your project for template tags, match sources to targets, render any template variables, apply transformers, and replace content.
The Pipeline
1. Scan project directory
├── Find *.t.md files → extract source blocks
├── Find *.md files → extract target blocks
└── Find source files (.rs, .ts, .py, ...) → extract target blocks from comments
2. Load configuration (mdt.toml)
└── Read data files (package.json, Cargo.toml, ...) into template context
3. For each target:
├── Find its matching source by name
├── Render template variables in source content ({{ package.version }})
├── Apply transformers (|trim|indent:" ")
└── Replace the target's content if it differs
Tag anatomy
All mdt tags live inside HTML comments. This means they’re invisible when markdown is rendered — your docs look clean to readers.
A tag has three parts:
<!-- {sigil name | transformers} -->
│ │ │
│ │ └── Optional: pipe-delimited content filters
│ └─────── The block name
└────────────── @ source, = target, ~ inline, / close
File conventions
mdt determines how to treat files based on their names:
| Pattern | Role |
|---|---|
*.t.md | Template files — only these can contain source blocks |
*.md, *.mdx, *.markdown | Markdown files — scanned for target and inline blocks |
*.rs, *.ts, *.py, *.go, etc. | Source files — scanned for target and inline blocks inside comments |
Source blocks found in non-template files are ignored. This prevents accidental content injection from arbitrary files.
What gets skipped
The scanner automatically ignores:
- Hidden directories (starting with
.) node_modules/target/(Rust build output)- Directories with their own
mdt.toml(treated as separate projects) - Files matching gitignore-style patterns in the
[exclude]config section - Blocks whose names appear in
[exclude] blocks - Tags inside fenced code blocks in source-file comments when
[exclude] markdown_codeblocksis configured
Matching rules
- Each source name must be unique across all template files. Duplicate names produce an error.
- A target references a source by name. If no matching source exists, mdt emits a warning but continues.
- Multiple targets can reference the same source. They all receive the same content (after their own transformers are applied).
- A single file can contain multiple target blocks.
Sources and Targets
mdt’s template system has two roles: sources define content, and targets receive it.
Sources
A source block defines a named piece of content. Providers live in template files (*.t.md).
<!-- {@installGuide} -->
Install the package:
npm install my-lib
<!-- {/installGuide} -->
The @ sigil marks this as a source. The name installGuide is how consumers reference it.
Rules for sources
- Providers can only appear in
*.t.mdfiles. A{@name}tag inreadme.mdis ignored. - Each source name must be unique across the entire project. Two template files defining
{@installGuide}produces an error. - The content between the opening and closing tags is the source’s content — including the surrounding whitespace.
Targets
A target block marks a location where source content should be injected. Consumers can appear in any scanned file.
<!-- {=installGuide} -->
Old content here (will be replaced).
<!-- {/installGuide} -->
The = sigil marks this as a target. The name installGuide tells mdt which provider to use.
Rules for targets
- Consumers can appear in any markdown file or source code file.
- Multiple targets can reference the same source. Each gets the same content.
- If a target references a non-existent source, mdt warns but doesn’t fail.
- Consumers can include transformers to modify the content for their specific context.
Close tags
Both sources and targets share the same close tag syntax:
<!-- {/blockName} -->
The / sigil closes the block. The name must match the opening tag.
How content flows
.templates/*.t.md readme.md src/lib.rs
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ <!-- {@docs} -->│ │ <!-- {=docs} --> │ │ // <!-- {=docs| │
│ │──────┬─────→│ │ │ // trim|indent: │
│ API reference. │ │ │ API reference. │ │ // "/// "} --> │
│ │ │ │ │ │ /// API reference│
│ <!-- {/docs} -->│ └─────→│ <!-- {/docs} --> │ │ // <!-- {/docs} │
└─────────────────┘ └──────────────────┘ │ // --> │
└──────────────────┘
Provider Consumer (plain) Consumer (with
transformers)
The same source content feeds multiple targets. Each consumer can apply its own transformers to adapt the content for its context.
A complete example
.templates/*.t.md — grouped sources of truth:
<!-- {@projectDescription} -->
A fast, type-safe HTTP client for Rust.
<!-- {/projectDescription} -->
<!-- {@usage} -->
let response = client.get("https://example.com").send().await?;
<!-- {/usage} -->
readme.md — targets reference sources by name:
# my-http-client
<!-- {=projectDescription} -->
<!-- {/projectDescription} -->
## Quick start
<!-- {=usage} -->
<!-- {/usage} -->
my-http-client/src/lib.rs — even works in source comments:
#![allow(unused)]
fn main() {
//! <!-- {=projectDescription|trim} -->
//! <!-- {/projectDescription} -->
}
After mdt update, all three files contain the same project description and usage example, each adapted for its context.
Template Files
Template files are the single source of truth for your shared content. They contain source blocks that define the content distributed to consumers throughout your project.
Naming convention
Template files use the .t.md extension:
template.t.md
docs.t.md
shared/api-docs.t.md
Any file ending in .t.md is treated as a template file. The t stands for “template.”
Only *.t.md files can contain source blocks. Source tags ({@name}) in other files are ignored. This is intentional — it prevents accidental content injection from arbitrary files and gives you a clear place to look for content definitions.
Structure
A template file is regular markdown containing one or more source blocks:
<!-- {@installGuide} -->
Install the package:
npm install my-lib
<!-- {/installGuide} -->
<!-- {@contributing} -->
See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
<!-- {/contributing} -->
Content outside of source blocks is ignored by mdt. You can use it for notes, organization, or documentation about the templates themselves.
Template variables
Provider content can include minijinja template variables that reference data from project files. This requires an mdt config file (mdt.toml, .mdt.toml, or .config/mdt.toml). See Data Interpolation for details.
<!-- {@installGuide} -->
Install `{{ package.name }}` version {{ package.version }}:
npm install {{ package.name }}@{{ package.version }}
<!-- {/installGuide} -->
When mdt renders this source, {{ package.name }} and {{ package.version }} are replaced with actual values from package.json (or whichever file is mapped to the package namespace).
Where to place template files
Template files can live anywhere in your project directory.
Canonical recommendation: use .templates/ at the project root.
Canonical layout (.templates/):
my-project/
.templates/
template.t.md
docs.t.md
readme.md
Compatible alternative (templates/):
my-project/
templates/
docs.t.md
examples.t.md
readme.md
Legacy single template at the root (still supported):
my-project/
template.t.md
readme.md
You can also configure explicit template paths in mdt.toml:
[templates]
paths = ["shared/templates"]
Multiple template files
A project can have multiple template files. Source names must be unique across all template files. If two files define {@installGuide}, mdt reports an error:
error: duplicate source `installGuide`: defined in `docs.t.md` and `api.t.md`
This ensures there’s always one unambiguous source of truth for each piece of content.
Why mdt?
Documentation drift is a familiar problem, and there are several ways to address it. This page explains where mdt fits and when it’s the right choice.
vs. Copy-paste
The simplest approach: copy content between files by hand.
| Copy-paste | mdt | |
|---|---|---|
| Setup effort | None | Minimal (mdt init) |
| Drift detection | Manual review | mdt check in CI |
| Sync effort | Edit every copy | Edit one source block, run mdt update |
| Cross-surface | Manual | README + source docs + docs site from one source |
| Scales with copies | Gets worse | Stays constant |
Copy-paste works for a single duplication. Once the same content lives in three or more places, manual synchronization becomes the dominant maintenance cost.
vs. Docs framework includes
Frameworks like mdBook, Docusaurus, and MkDocs support file includes or content transclusion within their own ecosystem.
| Framework includes | mdt | |
|---|---|---|
| Works in README.md | No | Yes |
| Works in source-doc comments | No | Yes |
| Works across frameworks | No | Yes |
| Works without a build step | Sometimes | Yes (tags are HTML comments) |
| Data interpolation | Framework-specific | Built-in ({{ pkg.version }}) |
| CI verification | Framework-specific | mdt check exits non-zero |
Framework includes solve the problem within one surface. mdt solves it across surfaces — your README, your crate/package docs, and your docs site all stay in sync from the same source blocks.
vs. Custom scripts
A common approach is writing a script that reads a source file and injects content into targets.
| Custom script | mdt | |
|---|---|---|
| Maintenance | You maintain it | Community-maintained |
| Declarative | No — imperative logic | Yes — tag-based |
| Caching | You build it | Built-in (file fingerprinting) |
| Editor support | None | LSP (diagnostics, completions, hover, go-to-definition) |
| AI integration | None | MCP server for assistants |
| Watch mode | You build it | mdt update --watch |
| Transformers | You build them | Built-in (trim, indent, linePrefix, etc.) |
| Data interpolation | You build it | Built-in (JSON, TOML, YAML, KDL, INI, scripts) |
Scripts work when you have one specific use case. mdt provides the same capability as a general-purpose tool with editor integration, CI support, and a growing feature set.
vs. Template engines (Tera, Handlebars, Jinja)
General-purpose template engines are powerful but solve a different problem.
| Template engines | mdt | |
|---|---|---|
| Target use case | Generate files from templates | Sync content across existing files |
| Preserves surrounding content | No — replaces entire file | Yes — only replaces tagged regions |
| Works in source comments | Not designed for it | Built-in |
| Learning curve | Template language + config | HTML comment tags |
| Invisible in rendered docs | N/A — generates output | Yes — tags are HTML comments |
Template engines generate files. mdt synchronizes regions within files. If your docs are already written and you want to keep specific sections in sync, mdt fits without restructuring your project.
When mdt is the right choice
mdt is a good fit when:
- The same content appears in 2+ places (README, source docs, docs site)
- You want CI to catch drift before it reaches users
- You need data interpolation (versions, package names) across doc surfaces
- You want editor support for navigating and maintaining template relationships
- Your docs are already written and you want to adopt incrementally
When something else might be better
- Single-surface docs — If all your docs live in one framework (e.g., only Docusaurus), framework-native includes may be simpler.
- Full file generation — If you’re generating entire files from data, a template engine like Tera or Handlebars is more appropriate.
- One-time migration — If you just need to copy content once and it won’t change, copy-paste is fine.
Configuration
mdt is configured through an mdt.toml file placed in the project root. Configuration is optional — mdt works without it using sensible defaults.
Creating a config file
Create mdt.toml in your project root:
[data]
package = "package.json"
[exclude]
patterns = ["vendor/", "dist/"]
What mdt init writes
mdt init creates a fully annotated starter mdt.toml so new projects can see every currently supported option before uncommenting anything.
# mdt.toml
#
# Welcome to mdt. This starter config is intentionally fully annotated so you
# can discover every supported option in one place.
#
# Uncomment only what your project needs. mdt works without a config file, but
# `mdt.toml` becomes useful once you want data interpolation, custom scanning
# rules, padding control, or formatter-aware convergence.
#
# When in doubt, start with a sample template + target block, run `mdt update`,
# and then come back here to enable the options that match your workflow.
# Maximum file size (in bytes) that mdt will scan before failing fast.
# Leave this commented to use the built-in default of 10 MB.
# max_file_size = 10485760
# By default mdt respects `.gitignore` and skips ignored files.
# Uncomment this only when ignored/generated files should still be scanned, or
# when you want `[include]` / `[exclude]` to be your only scanning rules.
# disable_gitignore = true
# `[padding]` controls the whitespace between tags and injected content.
# Supported values for `before` and `after`:
# - false -> keep content inline with the tag
# - 0 -> put content on the next line with no blank line
# - 1 -> add one blank line
# - 2+ -> add two or more blank lines
#
# Recommended when your targets live in source-code comments or when formatters
# tend to rewrite surrounding whitespace.
# [padding]
# before = 0
# after = 0
# `[check]` controls how `mdt check` compares expected vs actual content.
# - "strict" -> byte-for-byte comparison (default)
# - "lenient" -> whitespace-normalized comparison; ignores differences in
# blank line count, trailing whitespace, and table/JSON formatting so
# external formatters do not cause false staleness.
# `mdt update` always writes exact bytes regardless of this setting.
# [check]
# comparison = "lenient"
# `[data]` maps namespaces to external data sources. These values are available
# in source blocks through minijinja templates like `{{ package.version }}`.
#
# String values are file-backed sources. The parser is inferred from the file
# extension (`.json`, `.toml`, `.yaml`, `.yml`, `.kdl`, `.ini`).
# [data]
# package = "package.json"
# cargo = "Cargo.toml"
# config = "config.yaml"
#
# Typed data sources force a parser when the extension is missing or unusual.
# release = { path = "release-info", format = "json" }
#
# Script-backed data sources run a shell command from the project root and parse
# stdout. `format` accepts: `text`, `string`, `raw`, `txt`, `json`, `toml`,
# `yaml`, `yml`, `kdl`, or `ini`.
# `watch` files control cache invalidation in `.mdt/cache/data-v1.json`.
# version = { command = "cat VERSION", format = "text", watch = ["VERSION"] }
# git = { command = "git rev-parse --short HEAD", format = "text" }
# `[exclude]` skips files, directories, or block names during scanning.
# `patterns` use gitignore-style syntax, including `!negation`, trailing `/`,
# `*`, `**`, and character classes.
# [exclude]
# patterns = ["vendor/", "dist/", "generated/", "!generated/keep.md"]
#
# `markdown_codeblocks` only affects fenced code blocks inside source-file
# comments. Supported values:
# - false -> process tags in code blocks normally (default)
# - true -> ignore tags in all fenced code blocks
# - "..." -> ignore code blocks whose info string contains that text
# - ["...", ...] -> ignore code blocks matching any listed info-string text
# markdown_codeblocks = true
# markdown_codeblocks = "ignore"
# markdown_codeblocks = ["ignore", "example"]
#
# `blocks` excludes specific block names everywhere, even if their files are
# still scanned.
# blocks = ["draftSection", "deprecatedApi"]
# `[include]` narrows scanning to only matching files. Use it when you want a
# smaller, more predictable scan surface in large repos.
# [include]
# patterns = ["docs/**/*.rs", "src/**/_.ts", "packages/_/readme.md"]
# `[templates]` restricts where `*.t.md` provider files are discovered.
# Leave it commented to allow template discovery anywhere in the project.
# [templates]
# paths = [".templates", "shared/templates"]
# `[[formatters]]` makes `mdt update` and `mdt check` converge with your
# formatter's canonical output.
#
# This is the recommended fix when `mdt update`, your formatter, and
# `mdt check` would otherwise bounce back and forth in CI.
#
# Formatter `command` values are rendered with minijinja before execution.
# Available variables:
# - `{{ filePath }}` -> absolute path to the file being formatted
# - `{{ relativeFilePath }}` -> path relative to the project root
# - `{{ rootDirectory }}` -> absolute path to the project root
#
# `patterns` and `ignore` are both ordered gitignore-style rule lists. A
# leading `!` negates a prior match, so later rules can re-include paths.
#
# Start with one catch-all formatter when your repo already uses a router like
# dprint. Add more entries when different file types need different tools.
# [[formatters]]
# command = "dprint fmt --stdin \"{{ filePath }}\""
# patterns = ["**/*.md"]
# ignore = ["**/*.t.md", "**/*.snap"]
#
# [[formatters]]
# command = "prettier --stdin-filepath \"{{ filePath }}\""
# patterns = ["**/*.ts", "**/*.tsx"]
# ignore = ["dist/**"]
Sections
[data] — Data file mappings
Maps namespace names to data files. Each entry makes the file’s contents available as template variables under that namespace.
[data]
package = "package.json"
cargo = "Cargo.toml"
config = "config.yaml"
This creates three namespaces:
{{ package.name }}reads frompackage.json{{ cargo.package.version }}reads fromCargo.toml{{ config.database.host }}reads fromconfig.yaml
Paths are relative to the project root (where mdt.toml lives).
Supported formats: JSON, TOML, YAML (.yaml/.yml), and KDL.
See Data Interpolation for full details.
[exclude] — Exclude patterns
Patterns for files and directories to skip during scanning. Uses gitignore-style syntax — the same pattern format as .gitignore files, including negation (!), directory markers (/), wildcards (*, **), and character classes.
[exclude]
patterns = [
"vendor/",
"dist/",
"generated/",
"**/*.generated.md",
"!generated/keep-this.md",
]
These patterns are checked relative to the project root. In addition to your explicit patterns, mdt always skips hidden directories (.git, .vscode, etc.), node_modules/, and target/.
markdown_codeblocks — Skip tags in code blocks
Controls whether mdt tags inside fenced code blocks in source-file comments are processed. This is useful when doc comments contain fenced examples that show mdt tag syntax but should not be treated as real tags.
[exclude]
# Skip tags inside ALL fenced code blocks
markdown_codeblocks = true
# Skip only code blocks whose info string contains "ignore"
markdown_codeblocks = "ignore"
# Skip code blocks whose info string contains any of these
markdown_codeblocks = ["ignore", "example"]
The default is false, meaning tags in fenced source-comment code blocks are processed normally.
blocks — Exclude specific block names
Array of block names to exclude. Any block (source or target) whose name appears in this list is completely ignored.
[exclude]
blocks = ["draft-section", "deprecated-api"]
[include] — Include patterns
Restrict scanning to only files matching these patterns:
[include]
patterns = ["docs/**/*.rs", "src/**/*.ts"]
When set, only files matching at least one include pattern are scanned (in addition to markdown and template files which are always included).
[templates] — Template search paths
By default, mdt finds *.t.md files anywhere in the project. You can restrict where it looks:
[templates]
paths = ["templates", "shared/docs"]
When set, only *.t.md files within these directories are recognized as template files.
[padding] — Block content padding
Controls blank lines between block tags and their content. This is recommended when using target blocks in source code files.
[padding]
before = 0
after = 0
before and after accept false (inline), 0 (next line), 1 (one blank line), 2, etc. When [padding] is present but values are omitted, they default to 1. In source code files, blank lines use the same comment prefix as surrounding lines (e.g., //!, ///, *).
Without this setting, transformers like trim can cause content to merge directly into the surrounding tags, breaking the structure of code comments.
Recommended for projects with formatters: Use before = 0, after = 0 to minimize whitespace that formatters might alter.
[[formatters]] — Formatter-aware update/check pipeline
Formatter entries make mdt update and mdt check converge with your formatter’s canonical full-file output instead of comparing raw injected block text.
This is the recommended long-term fix for the mdt update → formatter → mdt check cycle described in issue #46, and the best way to keep CI green when external formatters rewrite synced files.
Each matching formatter entry:
- reads the full candidate file from stdin
- writes the full replacement file to stdout
- runs from the project root
- runs after block injection during
mdt update - runs before expected-output comparison during
mdt check - runs in declaration order when multiple entries match the same file
command is rendered with minijinja before execution. Available variables:
{{ filePath }}— absolute path to the file being formatted{{ relativeFilePath }}— path relative to the project root{{ rootDirectory }}— absolute project root
patterns and ignore are ordered gitignore-style rule lists. Leading ! entries negate a prior match, so later rules can re-include paths for a single formatter stage.
If a formatter command fails, exits non-zero, or renders an invalid minijinja command template, mdt returns an explicit formatter error instead of silently falling back to unformatted output.
[[formatters]]
command = "dprint fmt --stdin \"{{ filePath }}\""
patterns = ["**/*.md", "!docs/generated/**"]
ignore = ["vendor/**", "docs/generated/**", "!docs/generated/keep.md"]
Repositories without configured formatters keep the legacy fast path, so formatter support only adds work when you opt in.
max_file_size — Safety limit for scanned files
Set the maximum file size (in bytes) that mdt will scan. Files larger than this limit return an error.
max_file_size = 10485760 # 10 MB
If omitted, mdt uses a default of 10 MB.
disable_gitignore — Disable .gitignore integration
By default, mdt respects .gitignore rules when scanning for files, skipping anything that git would ignore. Set disable_gitignore = true to turn off this behavior:
disable_gitignore = true
When this option is enabled, mdt scans all files regardless of .gitignore rules. You can still control which files are scanned using the [exclude] and [include] sections.
When to use this:
- Generated files with mdt blocks — If your build output or generated files contain target blocks that need updating, those files are typically listed in
.gitignorebut still need to be scanned by mdt. - Working outside a git repository — If the project is not a git repo,
.gitignoreresolution can cause unnecessary overhead or errors. Disabling it avoids those issues. - Full control over scanning — When you prefer to manage file inclusion/exclusion entirely through
[exclude]and[include]patterns rather than relying on.gitignore.
If omitted, defaults to false (.gitignore rules are respected).
Sub-project boundaries
If mdt encounters a directory containing its own mdt.toml, it treats that directory as a separate project and skips it. This is useful in monorepos where each package manages its own templates:
my-monorepo/
mdt.toml # root project config
.templates/
template.t.md
packages/
lib-a/
mdt.toml # lib-a is a separate mdt project
.templates/
template.t.md
lib-b/
mdt.toml # lib-b is a separate mdt project
.templates/
template.t.md
Running mdt update from the root updates only the root project’s targets. Each sub-project is managed independently.
Annotated mdt.toml reference
The example below is synced from the repository’s annotated mdt.toml so the config reference and the real config evolve together.
# mdt.toml
#
# This file is intentionally verbose: active entries show one working setup,
# and commented entries document every configuration option currently
# supported by the codebase.
#
# Rule for contributors: when config behavior changes, update this annotated
# file and the synced configuration guide in the same PR.
# Top-level safety limit for scanned files, in bytes.
# Omit this to use the built-in default of 10 MB.
# Raise it for unusually large generated docs; lower it if you want earlier
# failure on oversized files.
# max_file_size = 10485760
# By default mdt respects `.gitignore` so it behaves like the repo itself.
# Set this to `true` only when ignored/generated files should still be scanned,
# or when you want `[include]` and `[exclude]` to be the only scanning rules.
# disable_gitignore = true
# Padding controls the blank lines between tags and injected content.
# Supported values for both `before` and `after`:
# - false -> keep content inline with the tag
# - 0 -> move content to the next line with no blank line
# - 1 -> one blank line
# - 2+ -> two or more blank lines
#
# This repo uses `0`/`0` because it keeps comment-based targets formatter-stable
# without introducing extra blank lines for dprint/rustfmt to rewrite.
[padding] before = 0 after = 0
# `[check]` controls how `mdt check` compares expected vs actual content.
# - "strict" -> byte-for-byte comparison (default)
# - "lenient" -> whitespace-normalized comparison; ignores differences in
# blank line count, trailing whitespace, and table/JSON formatting so
# external formatters do not cause false staleness.
#
# `mdt update` always writes exact bytes regardless of this setting.
#
# This repo uses `lenient` so that dprint can reformat generated targets
# without tripping `mdt check`.
[check] comparison = "lenient"
[data]
# String values are file-backed namespaces.
# The parser is inferred from the extension: `.json`, `.toml`, `.yaml`,
# `.yml`, `.kdl`, and `.ini` are supported.
#
# This repo exposes Cargo metadata as `{{ cargo.package.* }}` so templates can
# stay synchronized with workspace package information.
cargo = "Cargo.toml"
# Typed data sources let you force a parser when the extension is missing,
# unusual, or intentionally generic.
# release = { path = "release-info", format = "json" }
# Script-backed data sources shell out and parse stdout.
# `format` accepts: `text`, `string`, `raw`, `txt`, `json`, `toml`, `yaml`,
# `yml`, `kdl`, or `ini`.
# `watch` lists files that invalidate the cached result in
# `.mdt/cache/data-v1.json`.
#
# Use this when the source of truth comes from tooling instead of a checked-in
# file.
# version = { command = "cat VERSION", format = "text", watch = ["VERSION"] }
# git = { command = "git rev-parse --short HEAD", format = "text" }
[exclude]
# Gitignore-style patterns skip files or directories during scanning.
# Supports `!negation`, trailing `/` for directories, `*`, `**`, and character
# classes.
#
# This repo excludes test-only fixtures and snapshot directories so mdt only
# scans files that can contain real, maintained blocks.
patterns = [ "**/tests/", "**/__tests.rs", "**/snapshots/", ]
# `markdown_codeblocks` only affects fenced code blocks that appear inside
# source-file comments. It exists so docs/examples can show mdt tags without
# accidentally turning those examples into live targets.
#
# Supported values:
# - false -> process tags in fenced code blocks normally (default)
# - true -> ignore tags in all fenced code blocks
# - "..." -> ignore code blocks whose info string contains that substring
# - ["...", ...] -> ignore code blocks whose info string matches any substring
#
# This repo uses `true` because source-comment examples should stay
# illustrative, not executable.
markdown_codeblocks = true
# `blocks` excludes specific block names everywhere, even if their files are
# scanned. Use it when a block name is temporary, experimental, or
# intentionally unmanaged.
# blocks = ["draftSection", "experimentalApi"]
# `include` narrows scanning to only matching files. Use it to opt into a
# smaller search space in large repos once you know exactly where mdt tags live.
# [include]
# patterns = ["docs/**/*.rs", "src/**/_.ts", "packages/_/readme.md"]
# `templates.paths` restricts where `*.t.md` source files are discovered.
# Leave it unset to find template files anywhere in the project.
# Use it when a repo wants a dedicated source-of-truth directory layout.
# [templates]
# paths = [".templates", "shared/templates"]
# `[[formatters]]` lets `mdt update` and `mdt check` compare against your
# formatter's canonical output instead of raw injected text.
#
# Formatter commands are rendered with minijinja before execution.
# Available variables:
# - `{{ filePath }}` -> absolute path to the file being formatted
# - `{{ relativeFilePath }}` -> path relative to the project root
# - `{{ rootDirectory }}` -> absolute path to the project root
#
# `patterns` and `ignore` are both ordered rule lists with gitignore-like
# globs. A leading `!` negates a prior match, so later rules can re-include
# paths.
#
# This repo enables dprint for generated markdown targets to prevent the
# formatter cycle from issue #46, where `mdt update` and `dprint fmt` would
# otherwise keep disagreeing in CI.
[[formatters]] command = "dprint fmt --stdin \"{{ filePath }}\"" patterns = ["**/*.md"] ignore = ["**/*.t.md"]
# Add more formatter stages when different file types need different tools.
# [[formatters]]
# command = "prettier --stdin-filepath \"{{ filePath }}\""
# patterns = ["**/*.ts", "**/*.tsx"]
Data Interpolation
mdt can pull values from project files — package.json, Cargo.toml, YAML configs, and more — into your templates. This means version numbers, package names, and other metadata stay in one place and flow into your documentation automatically.
Setup
Add a [data] section to your mdt.toml:
[data]
package = "package.json"
release = { path = "release-info", format = "json" }
version = { command = "cat VERSION", format = "text", watch = ["VERSION"] }
This maps the file package.json to the namespace package.
- String values are backward-compatible and infer format from extension.
- Typed values (
{ path, format }) let you explicitly declare a format for files without extensions. - Script values (
{ command, format, watch }) execute commands and optionally cache stdout based on watched files.
If your package.json contains:
{
"name": "my-lib",
"version": "1.2.3",
"description": "A great library"
}
Then in your template files you can write:
<!-- {@install} -->
Install `{{ package.name }}` version {{ package.version }}:
npm install {{ package.name }}@{{ package.version }}
{{ package.description }}.
<!-- {/install} -->
After mdt update, targets of install will contain:
Install `my-lib` version 1.2.3:
npm install my-lib@1.2.3
A great library.
Supported data formats
| Format / Extension | Parser |
|---|---|
text, .txt | Raw text string |
json, .json | JSON |
toml, .toml | TOML |
yaml, .yaml | YAML |
yml, .yml | YAML |
kdl, .kdl | KDL |
ini, .ini | INI |
All formats are converted to a common structure internally. You access values using dot notation regardless of the source format.
Script-backed data sources
[data] entries can run shell commands and use stdout as template data. This is useful for values that come from tooling (for example Nix, git metadata, or generated version files).
[data]
release = { command = "cat VERSION", format = "text", watch = ["VERSION"] }
command: shell command executed from the project root.format: parser for stdout (text,json,toml,yaml,yml,kdl,ini).watch: files that control cache invalidation.
When watch files are unchanged, mdt reuses cached script output from .mdt/cache/data-v1.json instead of re-running the command.
- Script outputs are cached per namespace, command, format, and watch list.
- If
watchis empty, mdt re-runs the script every load (no cache hit). - A non-zero script exit status fails data loading with an explicit error.
Inline interpolation patterns
Inline blocks are useful when you need one local value from your data scope without creating a reusable source.
Inline value in prose
Install version <!-- {~releaseVersion:"{{ pkg.version }}"} -->0.0.0<!-- {/releaseVersion} --> today.
Inline value in a table cell
| Package | Version |
| ------- | --------------------------------------------------------------------- |
| mdt | <!-- {~mdtVersion:"{{ pkg.version }}"} -->0.0.0<!-- {/mdtVersion} --> |
Inline value with a transformer
CLI version: <!-- {~cliVersionCode:"{{ pkg.version }}"|code} -->`0.0.0`<!-- {/cliVersionCode} -->
Inline value from a script-backed data source
[data]
release = { command = "cat VERSION", format = "text", watch = ["VERSION"] }
Release: <!-- {~releaseValue:"{{ release }}"} -->0.0.0<!-- {/releaseValue} -->
When VERSION is unchanged, mdt reuses cached script output from .mdt/cache/data-v1.json.
TOML example
# mdt.toml
[data]
cargo = "Cargo.toml"
# Cargo.toml
[package]
name = "my-crate"
version = "0.1.0"
edition = "2024"
Template usage:
<!-- {@crateInfo} -->
**{{ cargo.package.name }}** — Rust edition {{ cargo.package.edition }}
<!-- {/crateInfo} -->
YAML example
# mdt.toml
[data]
config = "config.yaml"
# config.yaml
app:
name: My App
port: 8080
features:
- auth
- logging
Template usage:
<!-- {@appConfig} -->
{{ config.app.name }} runs on port {{ config.app.port }}.
<!-- {/appConfig} -->
Multiple data sources
You can map as many files as you need:
[data]
package = "package.json"
cargo = "Cargo.toml"
config = "config.yaml"
meta = "metadata.kdl"
Each namespace is independent. Use them together in the same template:
<!-- {@versions} -->
| Package | Version |
| ------- | --------------------------- |
| npm | {{ package.version }} |
| crate | {{ cargo.package.version }} |
<!-- {/versions} -->
Template syntax
mdt uses minijinja for template rendering. The full minijinja syntax is available:
Variables
{{ namespace.key }}
{{ namespace.nested.deeply.value }}
Undefined variables render as empty strings (mdt uses minijinja’s “chainable” undefined behavior).
Conditionals
{% if package.private %}
This is a private package.
{% else %}
Available on npm.
{% endif %}
Loops
{% for feature in config.features %}
- {{ feature }}
{% endfor %}
Filters
minijinja’s built-in filters work alongside mdt’s transformers:
{{ package.name | upper }}
{{ package.description | truncate(50) }}
When rendering happens
Template variables are rendered before transformers are applied. The flow is:
Provider content
→ Render {{ variables }} via minijinja
→ Apply |transformers
→ Replace target content
This means transformers operate on the already-rendered content. For example, if {{ package.name }} renders to my-lib, then a |trim transformer trims the rendered result.
No data, no rendering
If your project has no mdt.toml or no [data] section, template variable rendering is skipped entirely. Content containing {{ }} syntax passes through unchanged. This keeps mdt fully backwards-compatible for projects that don’t need data interpolation.
Transformers
Transformers modify source content before it’s injected into a target. They’re specified as pipe-delimited filters on the target tag, letting each target adapt the same content for its specific context.
Syntax
Transformers appear after the block name, separated by |:
<!-- {=blockName|trim|indent:" "} -->
<!-- {/blockName} -->
Multiple transformers are applied left to right. Each receives the output of the previous one.
Arguments
Some transformers take arguments, specified after a : delimiter:
<!-- {=block|indent:">>> "} -->
<!-- {=block|codeBlock:"typescript"} -->
<!-- {=block|replace:"old":"new"} -->
String arguments are quoted. Numeric arguments are unquoted:
<!-- {=block|indent:4} -->
Available transformers
trim
Removes whitespace from both ends of the content.
<!-- {=block|trim} -->
Before: \n Hello world! \n After: Hello world!
trimStart
Removes whitespace from the start of the content.
<!-- {=block|trimStart} -->
Aliases: trim_start
trimEnd
Removes whitespace from the end of the content.
<!-- {=block|trimEnd} -->
Aliases: trim_end
indent
Prepends a string to each non-empty line. Empty lines are preserved as-is by default.
<!-- {=block|indent:" "} -->
Before:
line one
line two
line four
After:
line one
line two
line four
To include empty lines (indent them too), pass true as a second argument:
<!-- {=block|indent:" ":true} -->
With true, every line gets the indent — including empty lines. Without it (default), empty lines stay completely empty.
prefix
Prepends a string to the entire content (not per-line).
<!-- {=block|prefix:"\n"} -->
Before: Hello After: \nHello
suffix
Appends a string to the entire content.
<!-- {=block|suffix:"\n"} -->
Before: Hello After: Hello\n
linePrefix
Prepends a string to each non-empty line. Similar to indent but with a clearer name for the intent.
<!-- {=block|linePrefix:"// "} -->
Before:
line one
line two
After:
// line one
// line two
To also prefix empty lines, pass true as a second argument. This is essential for code comment blocks where every line needs a comment marker:
<!-- {=block|linePrefix:"//! ":true} -->
Before:
A fast HTTP client.
Supports async and blocking modes.
After:
//! A fast HTTP client.
//!
//! Supports async and blocking modes.
Note: when true is set, the prefix is applied to empty lines too, so "//! " on an empty line produces //! (with trailing space). If you need a shorter prefix on empty lines (e.g., //! without the space), use linePrefix:"//! " (without true) and then handle the empty lines separately, or use replace to clean up trailing spaces.
Aliases: line_prefix
lineSuffix
Appends a string to each non-empty line. Empty lines are left empty by default.
<!-- {=block|lineSuffix:" \\"} -->
Before:
line one
line two
After:
line one \
line two \
To also suffix empty lines, pass true as a second argument:
<!-- {=block|lineSuffix:";":true} -->
Aliases: line_suffix
wrap
Wraps the entire content with a string on both sides.
<!-- {=block|wrap:"**"} -->
Before: important text After: **important text**
code
Wraps the content in inline code backticks.
<!-- {=block|code} -->
Before: my-lib After: `my-lib`
codeBlock
Wraps the content in a fenced code block. Optionally specify a language.
<!-- {=block|codeBlock:"typescript"} -->
Before: const x = 1; After:
```typescript
const x = 1;
```
Without a language argument:
<!-- {=block|codeBlock} -->
replace
Replaces all occurrences of a search string with a replacement. Takes exactly two arguments.
<!-- {=block|replace:"foo":"bar"} -->
Before: foo is great, foo forever After: bar is great, bar forever
Chaining transformers
Transformers compose left to right. This is powerful for adapting content to different contexts.
Example: Rust doc comments
Provider content as plain text, transformed into /// doc comments. Use true to ensure empty lines also get the comment prefix:
<!-- {=docs|trim|linePrefix:"/// ":true} -->
<!-- {/docs} -->
If the source contains:
A fast HTTP client.
Supports async and blocking modes.
The target receives:
/// A fast HTTP client.
///
/// Supports async and blocking modes.
Without true, the empty line would be left completely blank, which breaks the doc comment block in Rust.
Example: JSDoc comments
<!-- {=docs|trim|linePrefix:" * ":true} -->
<!-- {/docs} -->
Each line (including empty lines) gets the * prefix, producing valid JSDoc content.
Example: Code block with trimming
<!-- {=example|trim|codeBlock:"rust"} -->
<!-- {/example} -->
Trims the whitespace first, then wraps in a fenced code block.
Naming conventions
All transformers support both camelCase and snake_case names:
| camelCase | snake_case |
|---|---|
trimStart | trim_start |
trimEnd | trim_end |
codeBlock | code_block |
linePrefix | line_prefix |
lineSuffix | line_suffix |
Source File Support
mdt isn’t limited to markdown files. Target tags work inside code comments in any language that supports <!-- --> HTML comments within its comment syntax.
How it works
mdt scans source files for HTML comment patterns (<!-- ... -->) embedded within code comments. The same {=name} / {/name} consumer syntax works regardless of the surrounding comment style.
Supported languages
mdt recognizes these source file extensions:
| Language | Extensions |
|---|---|
| Rust | .rs |
| TypeScript | .ts, .tsx |
| JavaScript | .js, .jsx |
| Python | .py |
| Go | .go |
| Java | .java |
| Kotlin | .kt |
| Swift | .swift |
| C/C++ | .c, .cpp, .h |
| C# | .cs |
Examples by language
Rust doc comments
Keep crate-level documentation in sync with your README:
//! <!-- {=packageDescription|trim} -->
//! A fast, type-safe HTTP client for Rust.
//! <!-- {/packageDescription} -->
pub fn main() {}
For /// doc comments on items, use linePrefix to add the prefix:
#![allow(unused)]
fn main() {
/// <!-- {=apiDocs|trim|linePrefix:"/// "} -->
/// API documentation here.
/// <!-- {/apiDocs} -->
pub fn create_client() {}
}
TypeScript / JavaScript JSDoc
Keep JSDoc in sync with your docs:
/**
* <!-- {=apiDocs|trim|indent:" * "} -->
* Old JSDoc content.
* <!-- {/apiDocs} -->
*/
export function createClient() {
return {};
}
Python docstrings
# <!-- {=moduleDoc|trim} -->
# Module documentation here.
# <!-- {/moduleDoc} -->
def main():
pass
Go comments
// <!-- {=packageDoc|trim|linePrefix:"// "} -->
// Package documentation.
// <!-- {/packageDoc} -->
package mylib
Recommended: Enable [padding]
When using target blocks in source files, add a [padding] section to your mdt.toml:
[padding]
before = 0
after = 0
This ensures content is properly separated from the surrounding tags. The before and after values control how many blank lines appear between tags and content:
false— Content inline with tag (no newline)0— Content on the very next line (recommended for projects using formatters)1— One blank line between tag and content2— Two blank lines, etc.
Without [padding], a target with trim|linePrefix:"//! ":true could produce:
#![allow(unused)]
fn main() {
//! <!-- {=docs|trim|linePrefix:"//! ":true} -->//! Content here.<!-- {/docs}
//! -->
}
With before = 0, after = 0, the output is properly structured:
#![allow(unused)]
fn main() {
//! <!-- {=docs|trim|linePrefix:"//! ":true} -->
//! Content here.
//! <!-- {/docs} -->
}
With before = 1, after = 1, blank lines are added between tags and content:
#![allow(unused)]
fn main() {
//! <!-- {=docs|trim|linePrefix:"//! ":true} -->
//!
//! Content here.
//!
//! <!-- {/docs} -->
}
Key differences from markdown
Lenient parsing
Source file parsing is lenient. If an opening tag has no matching close tag, it’s silently ignored rather than producing an error. This prevents false positives when HTML comments appear in strings or other non-tag contexts.
Source blocks in source files
Source files can only contain consumer blocks. Even if you write {@name} in a source file, it won’t be recognized as a source. Providers must be in *.t.md template files.
Real-world example
Consider a TypeScript library where you want the README, JSDoc, and mdbook docs to stay in sync.
.templates/*.t.md files define the content:
<!-- {@apiDocs} -->
A sample TypeScript library.
## Usage
import { createClient } from "my-lib";
const client = createClient();
<!-- {/apiDocs} -->
readme.md consumes it as-is:
## API
<!-- {=apiDocs} -->
<!-- {/apiDocs} -->
src/index.ts consumes it with transformers for JSDoc formatting:
/**
* <!-- {=apiDocs|trim|indent:" * "} -->
* <!-- {/apiDocs} -->
*/
export function createClient() {
return {};
}
Running mdt update fills both targets. The readme gets the content as-is. The TypeScript file gets the content trimmed and indented with * for JSDoc formatting.
CI Integration
mdt’s check command is designed for CI pipelines. It verifies that all target blocks are up to date and exits with a non-zero status code if any are stale.
Basic CI check
Add a step to your CI workflow that runs mdt check:
- name: check documentation is up to date
run: mdt check
If any target blocks are out of date, the step fails and the pipeline reports which blocks need updating.
CI diagnostics triage
When mdt check fails in CI, add diagnostics commands so logs include root-cause context:
- name: diagnostics
run: |
mdt info
mdt doctor
This gives you:
- Project/config resolution details (
mdt.toml,.mdt.toml,.config/mdt.toml) - Provider/consumer linkage summary (orphans, missing sources, duplicates)
- Cache artifact health and reuse/reparse telemetry
- Actionable doctor hints for config/data/layout/cache issues
GitHub Actions
Full workflow example
name: docs
on:
pull_request:
branches: [main]
jobs:
check-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: install mdt
run: cargo install mdt_cli
- name: check documentation sync
run: mdt check
GitHub Actions annotations
Use --format github to produce GitHub Actions annotation output. This adds inline warnings on the pull request diff showing exactly which files have stale blocks:
- name: check documentation sync
run: mdt check --format github
This produces output like:
::warning file=readme.md::Target block `install` is out of date
GitHub renders these as yellow warning annotations directly on the affected lines in the PR diff.
With diff output
Use --diff to include a unified diff in the CI output showing what changed:
- name: check documentation sync
run: mdt check --diff
JSON output
For integration with other tools, use --format json:
- name: check documentation sync
run: mdt check --format json
mdt check --format json returns:
ok— overall success booleanstale— block-level drift entries withfileandblockstale_files— formatter-only file drift entries withfile
When formatter-aware normalization would change the full file without changing any managed block body, stale_files is populated and stale can remain empty.
Clean output:
{ "ok": true, "stale": [], "stale_files": [] }
Formatter-only drift example:
{
"ok": false,
"stale": [],
"stale_files": [{ "file": "docs/readme.md" }]
}
Pre-commit hook
You can also use mdt as a pre-commit check to prevent committing stale docs:
#!/bin/sh
# .git/hooks/pre-commit
mdt check --format text
if [ $? -ne 0 ]; then
echo ""
echo "Documentation is out of date. Run 'mdt update' before committing."
exit 1
fi
Automated fixes
If you prefer to auto-fix in CI rather than just check, run mdt update and commit the result:
- name: update documentation
run: mdt update
- name: check for changes
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "mdt update produced changes. Please run 'mdt update' locally and commit."
git diff
exit 1
fi
Publish mdBook on release
This repository publishes the mdBook when an mdt_cli release is published on GitHub (or via manual workflow_dispatch). Other crate releases (e.g., mdt_core, mdt_lsp, mdt_mcp) do not trigger a docs deploy.
The workflow lives at .github/workflows/docs-pages.yml and:
- Filters on
mdt_clirelease tags (or manual dispatch) - Builds the book with
mdbook build docs - Uploads
docs/bookas a Pages artifact - Deploys to GitHub Pages
Equivalent workflow structure:
name: docs-pages
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
# Only deploy docs on mdt_cli releases (not library-only releases).
if: >-
github.event_name == 'workflow_dispatch' ||
startsWith(github.event.release.tag_name, 'mdt_cli')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: taiki-e/install-action@v2
with:
tool: mdbook
- uses: actions/configure-pages@v5
- run: mdbook build docs
- uses: actions/upload-pages-artifact@v3
with:
path: docs/book
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/deploy-pages@v4
Benchmark CI (this repository)
This repository also runs .github/workflows/benchmark.yml on pull_request and push to main.
The benchmark job:
- Builds
mdtfor a baseline ref and the candidate ref. - Runs both binaries against the same deterministic workload.
- Compares medians per scenario with relative and absolute thresholds.
- Uploads raw benchmark artifacts and posts a PR comment report.
When regressions exceed threshold, pull requests must include a ## Benchmark Justification section in the PR description to document the tradeoff.
Monorepo & Multi-Project Setups
mdt supports monorepos where each package manages its own templates independently. The key mechanism is sub-project boundaries: any directory containing its own mdt.toml is treated as a separate mdt project.
How sub-project boundaries work
When mdt scans a directory tree, it stops descending into any subdirectory that contains an mdt.toml file. That subdirectory becomes its own isolated scope with its own sources, targets, data files, and configuration.
my-monorepo/
mdt.toml # root project
.templates/
template.t.md # root sources
readme.md # root targets
packages/
lib-a/
mdt.toml # lib-a is a separate project
.templates/
template.t.md # lib-a sources
readme.md # lib-a targets
lib-b/
mdt.toml # lib-b is a separate project
.templates/
template.t.md # lib-b sources
readme.md # lib-b targets
lib-c/
readme.md # NO mdt.toml — belongs to root project
Running mdt update from the monorepo root updates targets in readme.md and packages/lib-c/readme.md, but not in packages/lib-a/ or packages/lib-b/. Those are separate projects.
To update lib-a, run mdt update from inside packages/lib-a/, or use the --path flag:
mdt update --path packages/lib-a
Setting up a monorepo
Step 1: Create an mdt.toml in each package
Each package that needs its own template scope gets an mdt.toml. Even an empty file is enough to establish a boundary:
# packages/lib-a/mdt.toml
Add configuration as needed:
# packages/lib-a/mdt.toml
[data]
cargo = "Cargo.toml"
Step 2: Create template files per package
Each sub-project has its own *.t.md files with its own source blocks:
<!-- packages/lib-a/.templates/template.t.md -->
<!-- {@install} -->
cargo add lib-a
<!-- {/install} -->
<!-- packages/lib-b/.templates/template.t.md -->
<!-- {@install} -->
cargo add lib-b
<!-- {/install} -->
Source names only need to be unique within a project scope. Both lib-a and lib-b can have an {@install} provider without conflict.
Step 3: Run updates per package or use a script
Update each package individually:
mdt update --path packages/lib-a
mdt update --path packages/lib-b
Or use a script to update all packages:
#!/bin/sh
for dir in packages/*/; do
if [ -f "$dir/mdt.toml" ]; then
mdt update --path "$dir"
fi
done
Shared templates across packages
Sub-project boundaries are strict. A source in the root .templates/template.t.md is not visible to consumers inside packages/lib-a/. Each scope is fully isolated.
If you need shared content across packages, you have a few options:
Option 1: Use block arguments for parameterized content
Define a parameterized source at the root level and use it for files that belong to the root scope:
<!-- {@badge:"crate_name"} -->
[](https://crates.io/crates/{{ crate_name }})
<!-- {/badge} -->
For sub-projects, duplicate the source in each sub-project’s template file. This is intentional — each project is self-contained.
Option 2: Duplicate sources where needed
Copy the source block into each sub-project’s template file. While this creates duplication in template files, the target blocks throughout each project stay in sync with their local provider — which is mdt’s primary guarantee.
Option 3: Keep shared content at the root scope
If files consuming shared content don’t live inside a sub-project directory, they can all reference the root-level sources. Structure your project so that shared docs live outside sub-project boundaries.
CI checks in a monorepo
Run mdt check for each sub-project in CI:
- name: check root docs
run: mdt check
- name: check lib-a docs
run: mdt check --path packages/lib-a
- name: check lib-b docs
run: mdt check --path packages/lib-b
Or iterate over all directories that contain mdt.toml:
- name: check all mdt projects
run: |
for dir in . packages/*/; do
if [ -f "$dir/mdt.toml" ]; then
echo "Checking $dir"
mdt check --path "$dir"
fi
done
Data isolation
Each sub-project loads its own data files relative to its own mdt.toml. A [data] section in packages/lib-a/mdt.toml resolves paths relative to packages/lib-a/:
# packages/lib-a/mdt.toml
[data]
cargo = "Cargo.toml" # resolves to packages/lib-a/Cargo.toml
package = "package.json" # resolves to packages/lib-a/package.json
This means {{ cargo.package.name }} in lib-a’s templates refers to lib-a’s Cargo.toml, not the root workspace Cargo.toml.
Block Arguments
Block arguments let you create parameterized source blocks. Instead of defining a separate source for each variation, you define one provider with parameters and pass different values from each target.
Syntax
Provider: declare parameters
Add :"param_name" after the block name to declare parameters:
<!-- {@badges:"crate_name"} -->
[](https://crates.io/crates/{{ crate_name }})
[](https://docs.rs/{{ crate_name }}/)
<!-- {/badges} -->
The parameter name crate_name becomes a template variable available in the source content via {{ crate_name }}.
Consumer: pass values
Consumers pass string values in the same position:
<!-- {=badges:"mdt_core"} -->
<!-- {/badges} -->
When mdt renders this consumer, {{ crate_name }} in the source content is replaced with mdt_core.
Multiple arguments
Providers can declare multiple parameters:
<!-- {@installCmd:"pkg_manager":"pkg_name":"version"} -->
{{ pkg_manager }} install {{ pkg_name }}@{{ version }}
<!-- {/installCmd} -->
Consumers pass values in the same order:
<!-- {=installCmd:"npm":"my-lib":"1.2.3"} -->
<!-- {/installCmd} -->
<!-- {=installCmd:"yarn":"my-lib":"2.0.0"} -->
<!-- {/installCmd} -->
After mdt update, the first consumer contains npm install my-lib@1.2.3 and the second contains yarn install my-lib@2.0.0.
Combining arguments with other features
With transformers
Arguments and transformers work together. Transformers come after the arguments, separated by |:
<!-- {=badges:"mdt_core"|trim} -->
<!-- {/badges} -->
With data interpolation
Block arguments and data interpolation variables coexist in the same source content. Arguments are resolved alongside the data context:
# mdt.toml
[data]
cargo = "Cargo.toml"
<!-- {@crateInfo:"crate_name"} -->
**{{ crate_name }}** v{{ cargo.workspace.package.version }}
<!-- {/crateInfo} -->
Here {{ crate_name }} comes from the target’s argument, while {{ cargo.workspace.package.version }} comes from the data file.
With single quotes
Both single and double quotes work for argument values:
<!-- {@tmpl:'param'} -->
<!-- {=tmpl:'value'} -->
Use cases
Badge links for multiple crates
A common monorepo pattern where each crate needs the same badge markup with different crate names:
<!-- {@badgeLinks:"crateName"} -->
[crate-image]: https://img.shields.io/crates/v/{{ crateName }}.svg
[crate-link]: https://crates.io/crates/{{ crateName }}
[docs-image]: https://docs.rs/{{ crateName }}/badge.svg
[docs-link]: https://docs.rs/{{ crateName }}/
<!-- {/badgeLinks} -->
Each crate’s README passes its own name:
<!-- {=badgeLinks:"mdt_core"} -->
<!-- {/badgeLinks} -->
<!-- {=badgeLinks:"mdt_cli"} -->
<!-- {/badgeLinks} -->
Versioned install snippets
Generate install instructions that pull the crate name from an argument and the version from a data file:
<!-- {@addDep:"dep_name"} -->
Install via cargo: `cargo add {{ dep_name }}`
Or add to Cargo.toml: `{{ dep_name }} = "{{ cargo.workspace.package.version }}"`
<!-- {/addDep} -->
Platform-specific instructions
<!-- {@buildCmd:"platform":"toolchain"} -->
To build on {{ platform }}, install {{ toolchain }} first,
then run: {{ toolchain }} build --release
<!-- {/buildCmd} -->
Argument count mismatch
The number of target arguments must match the number of source parameters. If they don’t match, mdt reports a render error:
error: argument count mismatch: provider `badges` declares 1 parameter(s),
but consumer passes 2 argument(s)
mdt checkreports the mismatch as an error.mdt updateskips the mismatched target and continues with the rest.
Zero arguments on target
A target referencing a parameterized source without arguments also triggers a mismatch. If the source declares parameters, every consumer must supply values:
<!-- Provider expects 1 argument -->
<!-- {@greeting:"name"} -->
Hello, {{ name }}!
<!-- {/greeting} -->
<!-- This target is missing the argument — mdt reports an error -->
<!-- {=greeting} -->
<!-- {/greeting} -->
Zero parameters on source
If a source has no parameters, consumers should not pass arguments. Passing arguments to a parameter-less provider is a mismatch:
<!-- Provider has no parameters -->
<!-- {@simpleBlock} -->
Static content.
<!-- {/simpleBlock} -->
<!-- This target has an unexpected argument — mdt reports an error -->
<!-- {=simpleBlock:"unused"} -->
<!-- {/simpleBlock} -->
Inline Blocks
Inline blocks add source-free interpolation for small dynamic values that still need to stay synchronized.
Why this exists
Inline blocks are useful when you need dynamic content in-place without creating a separate source. Typical examples include versions, toolchain values, environment metadata, and short computed strings.
Inline blocks render minijinja template content from the block’s first argument:
<!-- {~version:"{{ pkg.version }}"} -->0.0.0<!-- {/version} -->
During mdt update, mdt evaluates the template argument with your configured [data] context, then replaces the content between the opening and closing tags.
Because inline blocks are source-free, they are ideal for one-off values that still need to stay synchronized.
Limits and behavior
- Inline blocks must include a first argument that is the template string to render.
- Inline blocks do not resolve source content; everything comes from the inline template argument and current data context.
- Inline rendering still supports transformers (
|trim,|code, etc.) after template evaluation. - In markdown, inline blocks work in normal content (paragraphs, lists, headings, tables) where HTML comments are parsed.
- Tags shown inside fenced markdown code blocks are treated as examples and are not interpreted as live blocks.
- In source files, inline tags follow source scanning rules and respect
[exclude] markdown_codeblocksfiltering.
Practical examples
Inline value in prose
Install version <!-- {~releaseVersion:"{{ pkg.version }}"} -->0.0.0<!-- {/releaseVersion} --> today.
Inline value in a table cell
| Package | Version |
| ------- | --------------------------------------------------------------------- |
| mdt | <!-- {~mdtVersion:"{{ pkg.version }}"} -->0.0.0<!-- {/mdtVersion} --> |
Inline value with a transformer
CLI version: <!-- {~cliVersionCode:"{{ pkg.version }}"|code} -->`0.0.0`<!-- {/cliVersionCode} -->
Inline value from a script-backed data source
[data]
release = { command = "cat VERSION", format = "text", watch = ["VERSION"] }
Release: <!-- {~releaseValue:"{{ release }}"} -->0.0.0<!-- {/releaseValue} -->
When VERSION is unchanged, mdt reuses cached script output from .mdt/cache/data-v1.json.
Comparison to sources
- Use
{@name} ... {/name}when the same content should be reused in many places. - Use
{~name:"..."} ... {/name}when you need localized dynamic output without a dedicated source block.
Benchmarking and Regressions
This project tracks CLI performance continuously in CI.
What CI benchmarks
The benchmark workflow compares two revisions in the same CI job and same Docker container:
baseline: merge-base withmain(for pull requests) or previous commit (for pushes tomain)candidate: the commit under test
Each revision is built in --release mode, then benchmarked against the same deterministic workload.
Scenarios currently include:
check_cold_cleancheck_warm_cleancheck_cold_stalecheck_diff_staleupdate_staleupdate_noop_cleanlist_cleaninfo_clean
Consistency strategy
Absolute runtimes naturally drift as GitHub runners evolve. To keep comparisons stable, we enforce:
- Same machine for both baseline and candidate (single job)
- Same container image (
rust:1.86.0-bookworm) - Same generated workload and iteration counts
- Median-based comparisons with combined thresholds
A regression is only flagged when both are true:
- Relative slowdown exceeds
BENCH_RELATIVE_THRESHOLD_PCT - Absolute slowdown exceeds
BENCH_ABSOLUTE_THRESHOLD_MS
This avoids failing on tiny/noisy deltas.
Regression policy
If benchmark regressions are detected on a pull request, the PR must include a ## Benchmark Justification section in the PR description explaining why the tradeoff is acceptable.
Without this section, the benchmark workflow fails.
Historical records
Each benchmark run uploads artifacts with:
- Baseline results (
baseline.json) - Candidate results (
candidate.json) - Comparison report (
compare.json,compare.md)
CI also posts an updated benchmark report comment on pull requests.
CLI Reference
Global options
mdt [OPTIONS] [COMMAND]
| Option | Description |
|---|---|
--path <DIR> | Set the project root directory. Defaults to the current directory. |
--verbose | Enable verbose output (show source/target counts, file lists). |
--no-color | Disable colored output. Also overrides terminal color detection and color-related environment variables. |
-h, --help | Print help. |
-V, --version | Print version. |
Commands
mdt init
Create a sample .templates/template.t.md file with a getting-started example.
mdt init
mdt init --path ./my-project
If .templates/template.t.md exists (or legacy template.t.md/templates/template.t.md exists), prints a message and exits without overwriting.
Creates a file containing:
<!-- {@greeting} -->
Hello from mdt! This is a source block.
<!-- {/greeting} -->
mdt check
Verify that all target blocks are up to date. Exits with code 1 if any are stale.
mdt check
mdt check --diff
mdt check --format json
mdt check --format github
| Option | Description |
|---|---|
--diff | Show a unified diff for each stale block. |
--format <FORMAT> | Output format: text (default), json, or github. |
Exit codes:
| Code | Meaning |
|---|---|
| 0 | All targets are up to date. |
| 1 | One or more targets are stale. |
Output formats:
text— Human-readable output. Lists stale blocks with file paths, colored headings, and colored diagnostics when the terminal supports color. Includes diff when--diffis set.json— Machine-readable JSON. Includesok,stale, andstale_filesso automation can distinguish block drift from formatter-only file drift.github— GitHub Actions::warningannotations. Produces inline warnings on PR diffs.
JSON payload
mdt check --format json returns:
ok— overall success booleanstale— block-level drift entries withfileandblockstale_files— formatter-only file drift entries withfile
When formatter-aware normalization would change the full file without changing any managed block body, stale_files is populated and stale can remain empty.
Clean output:
{ "ok": true, "stale": [], "stale_files": [] }
Formatter-only drift example:
{
"ok": false,
"stale": [],
"stale_files": [{ "file": "docs/readme.md" }]
}
mdt update
Update all target blocks with the latest source content.
mdt update
mdt update --dry-run
mdt update --watch
| Option | Description |
|---|---|
--dry-run | Show what would be updated without writing files. |
--watch | Watch for file changes and re-run updates automatically. |
In normal mode, prints the number of blocks and files updated:
Updated 3 block(s) in 2 file(s).
If everything is already in sync:
All target blocks are already up to date.
Dry run shows what would change without modifying files:
Dry run: would update 3 block(s) in 2 file(s):
readme.md
src/lib.rs
Watch mode keeps running after the initial update, watching for file changes with 200ms debouncing:
Updated 3 block(s) in 2 file(s).
Watching for file changes... (press Ctrl+C to stop)
File change detected, updating...
All target blocks are already up to date.
Watch mode is not available with --dry-run.
mdt list
Display all provider and target blocks in the project.
mdt list
Output:
Sources:
@installGuide .templates/template.t.md (2 target(s))
@apiDocs .templates/template.t.md (3 target(s))
Targets:
=installGuide readme.md [linked]
=installGuide crates/my-lib/readme.md [linked]
=apiDocs readme.md [linked]
=apiDocs src/lib.rs |trim|indent [linked]
~version readme.md [inline]
=orphanBlock docs/old.md [orphan]
2 source(s), 6 target(s)
Status indicators:
| Status | Meaning |
|---|---|
[linked] | Consumer has a matching source. |
[orphan] | Consumer references a non-existent source. |
[inline] | Inline block renders from its own template argument. |
Transformers are shown after the file path when present.
mdt info
Print a human-readable diagnostics summary for the current project.
mdt info
mdt info --path ./my-project
mdt info --format json
Includes:
- Project root and resolved config path (
mdt.toml,.mdt.toml, or.config/mdt.toml; ornone). - Provider/consumer counts, orphan targets, and unused sources.
- Data namespaces and their configured source files.
- Template file count, discovered template files, and template directory hints.
- Diagnostic totals (errors/warnings) and missing source names.
- Cache observability details (artifact path/health, schema compatibility, hash verification mode, cumulative reuse/reparse totals, and last scan metrics).
mdt doctor
Run project health checks with remediation hints and an explicit pass/warn/fail summary.
mdt doctor
mdt doctor --path ./my-project
mdt doctor --format json
Checks include:
- Config discovery and config parse status.
- Data source loading and parse validity.
- Template layout conventions (
.templates/preferred). - Provider/consumer integrity (duplicates, missing sources, orphan targets, unused sources).
- Parser diagnostics aggregation.
- Cache artifact validity and schema compatibility.
- Cache hash verification mode guidance.
- Cache efficiency trend analysis based on reuse vs reparse telemetry.
Text output prints one line per check with a status tag (PASS, WARN, FAIL, SKIP) and optional hints. JSON output includes ok, summary, and a detailed checks array.
mdt assist
Print an official assistant setup profile. This first slice focuses on practical presets: mdt prints MCP configuration snippets and repo-local guidance rather than introducing a plugin marketplace.
mdt assist claude
mdt assist cursor
mdt assist pi --format json
Supported assistants:
genericclaudecursorcopilotpi
text output includes:
- a ready-to-copy MCP config snippet
- suggested repo-local guidance for your assistant instructions
- assistant-specific notes for the chosen profile
json output returns the same setup information in a machine-readable shape, including mcp_config, repo_guidance, and notes.
Use this command when you want a quick, official starting point for wiring mdt mcp into an assistant workflow.
mdt lsp
Start the language server for editor integration. Communicates over stdin/stdout using the Language Server Protocol.
mdt lsp
The LSP provides:
- Diagnostics — Warnings for stale targets, missing sources, and source blocks in non-template files.
- Completions — Source name suggestions inside target tags. Transformer name suggestions after
|. - Hover — Shows source content when hovering over target tags. Shows consumer count when hovering over source tags.
- Go to definition — Jump from a target tag to its source definition.
- Document symbols — Lists all blocks in the current file.
- Code actions — Quick-fix to update a stale target block in place.
mdt mcp
Start the MCP server for AI integrations. Communicates over stdin/stdout using the Model Context Protocol.
mdt mcp
Use this command when you want an AI assistant to query template sources, targets, and render context directly from your project.
Environment variables
| Variable | Effect |
|---|---|
NO_COLOR | When set (to any value), disables colored output. Same as --no-color. |
CLICOLOR | When set to 0, disables colored output when color is otherwise auto-detected. |
CLICOLOR_FORCE | When set to a non-zero value, forces colored output even when stdout/stderr are not attached to a color terminal. |
MDT_CACHE_VERIFY_HASH | When set (to any value), cache fingerprints include content hashes (in addition to size/mtime) for stricter reuse. |
Template Syntax Reference
All mdt tags are HTML comments. They are invisible when markdown is rendered.
Tag types
Source tag
Defines a named block of content in a template file (*.t.md).
<!-- {@blockName} -->
- Sigil:
@ - Only recognized in
*.t.mdfiles. - The content between the opening and closing tags becomes the source’s content.
Target tag
Marks where source content should be injected.
<!-- {=blockName} -->
<!-- {=blockName|transformer1|transformer2:"arg"} -->
- Sigil:
= - Recognized in any scanned file (markdown or source code).
- Optionally includes transformers after the block name.
Inline tag
Inline blocks are useful when you need dynamic content in-place without creating a separate source. Typical examples include versions, toolchain values, environment metadata, and short computed strings.
Inline blocks render minijinja template content from the block’s first argument:
<!-- {~version:"{{ pkg.version }}"} -->0.0.0<!-- {/version} -->
During mdt update, mdt evaluates the template argument with your configured [data] context, then replaces the content between the opening and closing tags.
Because inline blocks are source-free, they are ideal for one-off values that still need to stay synchronized.
Current limits
- Inline blocks must include a first argument that is the template string to render.
- Inline blocks do not resolve source content; everything comes from the inline template argument and current data context.
- Inline rendering still supports transformers (
|trim,|code, etc.) after template evaluation. - In markdown, inline blocks work in normal content (paragraphs, lists, headings, tables) where HTML comments are parsed.
- Tags shown inside fenced markdown code blocks are treated as examples and are not interpreted as live blocks.
- In source files, inline tags follow source scanning rules and respect
[exclude] markdown_codeblocksfiltering.
Practical examples
Inline value in prose
Install version <!-- {~releaseVersion:"{{ pkg.version }}"} -->0.0.0<!-- {/releaseVersion} --> today.
Inline value in a table cell
| Package | Version |
| ------- | --------------------------------------------------------------------- |
| mdt | <!-- {~mdtVersion:"{{ pkg.version }}"} -->0.0.0<!-- {/mdtVersion} --> |
Inline value with a transformer
CLI version: <!-- {~cliVersionCode:"{{ pkg.version }}"|code} -->`0.0.0`<!-- {/cliVersionCode} -->
Inline value from a script-backed data source
[data]
release = { command = "cat VERSION", format = "text", watch = ["VERSION"] }
Release: <!-- {~releaseValue:"{{ release }}"} -->0.0.0<!-- {/releaseValue} -->
When VERSION is unchanged, mdt reuses cached script output from .mdt/cache/data-v1.json.
Close tag
Closes source, target, and inline blocks.
<!-- {/blockName} -->
- Sigil:
/ - The name must match the opening tag.
Block names
Block names follow identifier rules:
- Start with a letter or underscore
- Followed by letters, digits, or underscores
- Case-sensitive
Valid names: install, apiDocs, my_block, block123, _private
Transformer syntax
Transformers are pipe-delimited and follow the block name:
{=name|transformer1|transformer2:"arg1":"arg2"}
{~name:"{{ value }}"|transformer1|transformer2:"arg1":"arg2"}
Structure
|transformerName — no arguments
|transformerName:"arg" — one string argument
|transformerName:4 — one numeric argument
|transformerName:"a":"b" — two arguments
Argument types
| Type | Syntax | Example |
|---|---|---|
| String | Double-quoted | "hello", "/// ", "\n" |
| Number | Unquoted integer or float | 4, 2.5 |
| Boolean | true or false | true |
String arguments support escape sequences: \", \\, \n, \t.
Whitespace handling
Whitespace between the comment delimiters and the tag braces is allowed:
<!-- { @blockName } -->
Newlines within the comment are also allowed:
<!--
{/blockName}
-->
Content boundaries
The content of a block is everything between the end of the opening tag and the start of the closing tag. This includes surrounding whitespace and newlines:
<!-- {@block} -->
This content includes the newlines above and below.
<!-- {/block} -->
The source content here is \nThis content includes the newlines above and below.\n\n — note the leading newline after the opening tag and the trailing newline before the closing tag. Use the trim transformer on consumers if you want to strip this whitespace.
Template variables
Inside source blocks, minijinja template syntax is available when data files are configured:
Variable output
{{ namespace.key }}
{{ namespace.nested.value }}
Control flow
{% if condition %}...{% endif %}
{% if condition %}...{% else %}...{% endif %}
{% for item in list %}...{% endfor %}
Comments
{# This is a template comment and won't appear in output #}
Template variables are rendered before transformers are applied.
Examples
Minimal
<!-- {@greeting} -->
Hello!
<!-- {/greeting} -->
With transformers
<!-- {=docs|trim|linePrefix:"/// "} -->
Old content.
<!-- {/docs} -->
With template variables
<!-- {@version} -->
Current version: {{ package.version }}
<!-- {/version} -->
Complex chain
<!-- {=apiDocs|trim|replace:"Example":"Usage"|codeBlock:"typescript"} -->
<!-- {/apiDocs} -->
Transformer Reference
Quick reference for all available transformers.
Summary table
| Transformer | Arguments | Description |
|---|---|---|
trim | none | Remove whitespace from both ends |
trimStart | none | Remove whitespace from the start |
trimEnd | none | Remove whitespace from the end |
indent | string (optional), bool (optional) | Prepend string to each line |
prefix | string (optional) | Prepend string to entire content |
suffix | string (optional) | Append string to entire content |
linePrefix | string (optional), bool (optional) | Prepend string to each line |
lineSuffix | string (optional), bool (optional) | Append string to each line |
wrap | string (optional) | Wrap content with string on both sides |
code | none | Wrap in inline code backticks |
codeBlock | language (optional) | Wrap in fenced code block |
replace | search, replacement (both required) | Replace all occurrences |
Alias table
| Primary name | Alias |
|---|---|
trimStart | trim_start |
trimEnd | trim_end |
codeBlock | code_block |
linePrefix | line_prefix |
lineSuffix | line_suffix |
Detailed reference
trim
|trim
Removes leading and trailing whitespace (spaces, tabs, newlines).
Arguments: none
Example:
| Input | Output |
|---|---|
\n hello \n | hello |
trimStart
|trimStart
Removes leading whitespace only.
Arguments: none
trimEnd
|trimEnd
Removes trailing whitespace only.
Arguments: none
indent
|indent:" "
|indent:" ":true
|indent
Prepends the given string to each line. By default, empty lines are left empty. Pass true as a second argument to also indent empty lines.
Arguments: 0-2 (string, optional boolean)
- First argument: the indent string (defaults to empty string)
- Second argument:
trueto include empty lines,falseor omitted to skip them
Example:
Input:
line 1
line 3
With |indent:" " (default — skips empty lines):
line 1
line 3
With |indent:" ":true, every line gets the indent — including empty lines (which become lines containing only the indent string).
prefix
|prefix:"# "
|prefix
Prepends the string to the entire content (once, not per-line).
Arguments: 0-1 string
suffix
|suffix:"\n"
|suffix
Appends the string to the entire content.
Arguments: 0-1 string
linePrefix
|linePrefix:"// "
|linePrefix:"//! ":true
|line_prefix:"// "
Prepends the string to each line. By default, empty lines are left empty. Pass true as a second argument to also prefix empty lines — essential for code comment blocks.
Arguments: 0-2 (string, optional boolean)
- First argument: the prefix string (defaults to empty string)
- Second argument:
trueto include empty lines,falseor omitted to skip them
Example:
Input:
A fast HTTP client.
Supports async and blocking modes.
With |linePrefix:"/// ":true:
/// A fast HTTP client.
///
/// Supports async and blocking modes.
Without true, the empty line would be left blank (breaking Rust doc comments).
lineSuffix
|lineSuffix:" \\"
|lineSuffix:";":true
|line_suffix:" \\"
Appends the string to each line. By default, empty lines are left empty. Pass true as a second argument to also suffix empty lines.
Arguments: 0-2 (string, optional boolean)
- First argument: the suffix string (defaults to empty string)
- Second argument:
trueto include empty lines,falseor omitted to skip them
wrap
|wrap:"**"
Wraps the entire content: prepends and appends the same string.
Arguments: 0-1 string
Example:
| Input | With |wrap:"**" |
|---|---|
bold text | **bold text** |
code
|code
Wraps the content in inline code backticks.
Arguments: none
Example:
| Input | Output |
|---|---|
my-lib | `my-lib` |
codeBlock
|codeBlock:"rust"
|codeBlock
|code_block:"typescript"
Wraps the content in a fenced code block. The optional argument specifies the language.
Arguments: 0-1 string (language identifier)
Example with language:
Input: let x = 1;
Output:
```rust
let x = 1;
```
replace
|replace:"search":"replacement"
Replaces all occurrences of the search string with the replacement.
Arguments: exactly 2 strings (search, replacement)
Example:
| Input | With |replace:"foo":"bar" |
|---|---|
foo and foo | bar and bar |
To delete occurrences, use an empty replacement:
|replace:"unwanted":""
Argument validation
mdt validates transformer arguments at runtime:
| Transformer | Expected args |
|---|---|
trim, trimStart, trimEnd, code | 0 |
prefix, suffix, wrap, codeBlock | 0-1 |
indent, linePrefix, lineSuffix | 0-2 |
replace | exactly 2 |
Passing the wrong number of arguments produces an error:
error: transformer `replace` expects 2 argument(s), got 1
Configuration Reference
mdt is configured via a TOML file in the project root. All sections are optional.
File location
mdt resolves config in the root directory passed via --path (or the current directory if not specified) using this precedence:
mdt.toml.mdt.toml.config/mdt.toml
Sections
[data]
Maps namespace names to data sources. Each key becomes a namespace for template variable access.
[data]
package = "package.json"
cargo = "Cargo.toml"
config = "settings.yaml"
metadata = "data.kdl"
release = { path = "release-info", format = "json" }
version = { command = "cat VERSION", format = "text", watch = ["VERSION"] }
Keys: Any valid TOML key. Used as the namespace prefix in templates ({{ key.field }}).
Values:
- String path (backward-compatible):
pkg = "package.json" - Typed entry with explicit format:
release = { path = "release-info", format = "json" } - Script entry:
version = { command = "cat VERSION", format = "text", watch = ["VERSION"] }
String paths infer format from file extension. Typed entries use format and are useful for files without extensions.
Script-backed data sources
[data] entries can run shell commands and use stdout as template data. This is useful for values that come from tooling (for example Nix, git metadata, or generated version files).
[data]
release = { command = "cat VERSION", format = "text", watch = ["VERSION"] }
command: shell command executed from the project root.format: parser for stdout (text,json,toml,yaml,yml,kdl,ini).watch: files that control cache invalidation.
When watch files are unchanged, mdt reuses cached script output from .mdt/cache/data-v1.json instead of re-running the command.
- Script outputs are cached per namespace, command, format, and watch list.
- If
watchis empty, mdt re-runs the script every load (no cache hit). - A non-zero script exit status fails data loading with an explicit error.
Supported formats:
| Format / Extension | Parser |
|---|---|
json, .json | JSON (serde_json) |
toml, .toml | TOML (converted to JSON internally) |
yaml, .yaml | YAML (serde_yaml_ng) |
yml, .yml | YAML (serde_yaml_ng) |
kdl, .kdl | KDL (converted to JSON internally) |
ini, .ini | INI (serde_ini) |
Other formats produce an error:
error: unsupported data file format: `xml`
help: supported formats: text, json, toml, yaml, yml, kdl, ini
If a referenced file doesn’t exist, mdt produces an error:
error: failed to load data file `missing.json`: No such file or directory
[exclude]
Patterns for files and directories to skip during scanning. Uses gitignore-style syntax — the same pattern format as .gitignore files, including negation (!), directory markers (/), wildcards (*, **), and character classes.
[exclude]
patterns = [
"vendor/",
"dist/",
"**/*.generated.md",
"!dist/important.md",
]
patterns: Array of gitignore-style pattern strings. Matched against file paths relative to the project root.
These patterns are applied in addition to the built-in exclusions:
- Hidden directories (names starting with
.) node_modules/target/- Directories containing their own mdt config file (
mdt.toml,.mdt.toml,.config/mdt.toml) (sub-project boundaries)
markdown_codeblocks: Controls whether mdt tags inside fenced code blocks in source files are processed.
| Value | Behavior |
|---|---|
false (default) | Tags in code blocks are processed normally |
true | Tags in ALL fenced code blocks are skipped |
A string (e.g. "ignore") | Tags in code blocks whose info string contains the string are skipped |
| An array of strings | Tags in code blocks whose info string contains ANY of the strings are skipped |
[exclude]
# Skip tags inside all fenced code blocks
markdown_codeblocks = true
# Or skip only code blocks with specific info strings
markdown_codeblocks = "ignore"
# Or skip code blocks matching any of several info strings
markdown_codeblocks = ["ignore", "example", "no-sync"]
Markdown fenced code blocks are not treated as live tags by markdown parsing, so this option is specifically for source-file comment scanning.
blocks: Array of block names to exclude. Any block (source or target) whose name is in this list is completely ignored during scanning and updating.
[exclude]
blocks = ["draft-section", "deprecated-api"]
[include]
Glob patterns to restrict which files are scanned.
[include]
patterns = ["docs/**/*.rs", "src/**/*.ts"]
patterns: Array of glob strings. When present, only files matching at least one pattern are scanned.
Markdown files (*.md, *.mdx, *.markdown) and template files (*.t.md) are always scanned regardless of include patterns.
[templates]
Directories to search for template files.
[templates]
paths = [".templates", "templates", "shared/docs"]
paths: Array of directory paths relative to the project root.
Canonical recommendation: place provider templates in .templates/. Compatibility: templates/ is also supported.
By default (when this section is absent), mdt finds *.t.md files in the project tree, including .templates/.
[padding]
Controls blank lines between block tags and their content. When absent, no padding is applied. When present, before and after control how many blank lines separate tags from content.
[padding]
before = 0
after = 0
before: Controls blank lines between the opening tag and the content.
after: Controls blank lines between the content and the closing tag.
Both accept the same values:
| Value | Behavior |
|---|---|
false | Content appears inline with the tag (no newline separator) |
0 | Content starts on the very next line (one newline, no blank lines) |
1 | One blank line between the tag and content |
2 | Two blank lines, and so on |
When [padding] is present but before/after are omitted, they default to 1.
In source code files with comment prefixes (e.g., //!, ///, *), blank lines include the comment prefix to maintain valid syntax.
This is especially important for source code files (.rs, .ts, .py, .go, etc.) where target blocks appear inside code comments. Without padding, transformers like trim followed by linePrefix can produce content that merges with the surrounding tags, breaking the code structure.
Example: before = 0, after = 0 — content directly on the next line:
#![allow(unused)]
fn main() {
//! <!-- {=docs|trim|linePrefix:"//! ":true} -->
//! This content stays properly formatted.
//! <!-- {/docs} -->
}
Example: before = 1, after = 1 (default when [padding] is present) — one blank line:
#![allow(unused)]
fn main() {
//! <!-- {=docs|trim|linePrefix:"//! ":true} -->
//!
//! This content stays properly formatted.
//!
//! <!-- {/docs} -->
}
Without [padding], the same setup might produce:
#![allow(unused)]
fn main() {
//! <!-- {=docs|trim|linePrefix:"//! ":true} -->This content merges with the
//! tag.<!-- {/docs} -->
}
Recommended setting for projects with formatters: Use before = 0, after = 0 to minimize whitespace that formatters might alter, ensuring mdt check stays clean after formatting.
max_file_size
Maximum file size in bytes that mdt will scan.
max_file_size = 10485760
If a scanned file exceeds this value, mdt returns an error instead of reading it.
Default value: 10485760 (10 MB).
disable_gitignore
Disables .gitignore integration when scanning for files.
disable_gitignore = true
| Value | Behavior |
|---|---|
false (default) | mdt respects .gitignore patterns and skips files that git would ignore |
true | mdt ignores .gitignore rules and scans all files |
When set to true, file filtering is controlled entirely by the [exclude] and [include] sections. The built-in exclusions (hidden directories, node_modules/, target/, sub-project boundaries) still apply.
Use this when scanning generated files or build output that contains mdt target blocks, when working outside a git repository, or when you want full control over file scanning via [exclude] and [include] patterns.
Type: bool
Default: false
Complete example
# mdt.toml
# Refuse to scan files larger than 10 MB
max_file_size = 10485760
# Respect .gitignore rules (default behavior)
disable_gitignore = false
# Ensure content is properly separated from tags (recommended for source files)
[padding]
before = 0
after = 0
# Map data files to namespaces for template variables
[data]
package = "package.json"
cargo = "my-lib/Cargo.toml"
config = "config.yaml"
# Skip these paths during scanning (gitignore-style patterns)
[exclude]
patterns = [
"vendor/",
"dist/",
"coverage/",
]
blocks = ["draft-section"]
markdown_codeblocks = true
# Only scan source files matching these patterns
[include]
patterns = ["src/**", "docs/**"]
# Only look for templates in this directory
[templates]
paths = ["templates"]
Minimal example
A minimal config for data interpolation only:
[data]
package = "package.json"
No config
If no config file (mdt.toml, .mdt.toml, or .config/mdt.toml) exists, mdt uses defaults:
- No data interpolation (template variables pass through unchanged)
- No extra exclusions (only built-in exclusions apply, no block or code block filtering)
- No include filtering (all scannable files are scanned)
- Templates found anywhere in the project tree
- No padding (content is not adjusted between tags)
max_file_sizedefaults to 10 MB.gitignorerules are respected (disable_gitignoredefaults tofalse)
Troubleshooting
This page covers common errors, debugging techniques, and solutions for issues you might encounter with mdt.
Common errors
Consumer references a missing source
warning: consumer `installGuide` in readme.md has no matching source
Cause: The target tag references a source name that doesn’t exist in any *.t.md file.
Solutions:
- Check for typos in the block name. Names are case-sensitive —
installGuideandinstallguideare different. - Verify the source is in a
*.t.mdfile. Source tags in regular.mdfiles are ignored. - If you’re in a monorepo, confirm the source is in the same project scope. Providers from a parent or sibling project are not visible across
mdt.tomlboundaries. See Monorepo setups. - Run
mdt listto see all discovered sources and targets.
Argument count mismatch
error: argument count mismatch: provider `badges` declares 1 parameter(s),
but consumer passes 2 argument(s)
Cause: The target passes a different number of arguments than the source declares.
Solutions:
- Count the
:"value"segments on both the source and target tags. - If the source declares
<!-- {@badges:"crate_name"} -->(1 parameter), every consumer must pass exactly 1 argument:<!-- {=badges:"mdt_core"} -->. - See Block Arguments for details.
Duplicate source name
error: duplicate source `install`: defined in `docs.t.md` and `api.t.md`
Cause: Two *.t.md files define a source with the same name. Source names must be unique within a project scope.
Solution: Rename one of the sources, or consolidate them into a single template file.
Stale blocks after editing templates
After editing a source’s content in a template file, all targets referencing that provider become stale. mdt check reports them:
Target block `install` in readme.md is out of date.
Target block `install` in src/lib.rs is out of date.
Solution: Run mdt update to sync all targets. During development, use mdt update --watch to auto-sync on file changes.
Debugging techniques
Use mdt check --verbose
Verbose mode shows the full scan results — how many files were scanned, which sources and targets were found, and which blocks are stale:
mdt check --verbose
Use mdt list to see all blocks
mdt list displays every source and target in the project, their file locations, and their link status:
mdt list
Sources:
@installGuide .templates/template.t.md (2 target(s))
@apiDocs .templates/template.t.md (3 target(s))
Targets:
=installGuide readme.md [linked]
=installGuide crates/my-lib/readme.md [linked]
=apiDocs readme.md [linked]
=orphanBlock docs/old.md [orphan]
Orphaned consumers ([orphan]) indicate missing sources. Providers with (0 target(s)) might be unused.
Use mdt check --diff
When blocks are stale, --diff shows exactly what changed:
mdt check --diff
This produces a unified diff for each stale block, making it easy to see whether the change is expected.
Use mdt update --dry-run
Preview what mdt update would change without modifying any files:
mdt update --dry-run
Dry run: would update 3 block(s) in 2 file(s):
readme.md
src/lib.rs
Cache observability and diagnostics
If cache behavior looks suspicious (unexpected reparses, stale cache artifact, inconsistent local vs CI behavior), use:
mdt info
mdt doctor
mdt info shows cache telemetry:
- Artifact path and schema support
- Hash verification mode
- Cumulative reused vs reparsed file totals
- Last scan summary (
full cache hitvsincremental reuse)
mdt doctor adds cache health checks:
Cache Artifactvalidates readability/schema/key compatibilityCache Hash Modeexplains current fingerprint mode and troubleshooting toggleCache Efficiencywarns when reparses dominate over time
For strict cache-key validation during investigation:
MDT_CACHE_VERIFY_HASH=1 mdt check
This includes content hashes in cache fingerprints (in addition to size/mtime). Disable it again for baseline behavior comparisons.
Formatter interference
Code formatters like dprint, Prettier, and rustfmt can reformat content inside target files, which used to create mdt update → formatter → mdt check loops.
Symptoms
mdt checkreports stale blocks after running a formatter.- Running
mdt updatefollowed by the formatter followed bymdt checkalways shows stale blocks. - Whitespace, wrapping, or markdown table changes keep reappearing.
Recommended fix: configure [[formatters]]
Use formatter integration so mdt formats the full updated file before comparing or writing it. This is the long-term fix for the mdt update → formatter → mdt check cycle described in issue #46.
[[formatters]]
command = "dprint fmt --stdin \"{{ filePath }}\""
patterns = ["**"]
ignore = ["docs/generated/**"]
For per-language tools, add multiple entries:
[[formatters]]
command = "prettier --stdin-filepath \"{{ filePath }}\""
patterns = ["**/*.ts", "**/*.tsx"]
[[formatters]]
command = "eslint_d --fix-to-stdout --stdin --stdin-filename \"{{ filePath }}\""
patterns = ["**/*.ts", "**/*.tsx"]
This makes mdt update and mdt check use the same formatter-aware full-file pipeline.
Formatter-only stale files
Formatter-aware checking can also report formatter-only drift. This happens when the formatter would rewrite the full file, but no individual managed block body is stale.
In that case mdt reports the file in stale_files so automation can distinguish surrounding-formatting drift from block-content drift. The CLI JSON output and MCP responses include stale_files for this reason.
Additional mitigations
Set padding to minimize whitespace differences
Use [padding] in mdt.toml to control the exact whitespace between tags and content. This reduces the surface area for formatter conflicts:
[padding]
before = 0
after = 0
See Configuration for details on padding values.
Match transformer output to formatter expectations
If a formatter enforces specific indentation, configure your transformers to produce output that already matches. For example, if your formatter expects tabs:
<!-- {=docs|trim|indent:"\t"} -->
<!-- {/docs} -->
Exclude template files as a temporary workaround
If you cannot enable [[formatters]] yet, excluding *.t.md from your formatter can still reduce drift. This is a workaround, not the preferred long-term setup.
dprint: Add to dprint.json:
{
"excludes": ["**/*.t.md"]
}
Prettier: Add to .prettierignore:
*.t.md
Use ignore comments for especially formatter-sensitive blocks
If a formatter is still rewriting a specific target block in an undesirable way, use your formatter’s ignore mechanism around that block where appropriate.
You can also exclude whole paths from a specific formatter entry with ignore:
[[formatters]]
command = "dprint fmt --stdin \"{{ filePath }}\""
patterns = ["**/*.md", "!docs/generated/**"]
ignore = ["docs/generated/**", "vendor/**", "!docs/generated/keep.md"]
Leading ! entries act as negation rules in both patterns and ignore.
CI integration issues
mdt command not found
If your CI environment doesn’t have mdt installed globally, install it first:
- name: install mdt
run: cargo install mdt_cli
Or run directly from your workspace without installing:
- name: check docs
run: cargo run --bin mdt -- check
The cargo run approach is slower (it compiles on every run) but avoids installation steps. For faster CI, cache the cargo install or use a pre-built binary.
Check fails but works locally
Common causes:
-
Different working directory. mdt resolves paths relative to where it’s run. Use
--pathto be explicit:- run: mdt check --path ./my-project -
Files not checked out. If your CI does a shallow clone, data files referenced in
mdt.tomlmight be missing. Ensure a full checkout:- uses: actions/checkout@v4 with: fetch-depth: 0 -
Formatter ran after templates changed. If CI runs
dprint fmtbeforemdt check, the formatter might alter target content. Runmdt updateafter formatting, or exclude template content from the formatter.
Recommended CI order
When both formatting and mdt checks are in your pipeline, run them in this order:
- name: format
run: dprint fmt
- name: sync templates
run: mdt update
- name: verify everything is clean
run: |
mdt check
git diff --exit-code
This ensures formatting and template sync are both applied, and the final git diff catches any uncommitted changes.
FAQ
Can I use mdt with non-markdown files?
Yes. mdt scans source code files for target tags inside code comments. Supported languages include Rust, TypeScript, JavaScript, Python, Go, Java, Kotlin, Swift, C/C++, and C#. The target tag syntax (<!-- {=name} --> / <!-- {/name} -->) is the same — it just appears within the file’s comment syntax.
For example, in a Rust file:
#![allow(unused)]
fn main() {
//! <!-- {=packageDocs|trim} -->
//! Documentation content injected here.
//! <!-- {/packageDocs} -->
}
See Source File Support for the full list of languages and examples.
What happens if a source is deleted?
Consumers referencing the deleted source become orphaned. Their content is left unchanged — mdt does not clear or modify orphaned targets.
mdt checkwarns about orphaned targets.mdt listshows orphaned targets with the[orphan]status.mdt updateskips orphaned targets and proceeds with the rest.
To fix orphaned targets, either restore the source or remove the target tags from the files that referenced it.
Can multiple sources have the same name?
No. Source names must be unique within a project scope. If two *.t.md files define a source with the same name, mdt reports an error:
error: duplicate source `install`: defined in `docs.t.md` and `api.t.md`
In a monorepo, source names only need to be unique within each sub-project (each directory with its own mdt.toml). Two different sub-projects can both have an {@install} provider without conflict.
How do I keep formatters from mangling template content?
Formatters can interfere with mdt by reformatting content inside target blocks. The main strategies are:
- Exclude
*.t.mdfiles from your formatter so the source-of-truth content is never altered. - Use ignore comments (e.g.,
<!-- dprint-ignore -->) before target blocks in markdown files. - Set
[padding]inmdt.tomlto control whitespace precisely, reducing formatter conflicts. - Match transformer output to what the formatter expects (e.g., use the same indentation style).
See Troubleshooting > Formatter interference for detailed solutions.
Can I use conditional logic in templates?
Yes. mdt uses minijinja for template rendering, which supports conditionals, loops, and filters.
Conditionals
<!-- {@platformInstall} -->
{% if cargo.package.name %}
cargo add {{ cargo.package.name }}
{% endif %}
{% if package.name %}
npm install {{ package.name }}
{% endif %}
<!-- {/platformInstall} -->
Loops
<!-- {@featureList} -->
{% for feature in config.features %}
- {{ feature }}
{% endfor %}
<!-- {/featureList} -->
Filters
minijinja’s built-in filters work in source content:
{{ package.name | upper }}
{{ package.description | truncate(80) }}
See Data Interpolation for full details on template syntax.
Can targets appear inside other targets?
No. mdt does not support nested blocks. Each target block is a flat, non-overlapping region. If you need to compose content, define separate providers and place their consumers sequentially:
<!-- {=header} -->
<!-- {/header} -->
<!-- {=body} -->
<!-- {/body} -->
<!-- {=footer} -->
<!-- {/footer} -->
Do tags affect rendered markdown?
No. mdt tags are HTML comments (<!-- ... -->), which are invisible when markdown is rendered to HTML. Readers of your documentation never see the template machinery.
Can I use mdt without a config file?
Yes. mdt.toml is optional. Without it, mdt still scans for *.t.md template files and processes provider/target blocks. You only need a config file for:
- Data interpolation (
[data]section) - Custom exclude/include patterns
- Template search path restrictions
- Block padding configuration
How does mdt handle binary files?
mdt only scans text files with recognized extensions (.md, .mdx, .markdown, .t.md, and supported source code extensions). Binary files and unrecognized file types are ignored. A max_file_size limit (default 10 MB) prevents accidentally reading very large files.
Can I run mdt on a subset of files?
Not directly — mdt always scans the full project to build the source map. However, you can control the scan scope:
- Use
--pathto target a specific sub-project directory. - Use
[include]patterns inmdt.tomlto restrict which source files are scanned. - Use
[exclude]patterns to skip specific files or directories. - Use
[templates] pathsto limit where mdt looks for*.t.mdfiles.