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-tsThe 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.
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.
- 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
updateorextractwrites 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, andrunneeds--allow-shell(see Security: Untrusted Markdown)
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.
mdcode keeps each example in a source or test file and copies it into the Markdown:
- Write the example as ordinary code, marking the part to show with a
#regionif it's only part of a file - Point a code block at it, for example
```ts file=src/greet.ts region=greet - Lint and test the code with your project's usual tools, like any other file
- Copy it into the Markdown with
mdcode update --apply, and fail CI withmdcode update --checkwhen a block has drifted
The source file stays authoritative. The code block is a copy, so it shows whatever passed your checks.
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.mdCatch 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 itOne 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 updaterefreshes - A block can show a whole file, one
#regionof it, or an outline of its regions
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.mdThis suits documentation-driven development:
- Write your README with examples first
- Extract code blocks to create skeleton files
- Implement the functionality
- 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 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.
Install globally to use the mdcode command anywhere:
# Using npm
npm install -g mdcode-ts
# Using pnpm
pnpm install -g mdcode-tsAfter installation, you can run mdcode from anywhere:
mdcode --version
mdcode --help
mdcode list README.mdNo 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 --helpInstall 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-tsAfter installation, run via pnpm exec:
pnpm exec mdcode list README.md
pnpm exec mdcode extract --lang js docs/*.mdOr 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:extractThe 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-blocksOr point your agent at node_modules/mdcode-ts/skills/sync-markdown-code-blocks/SKILL.md.
pnpm install
pnpm buildRunning mdcode without any subcommand defaults to listing code blocks from README.md:
# These are equivalent:
mdcode
mdcode list README.mdIf 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# General help
mdcode --help
mdcode -h
# Command-specific help
mdcode list --help
mdcode extract --help
mdcode update --help
mdcode run --help
mdcode dump --helpmdcode --version
mdcode -VDisplay code blocks with their metadata and a preview of the content.
# 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 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.mdSee 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).
# 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--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# 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.mdCombine 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.mdExtract 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 repeatedregion=, or regions in different languages are refused asambiguous_target. Two spellings of one file (./a.tsanda.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, sodocs/a.mdanddocs/b.mdcannot both writesrc/x.tswhole, nor both write regiononeof it. With--update-source, anonymous blocks count too:block-1.shfrom 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 isduplicate_region, and one marked only in another language's comment syntax isregion_language_mismatch. An invalidregion=name ismalformed_regiontoo.
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
#regionmarker 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--forceto 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.tsvsa.ts) are treated as one target. - Absolute
file=, or one that leads outside--dirthrough..or through a symlink → refused as anunsafe_patherror. Every target is checked before anything is written, so when any is refused, nothing is written andextractexits 1. To write into../../shared-tests, point--dirhigher and writefile=relative to it. - No
file=, with--update-source→ written asblock-N.<ext>directly inside--dir. An existing symlink of that name that leads out of--diris 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.
# Extract to current directory
mdcode extract README.md
# Extract from multiple files
mdcode extract docs/*.md# 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/*.mdSuppress 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# 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# 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 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.mdBefore:
```bash
echo "hello"
```After:
```bash file=block-1.sh
echo "hello"
```This enables bidirectional sync workflow:
- Extract blocks:
mdcode extract --update-source README.md - Modify extracted files:
nano block-1.sh - Update markdown:
mdcode update --apply README.md
--update-source cannot be combined with --check, which writes nothing.
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 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
extractwould 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, althoughupdate --checkaccepts it, becauseextractwould rewrite them; the message saysonly trailing newlines differ. - Without
--force, an existing whole-file target is reported as skipped, asextractwould skip it, and the command exits 2. --checkcannot 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.
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 ./examplesUpdate 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.
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.mdThe 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).
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.mdBy 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--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.mdFrom 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# 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 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.mdExample 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
});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.mdUpdate 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.mdCheck 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.
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.mdwatch 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
--applynothing is written; runmdcode update --applywhen you are ready. - With
--applyeach check writes the documents whose blocks drifted, asupdate --applydoes, and skips a document while one of its blocks'file=orregion=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 afile=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 matchesdocumentsis 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;
watchkeeps going and checks again at the next change. - Starting and stopping -
watchexits 1 before watching anything when it has no documents (no files and no--projector--config), bad flags, or a configuration that cannot be used. Ctrl+C (SIGINT) orSIGTERMstops it with exit 0. It has no--json.
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.
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# 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"; doneSelect 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.mdSpecify 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.mdPreserve 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>"# 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# 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.mdrun 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:
- creates an empty workspace with
mkdtemp, one per document so two documents can use the samefile= - runs
mdcode extract --meta runnable=true --dir <workspace>into it, so everyfile=must stay inside the workspace - prints which file came from which block, then runs your command with the workspace as its working directory
- 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:snippetsThe 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.
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.
# Dump to stdout
mdcode dump README.md > code-blocks.tar
# Pipe to tar command
mdcode dump docs/guide.md | tar -x# 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# 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# 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.mdAfter 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.tarAll 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:
# 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.mdWhen 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# 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.mdGive 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.mdupdate fails with invalid_usage when no selected block has a given name, so a misspelt name
cannot make --check pass by checking nothing.
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'sfs.globsyntax. 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 intonode_modules, so preferdocs/**/*.mdto**/*.md.sourceRoot- The directoryupdateresolves every document'sfile=paths against, in place of each markdown file's own directory. Here.letsdocs/guide.mdwritefile=src/add.ts.outputRoot- The directoryextractwrites to, as--dirdoes.filter- Defaultlang,fileandmetafilters, as the flags of the same name take them.nameis refused, because block names belong to one document; pass--nameinstead.
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.
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 |
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.
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
# 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 --checkAdd 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:checkmdcode fits into CI in three stages. Each one stands alone, and each builds on the one before:
- 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 inmdcode.config.json. - Run the runnable snippets. Fail when a block written in the Markdown and marked
runnable=trueno longer runs. See Validating Runnable Snippets in CI. - 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.
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-scriptsBefore 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.
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/*.mdPin 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.
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/*.mdWhat it does, in order:
- 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. - Runs
mdcode update --applythere. Only Markdown is written; source files never are, and the commit is refused if it would change any file other than the documentsupdatewrote. - Commits to
branch(defaultmdcode/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. - Opens a pull request from
branchintobase-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: writeandpull-requests: write, and with the defaultgithub.tokenthe repository setting Allow GitHub Actions to create and approve pull requests must be on. - Pull requests opened with
github.tokendo not start workflows, so your checks will not run on them. To have them run, pass a GitHub App token (for example fromactions/create-github-app-token) or a fine-grained personal access token with contents and pull requests write astoken. - Run it on
pushto your default branch, ascheduleorworkflow_dispatch: events whose Markdown has already been reviewed. Never run it onpull_request_targetor on a fork's code; the action holds a write token, and a pull request's Markdown decides which filesupdatereads. - It runs no code from the Markdown and offers no
--transform. It reads only files inside each document's directory orbase, under the containment rules.
All commands support these common flags:
-l, --lang <lang>- Filter by language-f, --file <file>- Select blocks whosefile=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--metatakes one pair, so a file name after it is still read as the document-n, --name <name>- Select the block with thisnamemetadata; repeat to select several--json- Print one versioned JSON envelope instead of text (every command exceptwatch); see JSON Contract
Additional flags by command:
extract:
-d, --dir <dir>- Directory thatfile=paths resolve against and must stay inside (default: the configuration'soutputRoot, else the current directory)-q, --quiet- Suppress status messages--update-source- Also extract blocks withoutfile=, each asblock-<N>.<ext>, and add thatfile=to them in the markdown. Without it, those blocks are skipped--force- Overwrite existing files whose blocks have noregion=(skipped by default)--check- Write nothing; exit 1 when a target differs from whatextractwould write. See Check Without Writing--project- Loadmdcode.config.jsonfrom 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 thatfile=paths resolve against and must stay inside (default: the configuration'ssourceRoot, 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- Loadmdcode.config.jsonfrom 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) orextract--strict- Requirefile=metadata on every selected block--base <dir>- With--for update: as forupdate-d, --dir <dir>- With--for extract: as forextract--project,--config <path>- As forupdateandextract
watch:
--base <dir>- As forupdate--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 forupdate
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
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
--baseand--dirdirectories, the--transformmodule, and the<command>given torun.
updatereads afile=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.extractwrites a target only when it resolves inside--dir, and that includes theblock-N.<ext>names--update-sourcegenerates.- For
updateandextract, an absolutefile=, one that climbs out with.., and one that leads out through an existing symlink anywhere along the path, the file itself included, are refused asunsafe_path. The check runs before anything is read or written. dumpadds 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 asunsafe_path.runtakes its command from its argument only. Each block's code is written to a temporary file namedblock-<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.
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.
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.
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
--transformmodule is arbitrary code and runs inside the mdcode process. - The
runcommand is arbitrary shell and is not sandboxed. mdcode does not inspect or validate block code.
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.
version- The contract version, currently1. It changes only when the contract changes incompatibly. The library exports it asCONTRACT_VERSION.command- The command that ran:list,extract,update,validate,runordump.ok-truewhenerrorsis empty.result- What the command did, described below. It isnullwhen the command failed before doing any work: invalid metadata, bad flags, an invalid configuration, unreadable input, a transform module that could not be loaded, a refusal fromextract, anunsafe_pathrefusal fromdump, orupdatestopping at its first failed block. Forextractandupdate, it isnullwhen no document got as far as a result.errors- Everything that went wrong. A command can fail for some blocks and still report a result for all of them.
Each error has a code and a message. These fields are added when they apply:
document- The Markdown document concerned, as named on the command line or relative to the current directory; left out for stdinline- The 1-based line of the opening fence of the block concernedname- The name of the block concerned, when it has onepath- The file concerned: an extract target, afile=source, the transform module, or an output path
| Code | Meaning |
|---|---|
invalid_metadata |
A block's info string breaks the metadata grammar, or two blocks share a name |
invalid_usage |
Bad flags or flag combinations, including unknown options |
invalid_config |
mdcode.config.json is missing, is not valid JSON, has an unknown field or a value of the wrong type, or a documents entry matched nothing |
io_error |
Reading the markdown or writing an output failed |
invalid_transform |
The --transform module could not be loaded or has no default function export |
extract_skipped |
extract left a target file untouched |
read_failed |
update could not read a block's file= or region |
transform_failed |
update's transformer threw for a block |
unsafe_path |
A block's file= is empty, absolute or leads outside the allowed base, directly or through a symlink; or a configuration path leaves the configuration's directory |
ambiguous_target |
Several selected blocks write one extract target, but not each with its own region= in one language |
missing_region |
update: a block's region= is not in its file=, or outline=true finds no region markers |
duplicate_region |
A block's region= is opened more than once in its file |
malformed_region |
A block's region= is not a valid name, or its markers are never closed, overlap, or do not nest |
region_language_mismatch |
A block's region= is marked only in another language's comment syntax |
missing_file_metadata |
validate --strict: a selected block has no file= |
out_of_sync |
update --check found a selected block that differs from its source, or extract --check found a target that differs from what it would write |
command_failed |
run's command exited non-zero for a block |
unexpected_error |
Anything else |
Results and errors point at a block with name and line:
nameis the block's stable identifier, taken from itsname=metadata. It is unique within a document and does not change when other parts of the document are edited. It isnullfor an unnamed block, and errors leave it out.lineis the 1-based line of the block's opening fence. It locates the block in this version of the document only. Any edit above the block moves it, so it is not an identifier.
mdcode does not generate IDs or derive them from positions. To refer to a block durably, give it a
name=.
The schema below is written out from the types the library exports (Envelope, ResultError,
ErrorCode, BlockRef, ListedBlock, ExtractTarget, UpdatedBlock, RunBlockResult and
DumpedFile). Under --json, errors moves from a command's result to the envelope, and the CLI adds
the fields only it knows about, such as document, written and out.
interface Envelope<R> {
version: 1;
command: "list" | "extract" | "update" | "run" | "dump";
ok: boolean;
/** null when the command failed before doing any work */
result: R | null;
errors: Array<ResultError>;
}
interface ResultError {
code: ErrorCode;
message: string;
/** The Markdown document concerned; left out for stdin */
document?: string;
/** 1-based line of the opening fence of the block concerned */
line?: number;
/** Name of the block concerned, when it has one */
name?: string;
/** An extract target, a file= source, the transform module, an output path */
path?: string;
}
type ErrorCode =
| "invalid_metadata"
| "invalid_usage"
| "invalid_config"
| "io_error"
| "invalid_transform"
| "extract_skipped"
| "read_failed"
| "transform_failed"
| "unsafe_path"
| "ambiguous_target"
| "missing_region"
| "duplicate_region"
| "malformed_region"
| "region_language_mismatch"
| "missing_file_metadata"
| "out_of_sync"
| "command_failed"
| "unexpected_error";
interface BlockRef {
/** The block's name= metadata; null for an unnamed block */
name: string | null;
/** 1-based line of the opening fence */
line: number;
}
// mdcode list --json
type ListEnvelope = Envelope<{
blocks: Array<BlockRef & {
/** 1-based line of the closing fence */
endLine: number;
lang: string;
meta: Record<string, string>;
code: string;
}>;
}>;
// mdcode extract --json
type ExtractEnvelope = Envelope<{
/** One entry per document, in the order they were read */
documents: Array<{
/** As named on the command line, or relative to the current directory; null for stdin */
document: string | null;
targets: Array<{
path: string;
/** "unchanged" appears only with --check, which writes nothing: the other actions say what extract would do */
action: "written" | "spliced" | "skipped" | "unchanged";
/** The blocks that target this file, in document order */
blocks: Array<BlockRef>;
/** The region= names written */
regions: Array<string>;
/** Why the target was skipped */
reason?: string;
}>;
/** --update-source on stdin: the updated markdown */
updatedSource?: string;
/** --update-source on a file: the markdown file that was rewritten */
written?: string;
}>;
}>;
// mdcode update --json
type UpdatedBlock = BlockRef & {
lang: string;
/** Whether the block's code is different in the resulting markdown */
changed: boolean;
/** The block's code in the resulting markdown */
code: string;
/** Set when the block's code was read from its file= */
read?: { file: string; region?: string; outline?: true };
/** Whether the transformer changed the code */
transformed: boolean;
};
type UpdatedDocument = {
/** As named on the command line, or relative to the current directory; null for stdin */
document: string | null;
blocks: Array<UpdatedBlock>;
};
type UpdateEnvelope = Envelope<{
/** One entry per document, in the order they were read */
documents: Array<
/** Default, --plan and --check */
| UpdatedDocument
/** --apply: the markdown file written, or null when no block changed */
| UpdatedDocument & { written: string | null }
/** --diff: a unified diff of the markdown, empty when no block changed */
| UpdatedDocument & { diff: string }
/** --stdout, one document only: the updated markdown */
| UpdatedDocument & { source: string }
>;
}>;
// mdcode validate --json
type ValidateEnvelope = Envelope<{
operation: "extract" | "update";
/** One entry per document, in the order they were read */
documents: Array<{
/** As named on the command line, or relative to the current directory; null for stdin */
document: string | null;
blocks: Array<BlockRef & {
lang: string;
/** The target extract would write, or the file= update would read; null for none */
path: string | null;
region?: string;
/** false when an error concerns this block */
valid: boolean;
}>;
}>;
}>;
// mdcode run --allow-shell --json
type RunEnvelope = Envelope<{
/** Where block files were written; removed afterwards unless --keep or --dir was given */
workingDir: string;
blocks: Array<BlockRef & {
lang: string;
/** 0 on success; a command killed by the timeout reports 1 */
exitCode: number;
stdout: string;
stderr: string;
}>;
}>;
// mdcode dump --json --out <file>
type DumpEnvelope = Envelope<{
/** The archive path given with --out */
out: string;
files: Array<BlockRef & {
/** The entry's path inside the archive: the block's file=, or a generated block-N name */
path: string;
/** Size of the entry in bytes */
size: number;
}>;
}>;list- One entry per selected block.endLineis the line of the closing fence, andmetaholds every metadata key, includingnameandfile.extract- One entry indocumentsper document, each with one entry per target file, in the order they were processed.writtenmeans the file was created or overwritten whole,splicedmeans regions were replaced or appended in an existing file, andskippedmeans the file was left untouched;reasonsays why, and each skipped target also adds anextract_skippederror. With--update-source, when a block gainedfile=: from stdin,updatedSourceholds the updated markdown; from a file, the file is rewritten andwrittenholds its path. A document that fails stops the command; the documents before it have their entries. With--checknothing is written:unchangedmeans the file already holds whatextractwould write, the other actions say whatextractwould do, and each block whose part of a target would change adds anout_of_syncerror withpathset to the target. A document that fails under--checkdoes not stop the others.update- One entry indocumentsper document, each with one entry per selected block, in document order.changedandcodeare the plan: which blocks would change, and the exact code each would get. Only--applywrites the markdown;writtenholds its path, ornullwhen nothing changed and the file was left alone, or a block'sfile=orregion=failed.diffholds the unified diff under--diff, andsourcethe updated markdown under--stdout. Under--check, each changed block adds anout_of_syncerror, withpathset to itsfile=when it has one. With--continue-on-error, a block whosefile=cannot be read keeps its original code, which is still passed to the transformer, a block whose transformer throws keeps the code it had before the transform, and a document that fails is reported and skipped. Without it, the first such failure stops the command,--applywrites nothing, and a document that never got a result has no entry.validate-operationis the command the documents were checked for. One entry indocumentsper document, each with one entry per selected block, in document order.pathis the file the block maps to: forextract, the target joined onto--dir, ornullfor a block withoutfile=, whichextractskips; forupdate, itsfile=as written;nullwhen it maps to no file.validisfalsewhen an error concerns the block, and each problem adds an error whose code names the rule.run- One entry per selected block, with the command's exit code and output. Each block whose command failed adds acommand_failederror.dump-dump --jsonneeds--out <file>. The archive is written to that file and never encoded into the JSON. Without--out, the command fails withinvalid_usage.
mdcode list --json guide.md on this document:
# Guide
```js name=hello file=hello.js
console.log("hello");
```
```sh
echo hi
```{
"version": 1,
"command": "list",
"ok": true,
"result": {
"blocks": [
{
"name": "hello",
"line": 3,
"endLine": 5,
"lang": "js",
"meta": {
"name": "hello",
"file": "hello.js"
},
"code": "console.log(\"hello\");"
},
{
"name": null,
"line": 7,
"endLine": 9,
"lang": "sh",
"meta": {},
"code": "echo hi"
}
]
},
"errors": []
}mdcode run --allow-shell --json "sh {file}" checks.md, where the second block fails, exits 1:
# Checks
```sh name=passes
echo ok
```
```sh
echo "boom" >&2
exit 3
```{
"version": 1,
"command": "run",
"ok": false,
"result": {
"workingDir": "/work/.mdcode-tmp",
"blocks": [
{
"name": "passes",
"line": 3,
"lang": "sh",
"exitCode": 0,
"stdout": "ok\n",
"stderr": ""
},
{
"name": null,
"line": 7,
"lang": "sh",
"exitCode": 3,
"stdout": "",
"stderr": "boom\n"
}
]
},
"errors": [
{
"code": "command_failed",
"message": "command exited with code 3",
"line": 7
}
]
}mdcode list --json broken.md, where two blocks share a name and one has an unterminated quote,
fails before doing any work, so result is null:
# Broken
```js name=setup
let a = 1;
```
```js name=setup file="unterminated.js
let b = 2;
```{
"version": 1,
"command": "list",
"ok": false,
"result": null,
"errors": [
{
"code": "invalid_metadata",
"message": "duplicate name \"setup\" on lines 3, 7; names must be unique within a document",
"line": 3
},
{
"code": "invalid_metadata",
"message": "unterminated quoted value for \"file\"; add the closing \"",
"line": 7
}
]
}mdcode dump --json guide.md, without --out:
{
"version": 1,
"command": "dump",
"ok": false,
"result": null,
"errors": [
{
"code": "invalid_usage",
"message": "dump --json needs --out <file> for the archive"
}
]
}The envelope works with jq:
# Languages used in a document
mdcode list --json README.md | jq -r '.result.blocks[].lang' | sort | uniq -c
# Blocks in the main region
mdcode list --json README.md | jq '.result.blocks[] | select(.meta.region == "main")'Exit codes are the same with and without --json:
| Code | Meaning |
|---|---|
0 |
Success; for update --check and extract --check, everything is in sync |
1 |
Any error, including update --check or extract --check finding drift, validate finding a problem, and extract refusing a target before writing |
2 |
extract skipped one or more targets |
list --jsonused to print one JSON object per block (NDJSON). It now prints the envelope; read the blocks fromresult.blocks.runexits 1 when any block's command fails. It used to exit 0.updateexits 1 when afile=read or the transformer fails. It used to exit 0.updateno longer writes the markdown file by default. It prints a plan; pass--applyto write. Under--json, the default result no longer haswritten, and every block carries itscode.run --keepprintsWorking directory: <path>after the blocks instead of before them.- A transform module that cannot be loaded is reported as
could not load transform file: .... - The library functions return structured results and print nothing; see API Reference.
runneeds--allow-shell. Without it,runfails withinvalid_usagebefore reading any input.updatestops at the firstfile=read, unsafe path or transformer failure, writes nothing and reports only that error. It used to report every failure and carry on; pass--continue-on-errorfor that.updateresolvesfile=against the markdown file's directory (or--base <dir>) and refuses paths that lead outside it, including absolute paths and symlinks.file=../src/app.jsfrom adocs/folder used to work; run with--base .from the repository root and writefile=src/app.jsinstead.extractrefuses afile=that is absolute or leads outside--dir, including through a symlink, asunsafe_path. A relativefile=used to be honoured even when it left--dir, and an absolute one was skipped with exit 2. Now every target is checked first, and when any is refused nothing is written andextractexits 1.dumprefuses afile=that would unpack outside the archive (absolute, or climbing out with..) asunsafe_path, and produces no archive.- The default export
mdcode(filePath, transformer, filter?)resolvesfile=against the markdown file's directory instead of the current directory, refuses paths that lead outside it, and rejects on the first failed read or transform. - The new
unsafe_patherror code reports these refusals. extract --jsonandupdate --jsonreport each document inresult.documents, so readresult.documents[0].targetsandresult.documents[0].blockswhere you readresult.targetsandresult.blocksbefore. Both commands take several Markdown files, and errors carry thedocumentthey came from.updateandextractreadmdcode.config.jsonwith--projector--config <path>; see Project Configuration. The newinvalid_configerror code reports a configuration that cannot be used.- mdcode-ts needs Node 22.17 or later, for
fs.glob. extractchecks every target before writing any. Blocks that share a file without each declaring its ownregion=in one language, and region blocks whose existing file has broken markers, are refused asambiguous_target,malformed_region,duplicate_regionorregion_language_mismatchand nothing is written. They used to skip that one file withextract_skippedand exit 2. Two identical whole-file blocks for one file used to be written once; they are now refused too.extractwith several documents checks them all before writing any, including blocks in different documents that write one file. It used to write each document in turn, so a later document's refusal left the earlier ones' files written, and a file two documents both wrote ended up with whichever came first, the second being skipped withextract_skipped. Both are now refused before anything is written, andvalidate --for extractreports them.updatereports a missing, duplicated, unclosed or wrongly markedregion=asmissing_region,duplicate_region,malformed_regionorregion_language_mismatchinstead ofread_failed. A region found more than once in its file used to have its bodies joined; it is now refused. An emptyregion=used to read the whole file; it is nowmalformed_region. An emptyfile=is nowunsafe_path:updateused to ignore it, andextractaimed it at--diritself.- The new
validatecommand reports every problemextractorupdatewould refuse, without writing. update --apply --continue-on-errorno longer writes a document in which a block'sfile=orregion=failed; it used to write that document's other blocks. A document where only a transformer threw is still written.- The new
watchcommand reports drift after each change to a document or a file its blocks read. --metatakes onekey=valueper flag; repeat it for several. It used to take every following argument, somdcode list --meta type=example README.mdreadREADME.mdas a second pair, read stdin instead, and found nothing. A value containing=, as in--meta expr=a=b, is now kept whole.extractsplicing a region whose marker is indented no longer indents the body a second time.updatecopies a region with its indentation, so extracting that block used to push every line right by the marker's indent. A body whose first line already starts with the marker's indent is now written as it stands; a dedented body is still indented to the marker.extract --forcekeeps an overwritten file's final newline (LF or CRLF). It used to drop it. A new file is still written as the block's code stands.extract --forcerefuses a target that is a symlink or not valid UTF-8, as region splices already did, instead of replacing the link with a regular file or re-encoding the bytes. The target is skipped andextractexits 2.extractno longer writes blocks withoutfile=by default. A plainmdcode extract README.mdused to write every untagged block asblock-<N>.<ext>beside the README; now it writes only blocks that havefile=, as--ignore-anonymousdid.--ignore-anonymousis removed fromextractandvalidate, and so is the library'signoreAnonymousoption: drop it. To extract anonymous blocks, pass--update-source(updateSource: true), which also writes their generatedfile=into the markdown.validate --for extractreports such blocks withpath: null.
You can use mdcode programmatically in your Node.js or TypeScript projects:
pnpm add mdcode-tsThe simplest way to use mdcode is with the default export:
import mdcode from 'mdcode-ts';
// Transform a markdown file
const result = await mdcode('/path/to/file.md', ({tag, meta, code}) => {
// Transform SQL to uppercase
if (tag === 'sql') {
return code.toUpperCase();
}
// Add headers to test files
if (meta.file?.includes('.test.')) {
return `// AUTO-GENERATED\n${code}`;
}
return code; // unchanged
});
console.log(result); // Transformed markdownBlocks with file= are read first, resolved against the markdown file's directory and confined to
it, as mdcode update does by default. A file= that is absolute or leads outside that directory,
directly or through a symlink, is refused. The promise rejects on the first failed read, unsafe path
or failed transform, with an Error whose errors array holds that ResultError. There is no
opt-out here; to collect every failure, call update() with continueOnError: true.
With filters:
// Transform only SQL blocks
const result = await mdcode(
'/path/to/file.md',
({tag, meta, code}) => code.toUpperCase(),
{ lang: 'sql' }
);For more control, use the named exports:
import {
parse,
walk,
update,
list,
extract,
run,
dump,
defineTransform,
type Block,
type TransformerFunction,
type FilterOptions,
} from 'mdcode-ts';import { parse } from 'mdcode-ts';
const markdown = `
# Example
\`\`\`js file=app.js
const x = 1;
\`\`\`
\`\`\`python
y = 2
\`\`\`
`;
// Extract all blocks
const blocks = parse({ source: markdown });
console.log(blocks); // [{ lang: 'js', code: '...', meta: { file: 'app.js' } }, ...]
// Extract with filters
const jsBlocks = parse({
source: markdown,
filter: { lang: 'js' }
});import { update, defineTransform } from 'mdcode-ts';
const markdown = `
\`\`\`sql
select * from users;
\`\`\`
\`\`\`js
test('example');
\`\`\`
`;
// Create a transformer
const transformer = defineTransform(({tag, meta, code}) => {
// Transform SQL to uppercase
if (tag === 'sql') {
return code.toUpperCase();
}
// Add a header to JavaScript blocks
if (tag === 'js') {
return `// AUTO-GENERATED TEST\n${code}`;
}
return code; // unchanged
});
// Apply transformation
const { source, blocks, errors } = await update({ source: markdown, transformer });
console.log(source); // Transformed markdown
console.log(blocks); // [{ name: null, line: 2, lang: 'sql', changed: true, transformed: true }, ...]import { update, defineTransform } from 'mdcode-ts';
const transformer = defineTransform(async ({tag, meta, code}) => {
// Fetch from API, read files, etc.
const formatted = await someAsyncFormatter(code);
return formatted;
});
const { source } = await update({ source: markdown, transformer });import { walk, type Block } from 'mdcode-ts';
const result = await walk({
source: markdown,
walker: async (block: Block) => {
// Return modified block
return { ...block, code: block.code.toUpperCase() };
// Or return null to remove block
// return null;
},
filter: { lang: 'js' }, // Optional filter
});
console.log(result.source); // Modified markdown
console.log(result.blocks); // All processed blocks
console.log(result.modified); // true if any changes were madeAll functions support filtering:
// Filter by language
parse({ source: markdown, filter: { lang: 'js' } });
// Filter by file= (exact match)
parse({ source: markdown, filter: { file: 'app.js' } });
// Filter by custom metadata
parse({ source: markdown, filter: { meta: { region: 'main' } } });
// Select a block by name
parse({ source: markdown, filter: { name: 'quick start' } });
// Combine filters
update({
source: markdown,
transformer,
filter: { lang: 'sql', file: 'queries.sql' }
});Extract code blocks from markdown.
- options.source - The markdown source string
- options.filter - Optional filter criteria
- Returns - Array of Block objects. A named block also has
nameset. Each block'spositionincludeslineandendLine, the 1-based lines of its opening and closing fences. - Throws -
MetadataErrorwhen any block's metadata is malformed or two blocks share a name. Itsproblemsarray lists each problem with the line of the block's opening fence.
Walk through and optionally transform code blocks.
- options.source - The markdown source string
- options.walker - Function called for each block
- options.filter - Optional filter criteria
- Returns - Promise of WalkResult with source, blocks, and modified flag
Work out the updated markdown from files or via transformer. It never writes; the caller decides what to do with the result.
- options.source - The markdown source string
- options.transformer - Optional transformer function
- options.filter - Optional filter criteria
- options.basePath - Directory that
file=paths resolve against and must stay inside (default: '.'). An absolutefile=, or one that leads outside through..or a symlink, is anunsafe_patherror. - options.continueOnError - Collect every failure in
errorsand keep going, instead of throwing at the first - options.onBlock - Optional
(block, errors) => void, called as each selected block finishes, before the next one starts, with theUpdatedBlockand the errors it added (only withcontinueOnError). It is not called for a block whose failure throws. - Returns - Promise of
{ source, blocks, errors }: the updated markdown and oneUpdatedBlockper selected block with its resultingcodeand whether itchanged.errorsis only filled withcontinueOnError: for each failed block, aread_failed,unsafe_pathortransform_failederror, or the region rule it broke (missing_region,duplicate_region,malformed_regionorregion_language_mismatch). The block keeps the code it had before the failing step. - Throws -
MetadataErrorwhen the document's metadata is invalid. WithoutcontinueOnError, the first failed read, broken rule or failed transform throws anErrorwhoseerrorsarray holds that oneResultError.
List code blocks with their metadata, code, and location.
- options.source - The markdown source string
- options.filter - Optional filter criteria
- Returns -
{ blocks }, oneListedBlock(name,line,endLine,lang,meta,code) per selected block. This is theresultthatmdcode list --jsonprints. - Throws -
MetadataErrorwhen the document's metadata is invalid
Write code blocks to files based on their file metadata.
- options.source - The markdown source string
- options.filter - Optional filter criteria
- options.outputDir - Directory that
file=paths resolve against and must stay inside (default: '.') - options.updateSource - Also extract blocks without
file=, each asblock-<N>.<ext>, and add thatfile=to them inupdatedSource. Without it, those blocks are skipped - options.force - Overwrite existing files whose blocks have no
region= - options.check - Write nothing; compare each target with what
extractwould write instead. See Check Without Writing - Returns - Promise of
{ targets, updatedSource?, errors }: oneExtractTargetper target file, the markdown withfile=added whenupdateSourceadded any, oneextract_skippederror per skipped target and, withcheck, oneout_of_syncerror per block whose target would change.extractwrites the target files but not the markdown. - Throws -
MetadataErrorwhen the document's metadata is invalid. When any block breaks a mapping rule checked byvalidate()for extract, it throws anErrorwhoseerrorsarray holds oneResultErrorper block (unsafe_path,ambiguous_target,malformed_region,duplicate_regionorregion_language_mismatch), and nothing is written.updateSourcewithcheckthrows anErrorwhosecodeisinvalid_usage.
Check how the selected blocks map onto files for extract or update. It reads the files the blocks
name, where they exist, but never writes. See Validate Command for the rules.
- options.source - The markdown source string
- options.operation -
"extract"or"update" - options.filter - Optional filter criteria
- options.base -
extract'soutputDir, orupdate'sbasePath(default: '.') - options.strict - Require
file=on every selected block - options.updateSource - For
"extract", also map blocks withoutfile=to theblock-<N>filesextractwrites for them withupdateSource. Without it, they map to no file (path: null) - Returns - Promise of
{ blocks, errors }: oneValidatedBlock(name,line,lang,path,region?,valid) per selected block, and oneResultErrorper broken rule, in document order - Throws -
MetadataErrorwhen the document's metadata is invalid
Run one pass now, then another after each burst of changes to the watched files, until closed. See Watch Command.
- options.resolve - Called before every pass; returns
{ documents, filter?, extra? }, where each document is{ file, label, basePath }andextralists further files whose change starts a pass. When the first call rejects,watch()rejects; a later rejection is reported and watching goes on. - options.apply - Write each document whose blocks drifted, unless a block's
file=orregion=failed (default: false) - options.debounceMs - Quiet time after the last change before a pass (default: 100)
- options.onEvent - Receives
{ type: "ready" }, then{ type: "pass", documents }after every pass (each document with itschangedblocks,errorsand whether it waswritten), and{ type: "error", errors }for a failure watching carried on through - options.watchFiles - Replaces the file watcher, for tests; by default each file is watched
through its directory with
fs.watch - Returns -
{ close }, which stops watching and waits for a running pass to finish
Run a shell command on each code block.
- options.source - The markdown source string
- options.command - Command to run, with
{file}as the placeholder for the block's file - options.filter - Optional filter criteria
- options.keep - Keep the working directory afterwards
- options.dir - Working directory (default:
.mdcode-tmpin the current directory) - options.onBlock - Optional
(block, index, total) => void, called as each block finishes - Returns - Promise of
{ workingDir, blocks, errors }: oneRunBlockResultper selected block, and onecommand_failederror per block whose command failed. Nothing is printed.
Create a tar archive of code blocks.
- options.source - The markdown source string
- options.filter - Optional filter criteria
- Returns - Promise of
{ files, archive }: oneDumpedFile(name,line,path,size) per selected block, and the tar archive as aUint8Array(zero bytes when no block was selected) - Throws -
MetadataErrorwhen the document's metadata is invalid. When anyfile=would unpack outside the archive's directory, it throws anErrorwhoseerrorsarray holds oneunsafe_pathResultErrorper refused entry, and no archive is built.
Helper to define type-safe transformers.
- fn - The transformer function
({tag, meta, code}) => string | Promise<string> - Returns - The same function with proper typing
Add metadata to code blocks using the info string:
```js file=hello.js region=main
console.log('Hello, world!');
```Supported metadata:
file: Output filename for extractionregion: Region name for partial extraction (using#region/#endregioncomments)outline: Extract only the structure without implementation detailsname: The block's stable identifier, used with--name. Optional, but it must be non-empty and unique within the document.- Custom key=value pairs for filtering
The first word of the info string is the language. Each key=value after it is metadata:
- Unquoted:
file=app.js. The value runs to the next space and is taken as written, backslashes included. - Quoted:
file="examples/getting started.ts". Use quotes for values with spaces. Inside quotes,\"is a quote and\\is a backslash. - Words without
=are ignored.
mdcode refuses to process a document with broken metadata, and it reports every problem with the line of the block's opening fence:
Error: Invalid code block metadata:
line 12: unterminated quoted value for "file"; add the closing "
line 30: duplicate name "quick start" on lines 30, 41; names must be unique within a document
The other errors are an invalid escape (any backslash in quotes other than \" or \\), text right
after a closing quote, a key used twice in one block, and an empty name=.
mdcode follows CommonMark's fenced-code-block rules, with one exception for indentation:
- Opening fence: three or more backticks (
```) or tildes (~~~), then the info string. A backtick fence's info string can't contain a backtick, since CommonMark reads that line as inline code. Tilde fences have no such limit. - Indentation: an opening fence may be indented by any amount, so fences inside nested list items are found. (CommonMark allows at most three spaces outside a list.) Code is taken verbatim; its indentation is not stripped.
- Closing fence: the same character, at least as many of them as the opener, indented at most
three spaces more than the opener, and followed only by spaces or tabs. Anything else, such as
```inside a~~~block or a shorter run, is part of the code. That's how to show a fenced block inside another: use a longer fence, or the other character, on the outside. - Unclosed fence: yields no block. Everything after it is treated as its content, the way Markdown renderers display it, so mdcode never rewrites that part of the document.
When extract --update-source adds file= to a block, the opening fence's indentation, character
and length are kept as written.
Use region comments in your source files to extract specific sections. Each region name may be used once per file: update refuses a region it finds more than once as duplicate_region, rather than guessing which body you meant.
// #region factorial
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
// #endregion
// #region helper
function helper() { /* ... */ }
// #endregionRegion markers are detected using language-appropriate comment styles (e.g. // for JS/TS, # for Python/Shell, <!-- for HTML). Specify the lang in the code fence to enable language-aware matching.
Then reference the region in your markdown:
```js file=math.js region=factorial
```Use outline=true to extract code structure without implementation details:
```js file=calculator.js outline=true
```When updating from source, this will preserve the region markers and structure but remove the implementation:
// #region add
// #endregion
// #region subtract
// #endregionThis is useful for documentation that shows structure without implementation details.
This TypeScript implementation is a drop-in replacement for the original Go-based szkiba/mdcode. It keeps the original's commands and flags, with the differences listed under Command Compatibility.
| Feature | Original (Go) | This Implementation |
|---|---|---|
list command |
� | � |
extract command |
� | � |
update command |
� | � |
run command |
� | � |
dump command |
� | � |
Short flags (-l, -f, -m) |
� | � |
Long flags (--lang, --file) |
� | � |
JSON output (--json) |
� | � |
Quiet mode (-q, --quiet) |
� | � |
| Default behavior (list README.md) | � | � |
| Stdin support | � | � |
| Region extraction | � | � |
| Outline support | � | � |
| Transform functions | L | � (Bonus) |
| Library API | L | � (Bonus) |
All commands take the original's flags, with these differences:
updatewrites the markdown only with--apply, where the original writes it by default.runneeds--allow-shell.updateandextractrefuse afile=that leads outside its base, anddumprefuses an entry name that would unpack outside the archive; see Security: Untrusted Markdown.
# The same in both
mdcode list -l js README.md
mdcode extract -d output -q docs/*.md
mdcode dump -o archive.tar README.md
# This implementation also needs --allow-shell, which the original does not have
mdcode run --allow-shell -l python "python {file}" README.mdThese features are not in the original but are available in this implementation:
-
Transform Functions - Apply custom transformations to code blocks
mdcode update --apply --transform ./uppercase.js -l sql README.md
-
Library API - Use mdcode programmatically in Node.js/TypeScript projects
import mdcode from 'mdcode-ts'; const result = await mdcode('README.md', transformer);
-
Enhanced Update - Update command supports both file-based updates AND transformers
# 1. Extract code blocks to files
mdcode extract -d ./readme README.md
# 2. Edit the extracted files
nano ./readme/app.js
# 3. Review, then update README with changes
mdcode update --diff README.md
mdcode update --apply README.md# Extract the blocks marked kind=test
mdcode extract -l js -m kind=test -d ./tests docs/guide.md
# Run each of them
mdcode run --allow-shell -l js -m kind=test "node --test {file}" docs/guide.md
# If tests pass, create archive
mdcode dump -l js -m kind=test -o tests.tar docs/guide.md# Transform SQL to uppercase
mdcode update --apply -t ./uppercase.js -l sql README.md
# Verify changes
mdcode list --json -l sql README.md
# Extract transformed code
mdcode extract -l sql -d ./queries README.md# All tests (unit + E2E)
pnpm test:all
# Unit tests only
pnpm testpnpm buildnode packages/mdcode/src/main.ts list README.mdMIT
Original Go implementation by szkiba