Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, codeBlock to adapt shared content for each context
  • CI-friendlymdt check exits non-zero when docs are stale, with JSON and GitHub Actions output formats
  • Project diagnosticsmdt info and mdt doctor provide project health, cache observability, and actionable remediation hints
  • Watch modemdt update --watch auto-syncs on file changes during development
  • Human-first editor supportmdt lsp adds diagnostics, completions, hover, and code actions in your editor
  • Agent-friendly automationmdt mcp exposes the same documentation graph to AI assistants via the Model Context Protocol

Installation

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.

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 file
  • mdt.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

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.

  1. Run mdt assist <assistant> to print an official setup profile.
  2. Copy the MCP snippet into your assistant’s configuration.
  3. Add the suggested repo-local guidance to your project instructions.
  4. 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 mcp server 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_reuse or mdt_list before introducing a new source block.
  • Use .templates/ as the canonical template location.
  • Use mdt_preview to inspect source and target output before syncing changes.
  • Run mdt_check after documentation edits and mdt_update when 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

  • generic
  • claude
  • cursor
  • copilot
  • pi

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.md and .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. mdt lets 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.md becomes the source of truth
  • each surface keeps only a target tag
  • mdt check can fail CI when a target is stale

The day-two workflow

Once the migration is done, the maintenance loop is simple:

  1. edit the source block
  2. run mdt update
  3. run mdt check
  4. 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:

PatternRole
*.t.mdTemplate files — only these can contain source blocks
*.md, *.mdx, *.markdownMarkdown 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_codeblocks is 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.md files. A {@name} tag in readme.md is 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-pastemdt
Setup effortNoneMinimal (mdt init)
Drift detectionManual reviewmdt check in CI
Sync effortEdit every copyEdit one source block, run mdt update
Cross-surfaceManualREADME + source docs + docs site from one source
Scales with copiesGets worseStays 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 includesmdt
Works in README.mdNoYes
Works in source-doc commentsNoYes
Works across frameworksNoYes
Works without a build stepSometimesYes (tags are HTML comments)
Data interpolationFramework-specificBuilt-in ({{ pkg.version }})
CI verificationFramework-specificmdt 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 scriptmdt
MaintenanceYou maintain itCommunity-maintained
DeclarativeNo — imperative logicYes — tag-based
CachingYou build itBuilt-in (file fingerprinting)
Editor supportNoneLSP (diagnostics, completions, hover, go-to-definition)
AI integrationNoneMCP server for assistants
Watch modeYou build itmdt update --watch
TransformersYou build themBuilt-in (trim, indent, linePrefix, etc.)
Data interpolationYou build itBuilt-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 enginesmdt
Target use caseGenerate files from templatesSync content across existing files
Preserves surrounding contentNo — replaces entire fileYes — only replaces tagged regions
Works in source commentsNot designed for itBuilt-in
Learning curveTemplate language + configHTML comment tags
Invisible in rendered docsN/A — generates outputYes — 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 from package.json
  • {{ cargo.package.version }} reads from Cargo.toml
  • {{ config.database.host }} reads from config.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 .gitignore but still need to be scanned by mdt.
  • Working outside a git repository — If the project is not a git repo, .gitignore resolution 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 / ExtensionParser
text, .txtRaw text string
json, .jsonJSON
toml, .tomlTOML
yaml, .yamlYAML
yml, .ymlYAML
kdl, .kdlKDL
ini, .iniINI

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 watch is 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:

camelCasesnake_case
trimStarttrim_start
trimEndtrim_end
codeBlockcode_block
linePrefixline_prefix
lineSuffixline_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:

LanguageExtensions
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

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 content
  • 2 — 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 boolean
  • stale — block-level drift entries with file and block
  • stale_files — formatter-only file drift entries with file

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:

  1. Filters on mdt_cli release tags (or manual dispatch)
  2. Builds the book with mdbook build docs
  3. Uploads docs/book as a Pages artifact
  4. 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:

  1. Builds mdt for a baseline ref and the candidate ref.
  2. Runs both binaries against the same deterministic workload.
  3. Compares medians per scenario with relative and absolute thresholds.
  4. 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"} -->

[![crates.io](https://img.shields.io/crates/v/{{ 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"} -->

[![crates.io](https://img.shields.io/crates/v/{{ crate_name }})](https://crates.io/crates/{{ crate_name }})
[![docs.rs](https://docs.rs/{{ crate_name }}/badge.svg)](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

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 check reports the mismatch as an error.
  • mdt update skips 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_codeblocks filtering.

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 with main (for pull requests) or previous commit (for pushes to main)
  • candidate: the commit under test

Each revision is built in --release mode, then benchmarked against the same deterministic workload.

Scenarios currently include:

  • check_cold_clean
  • check_warm_clean
  • check_cold_stale
  • check_diff_stale
  • update_stale
  • update_noop_clean
  • list_clean
  • info_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]
OptionDescription
--path <DIR>Set the project root directory. Defaults to the current directory.
--verboseEnable verbose output (show source/target counts, file lists).
--no-colorDisable colored output. Also overrides terminal color detection and color-related environment variables.
-h, --helpPrint help.
-V, --versionPrint 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
OptionDescription
--diffShow a unified diff for each stale block.
--format <FORMAT>Output format: text (default), json, or github.

Exit codes:

CodeMeaning
0All targets are up to date.
1One 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 --diff is set.
  • json — Machine-readable JSON. Includes ok, stale, and stale_files so automation can distinguish block drift from formatter-only file drift.
  • github — GitHub Actions ::warning annotations. Produces inline warnings on PR diffs.

JSON payload

mdt check --format json returns:

  • ok — overall success boolean
  • stale — block-level drift entries with file and block
  • stale_files — formatter-only file drift entries with file

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
OptionDescription
--dry-runShow what would be updated without writing files.
--watchWatch 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:

StatusMeaning
[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; or none).
  • 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:

  • generic
  • claude
  • cursor
  • copilot
  • pi

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

VariableEffect
NO_COLORWhen set (to any value), disables colored output. Same as --no-color.
CLICOLORWhen set to 0, disables colored output when color is otherwise auto-detected.
CLICOLOR_FORCEWhen set to a non-zero value, forces colored output even when stdout/stderr are not attached to a color terminal.
MDT_CACHE_VERIFY_HASHWhen 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.md files.
  • 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_codeblocks filtering.

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

TypeSyntaxExample
StringDouble-quoted"hello", "/// ", "\n"
NumberUnquoted integer or float4, 2.5
Booleantrue or falsetrue

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

TransformerArgumentsDescription
trimnoneRemove whitespace from both ends
trimStartnoneRemove whitespace from the start
trimEndnoneRemove whitespace from the end
indentstring (optional), bool (optional)Prepend string to each line
prefixstring (optional)Prepend string to entire content
suffixstring (optional)Append string to entire content
linePrefixstring (optional), bool (optional)Prepend string to each line
lineSuffixstring (optional), bool (optional)Append string to each line
wrapstring (optional)Wrap content with string on both sides
codenoneWrap in inline code backticks
codeBlocklanguage (optional)Wrap in fenced code block
replacesearch, replacement (both required)Replace all occurrences

Alias table

Primary nameAlias
trimStarttrim_start
trimEndtrim_end
codeBlockcode_block
linePrefixline_prefix
lineSuffixline_suffix

Detailed reference

trim

|trim

Removes leading and trailing whitespace (spaces, tabs, newlines).

Arguments: none

Example:

InputOutput
\n hello \nhello

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: true to include empty lines, false or 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: true to include empty lines, false or 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: true to include empty lines, false or omitted to skip them

wrap

|wrap:"**"

Wraps the entire content: prepends and appends the same string.

Arguments: 0-1 string

Example:

InputWith |wrap:"**"
bold text**bold text**

code

|code

Wraps the content in inline code backticks.

Arguments: none

Example:

InputOutput
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:

InputWith |replace:"foo":"bar"
foo and foobar and bar

To delete occurrences, use an empty replacement:

|replace:"unwanted":""

Argument validation

mdt validates transformer arguments at runtime:

TransformerExpected args
trim, trimStart, trimEnd, code0
prefix, suffix, wrap, codeBlock0-1
indent, linePrefix, lineSuffix0-2
replaceexactly 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:

  1. mdt.toml
  2. .mdt.toml
  3. .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 watch is 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 / ExtensionParser
json, .jsonJSON (serde_json)
toml, .tomlTOML (converted to JSON internally)
yaml, .yamlYAML (serde_yaml_ng)
yml, .ymlYAML (serde_yaml_ng)
kdl, .kdlKDL (converted to JSON internally)
ini, .iniINI (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.

ValueBehavior
false (default)Tags in code blocks are processed normally
trueTags 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 stringsTags 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:

ValueBehavior
falseContent appears inline with the tag (no newline separator)
0Content starts on the very next line (one newline, no blank lines)
1One blank line between the tag and content
2Two 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
ValueBehavior
false (default)mdt respects .gitignore patterns and skips files that git would ignore
truemdt 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_size defaults to 10 MB
  • .gitignore rules are respected (disable_gitignore defaults to false)

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 — installGuide and installguide are different.
  • Verify the source is in a *.t.md file. Source tags in regular .md files 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.toml boundaries. See Monorepo setups.
  • Run mdt list to 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 hit vs incremental reuse)

mdt doctor adds cache health checks:

  • Cache Artifact validates readability/schema/key compatibility
  • Cache Hash Mode explains current fingerprint mode and troubleshooting toggle
  • Cache Efficiency warns 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 check reports stale blocks after running a formatter.
  • Running mdt update followed by the formatter followed by mdt check always shows stale blocks.
  • Whitespace, wrapping, or markdown table changes keep reappearing.

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 --path to be explicit:

    - run: mdt check --path ./my-project
    
  • Files not checked out. If your CI does a shallow clone, data files referenced in mdt.toml might be missing. Ensure a full checkout:

    - uses: actions/checkout@v4
      with:
        fetch-depth: 0
    
  • Formatter ran after templates changed. If CI runs dprint fmt before mdt check, the formatter might alter target content. Run mdt update after formatting, or exclude template content from the formatter.

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 check warns about orphaned targets.
  • mdt list shows orphaned targets with the [orphan] status.
  • mdt update skips 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:

  1. Exclude *.t.md files from your formatter so the source-of-truth content is never altered.
  2. Use ignore comments (e.g., <!-- dprint-ignore -->) before target blocks in markdown files.
  3. Set [padding] in mdt.toml to control whitespace precisely, reducing formatter conflicts.
  4. 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 --path to target a specific sub-project directory.
  • Use [include] patterns in mdt.toml to restrict which source files are scanned.
  • Use [exclude] patterns to skip specific files or directories.
  • Use [templates] paths to limit where mdt looks for *.t.md files.