npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mdcode-ts

v0.1.0

Published

Keep Markdown code blocks in sync with source files: update, extract, list and run README examples. TypeScript port of mdcode.

Readme

mdcode

npm version

mdcode keeps the code blocks in your Markdown docs in sync with real source files. Point a code block at a file, or a #region inside it, and mdcode update copies the current code into the document. extract writes blocks out to files, list shows them with their metadata, run runs a command against each block, and dump packs them into a tar archive.

npm install --save-dev mdcode-ts

The package installs the mdcode command and a library API. It is a TypeScript port of szkiba/mdcode. New to mdcode? Start with the quick start.

Drop-in Replacement

This TypeScript implementation is designed as a drop-in replacement for the original Go-based szkiba/mdcode. It maintains full CLI compatibility, including all commands, flags, and output formats, while adding bonus features like transform functions and a library API.

Features

  • Extract code blocks from markdown to files
  • List code blocks with metadata and previews (text or JSON format)
  • Update markdown code blocks from source files OR transform with custom functions
  • Validate that blocks map safely onto files before update or extract writes anything
  • Watch documents and their sources, reporting drift, or writing it with --apply, as you edit
  • Run shell commands on code blocks with enhanced control
  • Dump code blocks to tar archives (stdout or file)
  • Support for metadata in code block info strings
  • Filter blocks by language, file, or custom metadata
  • Region extraction using special comments
  • Outline extraction for code structure
  • Quiet mode for cleaner output
  • Short and long flag forms for all options
  • Containment for untrusted markdown: file= paths stay inside their base, and run needs --allow-shell (see Security: Untrusted Markdown)

Why Use mdcode?

The Problem

Documentation examples often become outdated. You write great examples in your README, but as your code evolves, those examples break. Users copy non-working code, get frustrated, and lose trust in your documentation.

The Solution

mdcode keeps each example in a source or test file and copies it into the Markdown:

  1. Write the example as ordinary code, marking the part to show with a #region if it's only part of a file
  2. Point a code block at it, for example ```ts file=src/greet.ts region=greet
  3. Lint and test the code with your project's usual tools, like any other file
  4. Copy it into the Markdown with mdcode update --apply, and fail CI with mdcode update --check when a block has drifted

The source file stays authoritative. The code block is a copy, so it shows whatever passed your checks.

Key Benefits

Keep Examples Fresh

# Change the code and check it as usual
nano src/calculator.js
npm test

# Copy the change into the README
mdcode update --apply README.md

Catch Drift in CI

npm run lint && npm test            # check the code where it lives
mdcode update --check README.md     # exit 1 if a block has drifted from it

One Copy to Maintain

  • The code lives in one file, which your tools already lint, type-check and test
  • The README holds a copy that mdcode update refreshes
  • A block can show a whole file, one #region of it, or an outline of its regions

Writing Examples in the Markdown

mdcode also works the other way. Write a block in the Markdown, then extract it to a file and run a command on it:

# Extract examples from README
mdcode extract README.md -d ./examples

# Run them as tests
mdcode run --allow-shell -l js "node {file}" README.md

This suits documentation-driven development:

  1. Write your README with examples first
  2. Extract code blocks to create skeleton files
  3. Implement the functionality
  4. Update README from working code

Once the files exist, they become the source and mdcode update keeps the README in step. To run Markdown-only blocks in CI, mark them runnable=true; see Validating Runnable Snippets in CI.

mdcode and Snippet Checkers

mdcode copies code between source files and Markdown. It doesn't type-check, lint or compile the code in a fence itself, and it has no editor integration. In the source-first workflow, your project's own tools check the source file before mdcode copies it.

Snippet checkers such as Kiira treat the fence as the source. Kiira extracts TypeScript and JavaScript fences from Markdown and MDX, type-checks them against your project and reports errors on the fence's line, in your editor, on the command line and in CI.

They suit different snippets:

  • An example that should be complete, tested code belongs in a source or test file, and mdcode copies it into the docs.
  • A fragment that only makes sense in the prose, such as a single call or part of a config, can stay in the Markdown, where a snippet checker type-checks it.

For blocks written in the Markdown, the ready-to-copy validate-snippets.mjs script can run your own type checker. It extracts the runnable=true blocks into a temporary workspace with mdcode extract and runs a command such as tsc --noEmit there. A failure names the block on the command line; nothing shows in your editor.

Installation

Global Installation

Install globally to use the mdcode command anywhere:

# Using npm
npm install -g mdcode-ts

# Using pnpm
pnpm install -g mdcode-ts

After installation, you can run mdcode from anywhere:

mdcode --version
mdcode --help
mdcode list README.md

Run Without Installing

No installation required - run directly:

# Using pnpm dlx
pnpm dlx mdcode-ts list README.md
pnpm dlx mdcode-ts extract --lang js docs/*.md

# Using npx
npx mdcode-ts list README.md
npx mdcode-ts --help

Project Installation

Install as a project dependency to use in scripts or via pnpm exec:

# Using pnpm
pnpm add -D mdcode-ts

# Using npm
npm install --save-dev mdcode-ts

After installation, run via pnpm exec:

pnpm exec mdcode list README.md
pnpm exec mdcode extract --lang js docs/*.md

Or add scripts to your package.json:

{
  "scripts": {
    "readme:update": "mdcode update --apply README.md",
    "readme:check": "mdcode update --check README.md",
    "readme:extract": "mdcode extract -d src README.md",
    "readme:list": "mdcode list --json README.md",
    "docs:validate": "mdcode run --allow-shell -l js \"node {file}\" README.md"
  }
}

Then run with:

pnpm readme:update
pnpm readme:extract

Agent Skills

The package ships an Agent Skill for coding agents, sync-markdown-code-blocks, in skills/. It covers the inspect, plan, apply, check workflow, regions, extract, run, dump, transformers, and which commands need approval on untrusted Markdown. The skill's version matches the installed package. To let your agent find it, run TanStack Intent in your project:

npx @tanstack/intent@latest install   # adds a skill-loading block to AGENTS.md
npx @tanstack/intent@latest list      # shows mdcode-ts#sync-markdown-code-blocks

Or point your agent at node_modules/mdcode-ts/skills/sync-markdown-code-blocks/SKILL.md.

Local Development

pnpm install
pnpm build

CLI Usage

Default Behavior

Running mdcode without any subcommand defaults to listing code blocks from README.md:

# These are equivalent:
mdcode
mdcode list README.md

If you provide a filename without a command, it will list blocks from that file:

# These are equivalent:
mdcode docs/API.md
mdcode list docs/API.md

Get Help

# General help
mdcode --help
mdcode -h

# Command-specific help
mdcode list --help
mdcode extract --help
mdcode update --help
mdcode run --help
mdcode dump --help

Check Version

mdcode --version
mdcode -V

List Command

Display code blocks with their metadata and a preview of the content.

Basic Usage

# List all code blocks from README.md (default)
mdcode list

# List blocks from a specific file
mdcode list docs/GUIDE.md

# Read from stdin
cat README.md | mdcode list

JSON Output

--json prints one JSON envelope. Its result.blocks lists every selected block with its name, fence lines, language, metadata and code:

# JSON output
mdcode list --json README.md

# With a filter
mdcode list --json -l js docs/API.md

See JSON Contract for the envelope, an example, and the result of every command.

In the text output, a named block is listed by its name, as in [1] quick start (js).

Filter by Language

# Long form
mdcode list --lang js README.md
mdcode list --lang python docs/guide.md

# Short form
mdcode list -l js README.md
mdcode list -l sql API.md

Filter by File Metadata

--file selects blocks whose file= is exactly the value given; it is not a glob.

# Long form
mdcode list --file app.js README.md
mdcode list --file app.test.js docs/guide.md

# Short form
mdcode list -f app.js README.md
mdcode list -f server.py docs/guide.md

Filter by Custom Metadata

# Long form
mdcode list --meta region=main README.md
mdcode list --meta type=example docs/guide.md

# Short form
mdcode list -m region=main README.md
mdcode list -m type=test API.md

Multiple Filters

Combine filters to narrow results:

# All filters together
mdcode list --lang js --file app.js --meta region=main README.md

# Short forms
mdcode list -l js -f app.js -m region=main README.md

# Filter one JavaScript test file
mdcode list -l js -f app.test.js docs/guide.md

Extract Command

Extract code blocks to files based on their file metadata. A block without file= (an anonymous block) links to no file, so extract skips it; pass --update-source to extract anonymous blocks too.

Extract is non-destructive. Before it writes anything, it checks every target (see Validate Command). When any block breaks a rule, nothing is written and extract exits 1 with an error per block:

  • Several blocks write one file → allowed only when every one declares its own region= and all are in the same language. Two whole-file blocks for one file, even identical ones, a whole-file block beside a region block, a repeated region=, or regions in different languages are refused as ambiguous_target. Two spellings of one file (./a.ts and a.ts, or a symlinked directory inside --dir) count as one file. With several documents, the same rule applies to blocks in different documents that write one file, so docs/a.md and docs/b.md cannot both write src/x.ts whole, nor both write region one of it. With --update-source, anonymous blocks count too: block-1.sh from two documents is one file.
  • An existing file's markers for a declared region are broken → a region that is never closed or overlaps another is malformed_region, one opened more than once is duplicate_region, and one marked only in another language's comment syntax is region_language_mismatch. An invalid region= name is malformed_region too.

When the target file already exists:

  • All blocks for that file declare region= → each region body is spliced in place. Surrounding code, and any regions in the file that the markdown doesn't declare, are preserved.
  • A declared region has no matching #region marker in the file → the region is appended at the end of the file, wrapped in markers written with the block language's comment syntax (//, #, <!-- -->). Existing markers are matched in any of that language's comment styles, so /* #region name */ in a JS file is spliced rather than duplicated.
  • The block has no region= → the file is skipped with a warning, since writing it would replace the whole file. Use --force to overwrite. An overwritten file keeps its final newline, LF or CRLF. A target that is a symlink or not valid UTF-8 is refused, as for a splice, rather than replaced.

Otherwise, files that don't exist yet are created.

file= paths resolve against --dir (default: the current directory) and must stay inside it:

  • Relative file= → written inside --dir. Two spellings of one file (a symlinked directory inside --dir, ./a.ts vs a.ts) are treated as one target.
  • Absolute file=, or one that leads outside --dir through .. or through a symlink → refused as an unsafe_path error. Every target is checked before anything is written, so when any is refused, nothing is written and extract exits 1. To write into ../../shared-tests, point --dir higher and write file= relative to it.
  • No file=, with --update-source → written as block-N.<ext> directly inside --dir. An existing symlink of that name that leads out of --dir is refused too. Without --update-source, the block is skipped.

Whenever a file is skipped (an existing file without --force, a symlinked target, or a target that is not valid UTF-8), extract prints a summary (even under --quiet) and exits with status 2. With --update-source on stdin, the updated markdown is still written to stdout in full first.

Basic Usage

# Extract to current directory
mdcode extract README.md

# Extract from multiple files
mdcode extract docs/*.md

Custom Output Directory

# Long form
mdcode extract --dir output README.md
mdcode extract --dir ./extracted docs/API.md

# Short form
mdcode extract -d output README.md
mdcode extract -d ./build docs/*.md

Quiet Mode

Suppress status messages (only show errors):

# Long form
mdcode extract --quiet README.md

# Short form
mdcode extract -q README.md

# Quiet with custom directory
mdcode extract -q -d output README.md

Filter What to Extract

# Extract only JavaScript files
mdcode extract --lang js README.md
mdcode extract -l js -d ./src docs/*.md

# Extract specific file
mdcode extract --file app.js README.md
mdcode extract -f server.py -d ./src docs/*.md

# Extract with metadata filter
mdcode extract --meta type=component README.md
mdcode extract -m region=main -d ./lib docs/*.md

Combined Examples

# Extract JavaScript files to src/ directory, quietly
mdcode extract -q -l js -d ./src README.md

# Extract Python examples to examples/ directory
mdcode extract -l python -m type=example -d ./examples docs/TUTORIAL.md

Update Source with Generated Filenames

--update-source also extracts anonymous blocks (blocks without file metadata), each as block-<N>.<ext>, and adds that generated filename back to the markdown, so every extracted file is linked to its block. Without it, anonymous blocks are skipped:

# Extract and update README with file metadata
mdcode extract --update-source README.md

# Extract to custom directory and update source
mdcode extract --update-source -d ./examples README.md

# Quiet mode
mdcode extract --update-source -q -d ./src README.md

Before:

```bash
echo "hello"
```

After:

```bash file=block-1.sh
echo "hello"
```

This enables bidirectional sync workflow:

  1. Extract blocks: mdcode extract --update-source README.md
  2. Modify extracted files: nano block-1.sh
  3. Update markdown: mdcode update --apply README.md

--update-source cannot be combined with --check, which writes nothing.

Force Overwrite

Blocks without region= describe a whole file, so extracting one over an existing file replaces it. Those files are skipped by default; --force overwrites them:

# Skipped with a warning if src/demo.ts already exists
mdcode extract README.md

# Overwrite it
mdcode extract --force README.md

--force has no effect on region blocks — those always splice in place.

Check Without Writing

--check works out every target exactly as extract would, then compares it with the file on disk instead of writing it. It exits 0 when every file already holds what extract would write, and 1 with an out_of_sync error for each block whose part of a file would change. Nothing is written, including with --force.

# Would extracting change any file? Pass --force so existing whole files are compared, not skipped
mdcode extract --check --force README.md
✗ Out of sync: line 9: region two in src/b.ts differs from this block; extract would replace it
✗ Out of sync: line 13: src/new.ts does not exist; extract would create it
2 block(s) out of sync with their files. Run mdcode extract to write them, or mdcode update to bring the blocks up to date instead.
  • A region target reports each region that differs, or that the file lacks and extract would append. Regions the block matches are not reported.
  • The comparison is exact. A file that differs from its block only in trailing newlines is out of sync for extract, although update --check accepts it, because extract would rewrite them; the message says only trailing newlines differ.
  • Without --force, an existing whole-file target is reported as skipped, as extract would skip it, and the command exits 2.
  • --check cannot be combined with --update-source.

update --check asks the other question: does each block show its file? CI that wants both directions runs both, or uses the check-sync GitHub Action.

Stdin Behavior with Update Source

When using stdin with --update-source, the updated markdown is written to stdout:

# Read from stdin, output updated markdown to stdout
cat README.md | mdcode extract --update-source > updated.md

# Extract files normally, no source update
cat README.md | mdcode extract -d ./examples

Update Command

Update markdown code blocks from source files or transform them with custom functions.

update never writes the markdown unless you pass --apply. Without it, update works out what would change and reports it, so you (or an agent) can review the change before anything is written.

Plan, Diff, Apply and Check

Each block with file= metadata is read from that file. Pick one mode:

| Mode | What it does | Writes the markdown | |------|--------------|---------------------| | --plan (default) | Lists the blocks that would change, and where their new code comes from | No | | --diff | Prints a unified diff of the markdown changes | No | | --check | Exits 1 when a selected block is out of sync with its source | No | | --stdout | Prints the updated markdown | No | | --apply | Writes the changes to the markdown file in place | Yes |

# What would change?
mdcode update README.md

# Review the changes as a patch
mdcode update --diff README.md

# Write them
mdcode update --apply README.md

# Fail CI when README.md has drifted from its sources
mdcode update --check README.md

# Print the updated markdown, or pipe it through
mdcode update --stdout README.md > UPDATED.md
cat README.md | mdcode update --stdout > UPDATED.md

The modes cannot be combined. --apply needs a markdown file: with stdin, use --stdout. When no block changes, --apply leaves the file untouched. The diff labels both sides with the markdown path, so patch -p0 < changes.diff applies it.

--check reports each drifted block as an out_of_sync error. A file= that cannot be read, or does not exist, is a read_failed error instead, a file= outside the base is unsafe_path, a transformer that throws is transform_failed, and a broken --transform module is invalid_transform. A region= must be opened exactly once in the file, in the block language's comment syntax, and closed: otherwise the block fails with missing_region, duplicate_region, malformed_region or region_language_mismatch. All of them exit 1; with --json the error codes tell them apart (see JSON Contract).

Where file= Is Read From

file= paths resolve against the base directory, which defaults to the markdown file's directory (the current directory when the markdown comes from stdin). The path must stay inside the base: an absolute path, a .. that climbs out of it, or a symlink that leads out of it is refused as an unsafe_path error before anything is read. --base <dir> picks another base, and file= paths then resolve against that directory instead of the markdown's.

A docs/README.md with file=../src/app.js therefore fails by default. Run from the repository root with --base . and write the path as file=src/app.js:

mdcode update --check --base . docs/README.md

When a Read or Transform Fails

By default update stops at the first block whose file= cannot be read (read_failed), is refused (unsafe_path), breaks a region rule, or whose transformer throws (transform_failed). Nothing is written and nothing is printed except that error. The command exits 1, and under --json the envelope has result: null and that one error. mdcode validate lists every such problem at once without stopping; see Validate Command.

--continue-on-error collects every failure and keeps going. A failed block keeps its previous code, and a block whose read failed is still transformed from its original code. Every failed block is reported and the command still exits 1. --apply does not write a document in which any block's file= or region= failed, so the markdown is never left half in sync with its sources; it still writes the other blocks of a document where only a transformer threw, and the other documents.

# Report every broken file= at once instead of stopping at the first
mdcode update --check --continue-on-error README.md

Checking Docs in CI

--check exits 1 both when a block has drifted and when mdcode could not check it, for example a file= it cannot read or a missing document. check-docs-sync.mjs is a ready-to-copy script that tells the two apart. It runs mdcode update --check --json --continue-on-error on each Markdown file you pass, never writes, and names every document and block that is out of sync. With a project configuration, mdcode update --project --check also checks every listed document in one run, naming each one; the script remains the way to tell drift from a broken check by exit code.

| Exit | Meaning | | --- | --- | | 0 | Every document is in sync | | 1 | At least one document is out of sync | | 2 | At least one document could not be checked, mdcode is not on PATH, or no documents were given |

It needs Node 22+ and mdcode-ts 0.1.0 or later, and runs the mdcode on PATH. Copy it into your repository, for example as scripts/check-docs-sync.mjs, then add mdcode-ts as a dev dependency and a script, so npm ci installs mdcode and npm run puts it on PATH:

{
  "scripts": {
    "docs:check": "node scripts/check-docs-sync.mjs README.md docs/guide.md"
  },
  "devDependencies": {
    "mdcode-ts": "^0.1.0"
  }
}

From a local shell:

# With mdcode-ts in devDependencies
npm run docs:check

# Without installing it
npx --yes -p mdcode-ts@^0.1.0 node scripts/check-docs-sync.mjs README.md docs/guide.md

From GitHub Actions, the same script also annotates each out-of-sync line in the pull request:

name: Docs

on: [push, pull_request]

jobs:
  docs-in-sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run docs:check

Quiet Mode

# Long form
mdcode update --apply --quiet README.md

# Short form
mdcode update --apply -q README.md

# Quiet with output redirection
mdcode update -q --stdout README.md > UPDATED.md

--check still reports the blocks that are out of sync under --quiet.

Transform Mode (with Custom Function)

Transform code blocks using a custom JavaScript/TypeScript function:

# Transform with a custom function
mdcode update --apply --transform ./transformers/uppercase-sql.js README.md

# Short form, previewing the result as a diff
mdcode update --diff -t ./transformers/add-headers.js README.md

# Transform and output to file
mdcode update -t ./transformers/format-code.js --stdout README.md > output.md

Example Transformer (uppercase-sql.js):

export default function({tag, meta, code}) {
  if (tag === 'sql') {
    return code.toUpperCase();
  }
  return code;
}

Creating a TypeScript transformer:

// my-transform.ts
import { defineTransform } from 'mdcode-ts';

export default defineTransform(({tag, meta, code}) => {
  // tag: language (e.g., 'js', 'sql', 'python')
  // meta: { file?: string, region?: string }
  // code: the code block content

  if (tag === 'sql') {
    return code.toUpperCase();
  }

  if (meta.file?.includes('.test.')) {
    return `// AUTO-GENERATED\n${code}`;
  }

  return code; // return unchanged
});

Transform with Filters

Combine transformers with filters to target specific blocks:

# Transform only SQL blocks
mdcode update --apply --transform ./uppercase.js --lang sql README.md

# Transform only the blocks for one test file
mdcode update --apply -t ./add-headers.js -f app.test.js docs/API.md

# Transform JavaScript blocks in examples
mdcode update --apply -t ./format.js -l js -m type=example docs/API.md

Region Support

Update specific regions of code:

# Update only 'main' region
mdcode update --apply --meta region=main README.md

# Check only the setup region
mdcode update --check -m region=setup README.md

Validate Command

Check how the selected blocks map onto files before update or extract writes anything. validate reads the markdown and the files it names, writes nothing, and reports every problem at once instead of stopping at the first. It exits 1 when it finds any.

# Would `mdcode update README.md` be able to read every file= and region=?
mdcode validate README.md

# Would `mdcode extract -d out README.md` refuse anything?
mdcode validate --for extract -d out README.md

# Also require every selected block to name its file
mdcode validate --strict README.md

# Every document in mdcode.config.json, as JSON
mdcode validate --project --json

--for picks the command to check for, update by default. Each finding names the document, the block's line and name, the file concerned and the rule, which is the error code:

| Rule | --for update | --for extract | |------|----------------|-----------------| | unsafe_path | file= is empty or leads outside --base | file= is empty, or it or a generated block-N name leads outside --dir | | read_failed | file= does not exist or cannot be read | - | | missing_region | region= is not in the file, or outline=true finds no markers | - (a missing region is appended) | | duplicate_region | region= is opened more than once in the file | Same, in an existing target | | malformed_region | invalid region= name, or its markers are unclosed or do not nest | Same, or two declared regions overlap | | region_language_mismatch | region= is marked only in another language's comment syntax | Same, in an existing target | | ambiguous_target | - | Several blocks write one file, but not each with its own region= in one language; with several documents, this includes blocks in different documents | | missing_file_metadata | --strict: a selected block has no file= | Same |

update and extract enforce every rule here except --strict, which only validate applies, so a document validate passes is one they will not refuse for these reasons. For extract, a region an existing target lacks is appended, not an error. extract can still skip an existing file it would overwrite whole without --force, and a symlinked or non-UTF-8 target it would splice.

$ mdcode validate --for extract doc.md
✗ line 5: out.ts: blocks on lines 5, 9 all write this file, but not every one declares region=; give each block a region= of its own, or a file of its own (ambiguous_target)
✗ line 9: out.ts: blocks on lines 5, 9 all write this file, but not every one declares region=; give each block a region= of its own, or a file of its own (ambiguous_target)
2 problem(s) found; extract would refuse them.

Watch Command

Keep a terminal open while you edit, and watch tells you which documents and blocks drift from their sources as soon as you save.

# Report drift in README.md after each change, without writing
mdcode watch README.md

# Every document in mdcode.config.json; edits to the configuration take effect at the next change
mdcode watch --project

# Write each change into the markdown as it happens
mdcode watch --apply README.md

watch checks the documents when it starts, then again after each change to a document, to a file a block's file= reads, or to the configuration file. Changes that arrive within --debounce (100 ms by default) of each other are checked once. Each check prints one line per drifted block, failed block or written document, or a single ✓ N document(s) in sync line when there is nothing to report:

[14:02:11] Watching 1 document(s) and 2 source file(s). Press Ctrl+C to stop.
[14:02:11] ✓ 1 document(s) in sync
[14:02:30] README.md: ✗ Out of sync: line 12 (greet): js from src/greet.js
  • Without --apply nothing is written; run mdcode update --apply when you are ready.
  • With --apply each check writes the documents whose blocks drifted, as update --apply does, and skips a document while one of its blocks' file= or region= cannot be read. The change event of its own write does not start another check.
  • Files it watches - Each document and every file its selected blocks' file= resolve to inside the base, worked out again after every check, so a file= you add is watched from then on. A file that does not exist yet is watched too, so creating it starts a check. With --project, a document that newly matches documents is not watched until then: creating one does not start a check by itself, but the next check, started by any other change, picks it up.
  • Errors - A file that cannot be read, a broken region and an unsafe path are reported for their block, and an invalid configuration is reported once; watch keeps going and checks again at the next change.
  • Starting and stopping - watch exits 1 before watching anything when it has no documents (no files and no --project or --config), bad flags, or a configuration that cannot be used. Ctrl+C (SIGINT) or SIGTERM stops it with exit 0. It has no --json.

Run Command

Execute shell commands on each code block.

run needs --allow-shell. It passes <command> to the shell once per selected block, and a command such as node {file} executes the block's code, so running it over markdown you did not write runs code you did not write. Without the flag, run fails with invalid_usage before reading any input. The command always comes from your command line; metadata in the markdown never supplies one. See Security: Untrusted Markdown.

Basic Usage

Use {file} as a placeholder for the temporary file path:

# Run node on JavaScript blocks
mdcode run --allow-shell "node {file}" --lang javascript README.md

# Run Python scripts
mdcode run --allow-shell "python {file}" --lang python README.md

# Compile and run C code
mdcode run --allow-shell "gcc {file} -o out && ./out" --lang c docs/guide.md

Filter by Language

# Long form
mdcode run --allow-shell --lang js "node {file}" README.md

# Short form
mdcode run --allow-shell -l js "node {file}" README.md

# Multiple languages (run separately)
mdcode run --allow-shell -l python "python {file}" docs/guide.md
mdcode run --allow-shell -l js "node {file}" docs/guide.md

# run reads one Markdown file; loop for several
for doc in docs/*.md; do mdcode run --allow-shell -l js "node {file}" "$doc"; done

Filter by Name

Select blocks by their name metadata. Every command accepts -n, --name; see Selecting Blocks by Name.

# Long form
mdcode run --allow-shell --name test-example "node {file}" README.md

# Short form
mdcode run --allow-shell -n calculate "python {file}" docs/API.md

# With language filter
mdcode run --allow-shell -l js -n integration-test "node {file}" tests/README.md

Custom Working Directory

Specify where to save temporary files and run commands:

# Long form
mdcode run --allow-shell --dir /tmp/mdcode "node {file}" README.md

# Short form
mdcode run --allow-shell -d ./temp "python {file}" docs/guide.md

# With filters
mdcode run --allow-shell -l js -d ./build "node {file}" README.md

Keep Temporary Files

Preserve temporary directory after execution (useful for debugging):

# Long form
mdcode run --allow-shell --keep "node {file}" README.md

# Short form
mdcode run --allow-shell -k "python {file}" docs/guide.md

# After the blocks, the command prints "Working directory: <path>"

Combined Examples

# Run JavaScript tests with all flags
mdcode run --allow-shell -l js -n test -k -d ./temp "node {file}" README.md

# Run Python examples in custom directory
mdcode run --allow-shell -l python -m type=example -d ./examples "python {file}" docs/guide.md

# Run and keep files, filter by file metadata
mdcode run --allow-shell -k -f "calculator.py" "python {file}" README.md

Advanced Usage

# Lint all JavaScript blocks
mdcode run --allow-shell -l js "eslint {file}" README.md

# Format code blocks
mdcode run --allow-shell -l python "black {file}" docs/guide.md

# Type check TypeScript blocks
mdcode run --allow-shell -l typescript "tsc --noEmit {file}" API.md

# Run tests with coverage
mdcode run --allow-shell -l js -n test "jest --coverage {file}" docs/guide.md

Validating Runnable Snippets in CI

run gives each block its own temporary file, which works for standalone snippets. When snippets import each other, or your linter or test runner expects a directory, use validate-snippets.mjs. It is a ready-to-copy script that extracts the snippets into a fresh workspace and runs your command there.

Only fences marked runnable=true are validated. Every other block, including runnable=false and blocks with no runnable= at all, is left out. Add file= when a snippet needs a particular name, for example so another snippet can import it. Without it, the file is named block-N with an extension for its language:

```js runnable=true name=add file=lib/add.js
export const add = (a, b) => a + b;
```

```js runnable=true name=use-add
import { add } from "./lib/add.js";
console.log(add(1, 2));
```

```js
// Illustration only: not extracted, not run
add(1, 2);
```

For each Markdown file, the script:

  1. creates an empty workspace with mkdtemp, one per document so two documents can use the same file=
  2. runs mdcode extract --meta runnable=true --dir <workspace> into it, so every file= must stay inside the workspace
  3. prints which file came from which block, then runs your command with the workspace as its working directory
  4. removes the workspace afterwards, whether the command passed or failed and when the job is cancelled with SIGINT or SIGTERM. It only ever removes the directory it created.

Your command goes after -- and runs without a shell. If an argument contains {file}, the command runs once per extracted file, with {file} replaced by that file's path in the workspace, and a failure names the block. Otherwise it runs once per document against the whole workspace:

# Run every snippet as a script; a failure names the block
node scripts/validate-snippets.mjs README.md docs/guide.md -- node {file}

# Run your test runner once per document
node scripts/validate-snippets.mjs README.md -- node --test

# Snippets that import your dependencies need a workspace inside the project (gitignore .mdcode-tmp/)
node scripts/validate-snippets.mjs --tmp-dir .mdcode-tmp README.md -- npx tsc --noEmit --allowJs --checkJs lib/add.js

# Keep the workspace to debug a failure
node scripts/validate-snippets.mjs --keep README.md -- node {file}

| Exit | Meaning | | --- | --- | | 0 | Every runnable block passed | | 1 | The command failed for at least one block or document | | 2 | A document could not be extracted, no document has a runnable=true block, the command or mdcode was not found, or the arguments are wrong |

It needs Node 22+ and mdcode-ts 0.1.0 or later, with mdcode on PATH. As with Checking Docs in CI, add mdcode-ts to devDependencies and call the script from an npm script:

{
  "scripts": {
    "docs:snippets": "node scripts/validate-snippets.mjs README.md docs/guide.md -- node {file}"
  },
  "devDependencies": {
    "mdcode-ts": "^0.1.0"
  }
}

In GitHub Actions, the script also annotates each failing block in the pull request:

name: Docs

on: [push, pull_request]

jobs:
  runnable-snippets:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run docs:snippets

The command is trusted: it comes from your package.json or workflow, never from the Markdown. The snippets are not. A command that executes them, as node {file} does, runs their code with the job's permissions. Run it only on Markdown you would run as code, and keep secrets out of jobs that run on pull requests from forks. See Security: Untrusted Markdown.


Dump Command

Create a tar archive of all code blocks.

A file= that would unpack outside the archive's directory (an absolute path, or one that climbs out with .., written with / or \) is refused as an unsafe_path error, and no archive is produced.

Basic Usage (Output to stdout)

# Dump to stdout
mdcode dump README.md > code-blocks.tar

# Pipe to tar command
mdcode dump docs/guide.md | tar -x

Output to File

# Long form
mdcode dump --out archive.tar README.md

# Short form
mdcode dump -o archive.tar docs/API.md

# With custom name
mdcode dump -o examples-$(date +%Y%m%d).tar README.md

Quiet Mode

# Long form
mdcode dump --quiet --out archive.tar README.md

# Short form
mdcode dump -q -o archive.tar docs/guide.md

# Quiet to stdout
mdcode dump -q README.md > archive.tar

Filter What to Dump

# Dump only JavaScript files
mdcode dump --lang js -o js-blocks.tar README.md
mdcode dump -l js -o javascript.tar docs/guide.md

# Dump the blocks for one file= value
mdcode dump --file build.py -o python.tar docs/guide.md
mdcode dump -f server.js -o server.tar README.md

# Dump by metadata
mdcode dump --meta type=example -o examples.tar docs/guide.md
mdcode dump -m region=main -o main.tar API.md

Extract Tar Archive

After creating a tar archive, you can extract it:

# Standard tar extraction
tar -xf code-blocks.tar

# Extract to specific directory
tar -xf code-blocks.tar -C ./extracted

# List contents without extracting
tar -tf code-blocks.tar

Filtering Examples

All commands support the same filtering options. Here are comprehensive filtering examples. --file matches a block's file= exactly, and list, run and dump read one Markdown file, while extract, update and validate take several:

Single Filters

# By language
mdcode list -l js README.md
mdcode extract -l python docs/*.md
mdcode dump -l sql -o queries.tar API.md

# By file metadata
mdcode list -f app.js README.md
mdcode extract -f app.test.js docs/*.md
mdcode run --allow-shell -f server.py "python {file}" README.md

# By custom metadata
mdcode list -m region=main README.md
mdcode extract -m type=example docs/*.md
mdcode update --apply -m author=admin API.md

Multiple Filters (AND Logic)

When you combine filters, ALL filters must match:

# Language AND file
mdcode list -l js -f app.js README.md

# Language AND metadata
mdcode extract -l python -m type=example docs/*.md

# File AND metadata
mdcode dump -f server.js -m region=main -o server.tar README.md

# All three filters
mdcode list -l js -f app.js -m region=main README.md

Complex Filtering Scenarios

# Extract one JavaScript test file
mdcode extract -l js -f app.test.js -d ./tests docs/*.md

# List Python examples in main region
mdcode list -l python -m type=example -m region=main docs/guide.md

# Run tests only for specific component
mdcode run --allow-shell -l js -f "auth.test.js" -n "login-test" "node {file}" README.md

# Update only SQL queries in specific file
mdcode update --apply -l sql -f queries.sql README.md

Selecting Blocks by Name

Give a block a stable name with name=, then select it by that name from any command. Names are unique within one markdown document, so each --name picks out at most one block; repeat --name to select several. Elsewhere, the document path plus the name identifies the block.

```js name="quick start" file="examples/getting started.js"
console.log('Hello, world!');
```
mdcode list --name "quick start" README.md
mdcode extract -n "quick start" -d ./out README.md
mdcode update --apply --name "quick start" README.md
mdcode update --check -n "quick start" -n setup README.md
mdcode run --allow-shell -n "quick start" "node {file}" README.md
mdcode dump --name "quick start" -o quick-start.tar README.md

update fails with invalid_usage when no selected block has a given name, so a misspelt name cannot make --check pass by checking nothing.


Project Configuration

A repository can list the Markdown documents it keeps in sync, and the defaults for them, in mdcode.config.json. update and extract read it when you pass --project, which looks in the current directory, or --config <path>. Without either flag no configuration is read, and stdin stays the default input. Configuration needs Node 22.17 or later.

A complete consumer repository:

my-project/
├── mdcode.config.json
├── package.json
├── README.md        # ```ts file=src/greet.ts
├── docs/
│   └── guide.md     # ```ts file=src/add.ts region=main
└── src/
    ├── greet.ts
    └── add.ts

The file is plain JSON and is never executed:

{
  "documents": ["README.md", "docs/**/*.md"],
  "sourceRoot": ".",
  "outputRoot": "build/snippets",
  "filter": { "lang": "ts" }
}
  • documents - Markdown paths or globs, in Node's fs.glob syntax. Each entry must match at least one file. Documents are read in entry order, each glob's matches sorted, and each document once. ** also descends into node_modules, so prefer docs/**/*.md to **/*.md.
  • sourceRoot - The directory update resolves every document's file= paths against, in place of each markdown file's own directory. Here . lets docs/guide.md write file=src/add.ts.
  • outputRoot - The directory extract writes to, as --dir does.
  • filter - Default lang, file and meta filters, as the flags of the same name take them. name is refused, because block names belong to one document; pass --name instead.

Every path is relative to the configuration file's directory and must stay inside it. An absolute path, a .., or a symlink that leads out is refused as unsafe_path.

Command-Line Precedence

What you pass on the command line replaces the configuration's value for that run. Command-line paths resolve against the current directory, as without a configuration.

| On the command line | Replaces | | --- | --- | | Markdown files | documents | | --base <dir> | sourceRoot | | -d, --dir <dir> | outputRoot | | --lang, --file, --meta | The matching filter field. --meta replaces all of filter.meta |

Several Documents

update and extract work through the documents in order, and with more than one, every line of text output starts with the document it is about. --stdout needs exactly one document.

update --apply writes every document or none of them: when one fails, nothing is written. With --continue-on-error, update carries on past a failed document and applies the others. extract writes nothing until every document has been checked: when any document breaks a rule, or two documents write one file in a way that rule refuses, nothing is written for any of them. It still stops at a document that fails while writing, after extracting the ones before it. validate --for extract runs the same check across its documents.

Under --json, result.documents holds one entry per document, and each error names its document; see JSON Contract.

Invalid Configuration

A missing file, invalid JSON, an unknown field, a value of the wrong type, or a documents entry that matches nothing fails with invalid_config before any document is read. The message names the file and the field:

Error: mdcode.config.json: unknown field "documnets"; expected documents, sourceRoot, outputRoot, filter
Error: mdcode.config.json: documents[1] "guide/*.md" matched no files in /work/my-project

Locally and in CI

# Plan, check and apply every configured document
mdcode update --project
mdcode update --project --check
mdcode update --project --apply

# One document, with the configured roots and filters
mdcode update --project --diff docs/guide.md

# Extract the configured documents' blocks into outputRoot
mdcode extract --project

# A configuration somewhere else
mdcode update --config config/mdcode.config.json --check

Add mdcode-ts and the scripts to package.json:

{
  "scripts": {
    "docs:check": "mdcode update --project --check",
    "docs:sync": "mdcode update --project --apply"
  },
  "devDependencies": {
    "mdcode-ts": "^0.1.0"
  }
}

Then check the documents on every push and pull request:

name: Docs

on: [push, pull_request]

jobs:
  docs-in-sync:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run docs:check

CI/CD Integration

mdcode fits into CI in three stages. Each one stands alone, and each builds on the one before:

  1. Check the docs are in sync. Fail when a block has drifted from the file= it points at. See Checking Docs in CI, or Project Configuration to list the documents once in mdcode.config.json.
  2. Run the runnable snippets. Fail when a block written in the Markdown and marked runnable=true no longer runs. See Validating Runnable Snippets in CI.
  3. Gate the release on both. Publish only after your tests and both docs checks pass, as below.

Stages 1 and 2 give you two npm scripts, docs:check and docs:snippets. Run them on every pull request so drift fails the change that caused it, then run them again before publishing so nothing stale reaches npm.

Gating a Release

When you publish from your own machine, the smallest gate is prepublishOnly. npm publish runs it first and stops without publishing if it fails:

{
  "scripts": {
    "test": "node --test",
    "docs:check": "node scripts/check-docs-sync.mjs README.md docs/guide.md",
    "docs:snippets": "node scripts/validate-snippets.mjs README.md docs/guide.md -- node {file}",
    "prepublishOnly": "npm test && npm run docs:check && npm run docs:snippets"
  },
  "devDependencies": {
    "mdcode-ts": "^0.1.0"
  }
}

When GitHub Actions publishes, put the checks in their own job and make the publish job need it. Each check is a named step, so a failed run names the gate that stopped it, and the publish job never starts. This workflow publishes when you push a v* tag, using npm trusted publishing, so no npm token is stored:

name: Release

on:
  push:
    tags: ['v*']

permissions:
  contents: read

jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - name: 'Gate: tests'
        run: npm test
      - name: 'Gate: docs in sync'
        run: npm run docs:check
      - name: 'Gate: runnable snippets'
        run: npm run docs:snippets

  publish:
    needs: checks
    runs-on: ubuntu-latest
    environment: npm
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org
      # Trusted publishing needs npm 11.5.1 or later; Node 22 ships npm 10
      - run: npm install -g npm@latest
      - run: npm ci
      - run: npm run build --if-present
      # The checks job already passed; skip prepublishOnly so snippets never run here
      - run: npm publish --ignore-scripts

Before the first run, add a trusted publisher for your package on npmjs.com, naming this repository, the workflow file and the npm environment. To publish with a token instead, store it as a repository secret, drop id-token: write, and pass it to npm publish as NODE_AUTH_TOKEN.

Only the publish job can mint an npm credential. The checks job runs the snippets, which is running code from the Markdown, so it gets a read-only token and no id-token. That is why the publish job builds explicitly and publishes with --ignore-scripts: a plain npm publish would run a prepublishOnly like the one above, executing the snippets again in the job that can mint the credential. --ignore-scripts also skips prepare and prepack, so run any step they did as its own step before publishing.

mdcode-ts gates its own releases the same way; see its RELEASING.md.

GitHub Action: check-sync

The check-sync action fails a job when Markdown code blocks and the files they link to disagree, in either direction, and writes nothing:

  • files → Markdown runs mdcode update --check: does each block show its file?
  • Markdown → files runs mdcode extract --check --force: would extracting the blocks change a file? See Check Without Writing.

The same drift seen from both sides is reported once, as both. Each problem becomes an error annotation on the document and line, naming the direction, the block's name=, the file and the region, and the job summary lists them all in a table.

name: Docs

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  check-sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: adrianbrowning/mdcode-ts/.github/actions/check-sync@<commit-sha> # mdcode-ts@<version>
        with:
          documents: |
            README.md
            docs/*.md

Pin the action to a full commit SHA, and note the release it belongs to in a comment. The repository publishes no moving v1-style tags: each release is tagged mdcode-ts@<version>, and the commit that tag points at is the one to pin. To find it:

git ls-remote https://github.com/adrianbrowning/mdcode-ts 'refs/tags/mdcode-ts@*'

The action runs the mdcode-ts release that its commit belongs to, from npm (npx --yes mdcode-ts@<version>), so pinning the action pins the CLI too. A commit between releases runs the last release, which may lack a flag the action needs; pin a release commit.

| Input | Default | Meaning | |-------|---------|---------| | documents | | Markdown files to check, one path or glob per line, so paths may contain spaces | | directions | update extract | Which directions to check | | base | | For update, the directory file= resolves against (--base); default each document's own directory | | dir | | For extract, the same (--dir); default base, else each document's own directory, so both directions read the same files | | project | false | Use mdcode.config.json (--project): its documents when documents is empty, its sourceRoot and outputRoot, its filters | | config | | Use this configuration file instead (--config) | | working-directory | . | Where to run | | node-version | 22 | Node.js to set up with actions/setup-node; empty uses the runner's | | mdcode-command | | Run mdcode with this command instead, such as npx mdcode for the version in your lockfile |

Its problems output is the number of problems found. The step exits 1 when there is any, and 2 when the inputs are wrong, such as a document that does not exist or a pattern that matches nothing.

The action needs only contents: read, and it is safe on pull requests from forks: it runs no code from the Markdown, writes no file, and reads only files inside each document's directory or base, under the containment rules. Run it on pull_request, never pull_request_target, so a fork's change runs without your repository's secrets.

GitHub Action: update-readme

The update-readme action treats the source files as authoritative. It runs mdcode update --apply on the selected documents and opens one pull request holding only their Markdown changes, or refreshes the one it opened before:

name: Update docs

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write

concurrency:
  group: update-docs

jobs:
  update-readme:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: adrianbrowning/mdcode-ts/.github/actions/update-readme@<commit-sha> # mdcode-ts@<version>
        with:
          documents: |
            README.md
            docs/*.md

What it does, in order:

  1. Checks out the tip of base-branch (default: the repository's default branch) in a temporary git worktree, so the caller's checkout is never changed and the update always sits on that tip.
  2. Runs mdcode update --apply there. Only Markdown is written; source files never are, and the commit is refused if it would change any file other than the documents update wrote.
  3. Commits to branch (default mdcode/update-docs) and force-pushes it, unless the branch already holds exactly this change on this tip, so a rerun with nothing new pushes nothing.
  4. Opens a pull request from branch into base-branch, or updates the title and body of the open one. The body lists every block that changed and the file it came from.

When every block is already in sync, it pushes nothing and closes its open pull request, if there is one: that pull request no longer matches the sources. When a block's file= or region= cannot be read, it opens nothing and exits 1 with an annotation per block. It never pushes to base-branch; setting branch to the same name is an error.

| Input | Default | Meaning | |-------|---------|---------| | documents | | Markdown files to update, one path or glob per line | | base | | The directory file= resolves against (--base); default each document's own directory | | project, config | | Use mdcode.config.json or this configuration file, as for check-sync | | branch | mdcode/update-docs | The branch the action owns and force-pushes | | base-branch | the default branch | The branch the pull request targets | | title, commit-message | docs: update code blocks from their source files | | | author-name, author-email | github-actions[bot] | The commit's author | | token | github.token | Pushes the branch and opens the pull request | | working-directory, node-version, mdcode-command | | As for check-sync. A relative mdcode-command path resolves inside the temporary worktree |

Outputs: changed (true when the pull request was opened or refreshed), pull-request-number and pull-request-url.

Permissions and safety:

  • The job needs contents: write and pull-requests: write, and with the default github.token the repository setting Allow GitHub Actions to create and approve pull requests must be on.
  • Pull requests opened with github.token do not start workflows, so your checks will not run on them. To have them run, pass a GitHub App token (for example from actions/create-github-app-token) or a fine-grained personal access token with contents and pull requests write as token.
  • Run it on push to your default branch, a schedule or workflow_dispatch: events whose Markdown has already been reviewed. Never run it on pull_request_target or on a fork's code; the action holds a write token, and a pull request's Markdown decides which files update reads.
  • It runs no code from the Markdown and offers no --transform. It reads only files inside each document's directory or base, under the containment rules.

CLI Flags Reference

All commands support these common flags:

  • -l, --lang <lang> - Filter by language
  • -f, --file <file> - Select blocks whose file= is exactly this value (not a glob)
  • -m, --meta <key=value> - Select blocks with this metadata; repeat to require several, as in -m type=example -m region=main. Each --meta takes one pair, so a file name after it is still read as the document
  • -n, --name <name> - Select the block with this name metadata; repeat to select several
  • --json - Print one versioned JSON envelope instead of text (every command except watch); see JSON Contract

Additional flags by command:

extract:

  • -d, --dir <dir> - Directory that file= paths resolve against and must stay inside (default: the configuration's outputRoot, else the current directory)
  • -q, --quiet - Suppress status messages
  • --update-source - Also extract blocks without file=, each as block-<N>.<ext>, and add that file= to them in the markdown. Without it, those blocks are skipped
  • --force - Overwrite existing files whose blocks have no region= (skipped by default)
  • --check - Write nothing; exit 1 when a target differs from what extract would write. See Check Without Writing
  • --project - Load mdcode.config.json from the current directory; see Project Configuration
  • --config <path> - Load this configuration file instead

update:

  • -q, --quiet - Suppress status messages
  • -t, --transform <file> - Path to transformer function
  • --base <dir> - Directory that file= paths resolve against and must stay inside (default: the configuration's sourceRoot, else the markdown file's directory, or the current directory for stdin)
  • --continue-on-error - Report every failed document, read and transform and keep going, instead of stopping at the first
  • --plan - List the blocks that would change, without writing (default)
  • --apply - Write the changes to the markdown files in place
  • --diff - Print a unified diff of the changes, without writing
  • --check - Exit 1 when a selected block is out of sync, without writing
  • --stdout - Print the updated markdown of one document, without writing
  • --project - Load mdcode.config.json from the current directory; see Project Configuration
  • --config <path> - Load this configuration file instead

validate:

  • --for <command> - The command to check the documents for: update (default) or extract
  • --strict - Require file= metadata on every selected block
  • --base <dir> - With --for update: as for update
  • -d, --dir <dir> - With --for extract: as for extract
  • --project, --config <path> - As for update and extract

watch:

  • --base <dir> - As for update
  • --apply - Write drifted blocks into the markdown after each change
  • --debounce <ms> - Wait this long after the last change before checking (default: 100)
  • --project, --config <path> - As for update

run:

  • --allow-shell - Confirm that <command> may run through the shell once per selected block (required)
  • -k, --keep - Keep temporary directory
  • -d, --dir <dir> - Custom working directory

dump:

  • -o, --out <file> - Output file (default: stdout; required with --json)
  • -q, --quiet - Suppress status messages

Security: Untrusted Markdown

mdcode is often pointed at markdown it did not write: a pull request, a downloaded README, or a document an agent produced. It treats the markdown as untrusted and whoever runs it as trusted.

  • Untrusted: everything in the markdown, meaning the block code and the info strings with their metadata (file=, region=, name= and the rest).
  • Trusted: what the caller supplies. That is the command-line flags, the --base and --dir directories, the --transform module, and the <command> given to run.

Containment

  • update reads a file= only when it resolves inside the base: the markdown file's directory by default, the current directory for stdin, or the directory given with --base.
  • extract writes a target only when it resolves inside --dir, and that includes the block-N.<ext> names --update-source generates.
  • For update and extract, an absolute file=, one that climbs out with .., and one that leads out through an existing symlink anywhere along the path, the file itself included, are refused as unsafe_path. The check runs before anything is read or written.
  • dump adds an entry only when its name stays inside the archive's directory. An absolute name, or one that climbs out with .. using / or \, is refused as unsafe_path.
  • run takes its command from its argument only. Each block's code is written to a temporary file named block-<index><ext>, with the extension taken from a fixed table by language, and that path is substituted for {file}. Metadata never supplies a command or a file name.

The library functions apply the same checks: update() against basePath, extract() against outputDir, and dump() against the archive root.

Failure Policy

A refusal leaves no partial work behind. extract checks every target before writing any, and dump checks every entry before building the archive, so one unsafe or ambiguous target fails the whole command with exit 1. update stops at its first failed read, broken region rule, unsafe path or transform and writes nothing. With --continue-on-error it reports every failure instead, --apply writes only the documents whose every file= and region= could be read, and the command still exits 1. validate reports every problem for a document without writing anything.

Running Code

run passes <command> to the shell once per selected block. When the command executes its file, as node {file} does, run executes the block's code, so running it over untrusted markdown runs untrusted code with your permissions. --allow-shell is the acknowledgement: without it, run fails with invalid_usage before reading any input. The library run() function needs no such flag, because calling it is already explicit.

validate-snippets.mjs follows the same model. Its command comes only from its own command line, and a command that executes the extracted snippets runs untrusted code.

Limits

mdcode is not a sandbox.

  • The path checks are check-then-use. A symlink swapped in between the check and the read or write is not detected, so containment holds only while nothing else is changing the directory tree.
  • A --transform module is arbitrary code and runs inside the mdcode process.
  • The run command is arbitrary shell and is not sandboxed. mdcode does not inspect or validate block code.

JSON Contract

Every command except watch, which prints a line per change until you stop it, accepts --json. With it, the command prints exactly one JSON object, the envelope, on stdout and nothing else: no colours and no progress text. Without --json, the output is text.

Envelope

  • version - The contract version, currently 1. It changes only when the contract changes incompatibly. The library exports it as CONTRACT_VERSION.
  • command - The command that ran: list, extract, update, validate, run or `d