mdcode-ts
v0.1.0
Published
Keep Markdown code blocks in sync with source files: update, extract, list and run README examples. TypeScript port of mdcode.
Maintainers
Readme
mdcode
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.
Drop-in Replacement
This TypeScript implementation is designed as a drop-in replacement for the original Go-based szkiba/mdcode. It maintains full CLI compatibility, including all commands, flags, and output formats, while adding bonus features like transform functions and a library API.
Features
- Extract code blocks from markdown to files
- List code blocks with metadata and previews (text or JSON format)
- Update markdown code blocks from source files OR transform with custom functions
- Validate that blocks map safely onto files before
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)
Why Use mdcode?
The Problem
Documentation examples often become outdated. You write great examples in your README, but as your code evolves, those examples break. Users copy non-working code, get frustrated, and lose trust in your documentation.
The Solution
mdcode keeps each example in a source or test file and copies it into the Markdown:
- 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.
Key Benefits
Keep Examples Fresh
# Change the code and check it as usual
nano src/calculator.js
npm test
# Copy the change into the README
mdcode update --apply README.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
Writing Examples in the Markdown
mdcode also works the other way. Write a block in the Markdown, then extract it to a file and run a command on it:
# Extract examples from README
mdcode extract README.md -d ./examples
# Run them as tests
mdcode run --allow-shell -l js "node {file}" README.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 and Snippet Checkers
mdcode copies code between source files and Markdown. It doesn't type-check, lint or compile the code in a fence itself, and it has no editor integration. In the source-first workflow, your project's own tools check the source file before mdcode copies it.
Snippet checkers such as Kiira treat the fence as the source. Kiira extracts TypeScript and JavaScript fences from Markdown and MDX, type-checks them against your project and reports errors on the fence's line, in your editor, on the command line and in CI.
They suit different snippets:
- An example that should be complete, tested code belongs in a source or test file, and mdcode copies it into the docs.
- A fragment that only makes sense in the prose, such as a single call or part of a config, can stay in the Markdown, where a snippet checker type-checks it.
For blocks written in the Markdown, the ready-to-copy validate-snippets.mjs script can run your own type checker. It extracts the runnable=true blocks into a temporary workspace with mdcode extract and runs a command such as tsc --noEmit there. A failure names the block on the command line; nothing shows in your editor.
Installation
Global Installation
Install globally to use the mdcode command anywhere:
# Using npm
npm install -g mdcode-ts
# Using pnpm
pnpm install -g mdcode-tsAfter installation, you can run mdcode from anywhere:
mdcode --version
mdcode --help
mdcode list README.mdRun Without Installing
No installation required - run directly:
# Using pnpm dlx
pnpm dlx mdcode-ts list README.md
pnpm dlx mdcode-ts extract --lang js docs/*.md
# Using npx
npx mdcode-ts list README.md
npx mdcode-ts --helpProject Installation
Install as a project dependency to use in scripts or via pnpm exec:
# Using pnpm
pnpm add -D mdcode-ts
# Using npm
npm install --save-dev mdcode-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:extractAgent Skills
The package ships an Agent Skill for coding agents,
sync-markdown-code-blocks, in skills/. It covers the inspect, plan, apply, check workflow,
regions, extract, run, dump, transformers, and which commands need approval on untrusted
Markdown. The skill's version matches the installed package. To let your agent find it, run
TanStack Intent in your project:
npx @tanstack/intent@latest install # adds a skill-loading block to AGENTS.md
npx @tanstack/intent@latest list # shows mdcode-ts#sync-markdown-code-blocksOr point your agent at node_modules/mdcode-ts/skills/sync-markdown-code-blocks/SKILL.md.
Local Development
pnpm install
pnpm buildCLI Usage
Default Behavior
Running 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.mdGet Help
# General help
mdcode --help
mdcode -h
# Command-specific help
mdcode list --help
mdcode extract --help
mdcode update --help
mdcode run --help
mdcode dump --helpCheck Version
mdcode --version
mdcode -VList Command
Display code blocks with their metadata and a preview of the content.
Basic Usage
# List all code blocks from README.md (default)
mdcode list
# List blocks from a specific file
mdcode list docs/GUIDE.md
# Read from stdin
cat README.md | mdcode listJSON Output
--json prints one JSON envelope. Its result.blocks lists every selected block with its name,
fence lines, language, metadata and code:
# JSON output
mdcode list --json README.md
# With a filter
mdcode list --json -l js docs/API.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).
Filter by Language
# Long form
mdcode list --lang js README.md
mdcode list --lang python docs/guide.md
# Short form
mdcode list -l js README.md
mdcode list -l sql API.mdFilter by File Metadata
--file selects blocks whose file= is exactly the value given; it is not a glob.
# Long form
mdcode list --file app.js README.md
mdcode list --file app.test.js docs/guide.md
# Short form
mdcode list -f app.js README.md
mdcode list -f server.py docs/guide.mdFilter by Custom Metadata
# Long form
mdcode list --meta region=main README.md
mdcode list --meta type=example docs/guide.md
# Short form
mdcode list -m region=main README.md
mdcode list -m type=test API.mdMultiple Filters
Combine filters to narrow results:
# All filters together
mdcode list --lang js --file app.js --meta region=main README.md
# Short forms
mdcode list -l js -f app.js -m region=main README.md
# Filter one JavaScript test file
mdcode list -l js -f app.test.js docs/guide.mdExtract Command
Extract code blocks to files based on their file metadata. A block without file= (an anonymous
block) links to no file, so extract skips it; pass --update-source
to extract anonymous blocks too.
Extract is non-destructive. Before it writes anything, it checks every target (see
Validate Command). When any block breaks a rule, nothing is written and extract
exits 1 with an error per block:
- Several blocks write one file → allowed only when every one declares its own
region=and all are in the same language. Two whole-file blocks for one file, even identical ones, a whole-file block beside a region block, a 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.
Basic Usage
# Extract to current directory
mdcode extract README.md
# Extract from multiple files
mdcode extract docs/*.mdCustom Output Directory
# Long form
mdcode extract --dir output README.md
mdcode extract --dir ./extracted docs/API.md
# Short form
mdcode extract -d output README.md
mdcode extract -d ./build docs/*.mdQuiet Mode
Suppress status messages (only show errors):
# Long form
mdcode extract --quiet README.md
# Short form
mdcode extract -q README.md
# Quiet with custom directory
mdcode extract -q -d output README.mdFilter What to Extract
# Extract only JavaScript files
mdcode extract --lang js README.md
mdcode extract -l js -d ./src docs/*.md
# Extract specific file
mdcode extract --file app.js README.md
mdcode extract -f server.py -d ./src docs/*.md
# Extract with metadata filter
mdcode extract --meta type=component README.md
mdcode extract -m region=main -d ./lib docs/*.mdCombined Examples
# Extract JavaScript files to src/ directory, quietly
mdcode extract -q -l js -d ./src README.md
# Extract Python examples to examples/ directory
mdcode extract -l python -m type=example -d ./examples docs/TUTORIAL.mdUpdate Source with Generated Filenames
--update-source also extracts anonymous blocks (blocks without file metadata), each as
block-<N>.<ext>, and adds that generated filename back to the markdown, so every extracted file is
linked to its block. Without it, anonymous blocks are skipped:
# Extract and update README with file metadata
mdcode extract --update-source README.md
# Extract to custom directory and update source
mdcode extract --update-source -d ./examples README.md
# Quiet mode
mdcode extract --update-source -q -d ./src README.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.
Force Overwrite
Blocks without region= describe a whole file, so extracting one over an existing file replaces it.
Those files are skipped by default; --force overwrites them:
# Skipped with a warning if src/demo.ts already exists
mdcode extract README.md
# Overwrite it
mdcode extract --force README.md--force has no effect on region blocks — those always splice in place.
Check Without Writing
--check works out every target exactly as extract would, then compares it with the file on disk
instead of writing it. It exits 0 when every file already holds what extract would write, and 1
with an out_of_sync error for each block whose part of a file would change. Nothing is written,
including with --force.
# Would extracting change any file? Pass --force so existing whole files are compared, not skipped
mdcode extract --check --force README.md✗ Out of sync: line 9: region two in src/b.ts differs from this block; extract would replace it
✗ Out of sync: line 13: src/new.ts does not exist; extract would create it
2 block(s) out of sync with their files. Run mdcode extract to write them, or mdcode update to bring the blocks up to date instead.- A region target reports each region that differs, or that the file lacks and
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.
Stdin Behavior with Update Source
When using stdin with --update-source, the updated markdown is written to stdout:
# Read from stdin, output updated markdown to stdout
cat README.md | mdcode extract --update-source > updated.md
# Extract files normally, no source update
cat README.md | mdcode extract -d ./examplesUpdate Command
Update markdown code blocks from source files or transform them with custom functions.
update never writes the markdown unless you pass --apply. Without it, update works out what
would change and reports it, so you (or an agent) can review the change before anything is written.
Plan, Diff, Apply and Check
Each block with file= metadata is read from that file. Pick one mode:
| Mode | What it does | Writes the markdown |
|------|--------------|---------------------|
| --plan (default) | Lists the blocks that would change, and where their new code comes from | No |
| --diff | Prints a unified diff of the markdown changes | No |
| --check | Exits 1 when a selected block is out of sync with its source | No |
| --stdout | Prints the updated markdown | No |
| --apply | Writes the changes to the markdown file in place | Yes |
# What would change?
mdcode update README.md
# Review the changes as a patch
mdcode update --diff README.md
# Write them
mdcode update --apply README.md
# Fail CI when README.md has drifted from its sources
mdcode update --check README.md
# Print the updated markdown, or pipe it through
mdcode update --stdout README.md > UPDATED.md
cat README.md | mdcode update --stdout > UPDATED.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).
Where file= Is Read From
file= paths resolve against the base directory, which defaults to the markdown file's directory
(the current directory when the markdown comes from stdin). The path must stay inside the base: an
absolute path, a .. that climbs out of it, or a symlink that leads out of it is refused as an
unsafe_path error before anything is read. --base <dir> picks another base, and file= paths
then resolve against that directory instead of the markdown's.
A docs/README.md with file=../src/app.js therefore fails by default. Run from the repository
root with --base . and write the path as file=src/app.js:
mdcode update --check --base . docs/README.mdWhen a Read or Transform Fails
By default update stops at the first block whose file= cannot be read (read_failed), is refused
(unsafe_path), breaks a region rule, or whose transformer throws (transform_failed). Nothing is
written and nothing is printed except that error. The command exits 1, and under --json the
envelope has result: null and that one error. mdcode validate lists every such problem at once
without stopping; see Validate Command.
--continue-on-error collects every failure and keeps going. A failed block keeps its previous code,
and a block whose read failed is still transformed from its original code. Every failed block is
reported and the command still exits 1. --apply does not write a document in which any block's
file= or region= failed, so the markdown is never left half in sync with its sources; it still
writes the other blocks of a document where only a transformer threw, and the other documents.
# Report every broken file= at once instead of stopping at the first
mdcode update --check --continue-on-error README.mdChecking Docs in CI
--check exits 1 both when a block has drifted and when mdcode could not check it, for example a
file= it cannot read or a missing document. check-docs-sync.mjs
is a ready-to-copy script that tells the two apart. It runs mdcode update --check --json
--continue-on-error on each Markdown file you pass, never writes, and names every document and
block that is out of sync. With a project configuration,
mdcode update --project --check also checks every listed document in one run, naming each one; the
script remains the way to tell drift from a broken check by exit code.
| Exit | Meaning |
| --- | --- |
| 0 | Every document is in sync |
| 1 | At least one document is out of sync |
| 2 | At least one document could not be checked, mdcode is not on PATH, or no documents were given |
It needs Node 22+ and mdcode-ts 0.1.0 or later, and runs the mdcode on PATH. Copy it into your
repository, for example as scripts/check-docs-sync.mjs, then add mdcode-ts as a dev dependency and
a script, so npm ci installs mdcode and npm run puts it on PATH:
{
"scripts": {
"docs:check": "node scripts/check-docs-sync.mjs README.md docs/guide.md"
},
"devDependencies": {
"mdcode-ts": "^0.1.0"
}
}From a local shell:
# With mdcode-ts in devDependencies
npm run docs:check
# Without installing it
npx --yes -p mdcode-ts@^0.1.0 node scripts/check-docs-sync.mjs README.md docs/guide.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:checkQuiet Mode
# Long form
mdcode update --apply --quiet README.md
# Short form
mdcode update --apply -q README.md
# Quiet with output redirection
mdcode update -q --stdout README.md > UPDATED.md--check still reports the blocks that are out of sync under --quiet.
Transform Mode (with Custom Function)
Transform code blocks using a custom JavaScript/TypeScript function:
# Transform with a custom function
mdcode update --apply --transform ./transformers/uppercase-sql.js README.md
# Short form, previewing the result as a diff
mdcode update --diff -t ./transformers/add-headers.js README.md
# Transform and output to file
mdcode update -t ./transformers/format-code.js --stdout README.md > output.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
});Transform with Filters
Combine transformers with filters to target specific blocks:
# Transform only SQL blocks
mdcode update --apply --transform ./uppercase.js --lang sql README.md
# Transform only the blocks for one test file
mdcode update --apply -t ./add-headers.js -f app.test.js docs/API.md
# Transform JavaScript blocks in examples
mdcode update --apply -t ./format.js -l js -m type=example docs/API.mdRegion Support
Update specific regions of code:
# Update only 'main' region
mdcode update --apply --meta region=main README.md
# Check only the setup region
mdcode update --check -m region=setup README.mdValidate Command
Check how the selected blocks map onto files before update or extract writes anything. validate
reads the markdown and the files it names, writes nothing, and reports every problem at once instead
of stopping at the first. It exits 1 when it finds any.
# Would `mdcode update README.md` be able to read every file= and region=?
mdcode validate README.md
# Would `mdcode extract -d out README.md` refuse anything?
mdcode validate --for extract -d out README.md
# Also require every selected block to name its file
mdcode validate --strict README.md
# Every document in mdcode.config.json, as JSON
mdcode validate --project --json--for picks the command to check for, update by default. Each finding names the document, the
block's line and name, the file concerned and the rule, which is the error code:
| Rule | --for update | --for extract |
|------|----------------|-----------------|
| unsafe_path | file= is empty or leads outside --base | file= is empty, or it or a generated block-N name leads outside --dir |
| read_failed | file= does not exist or cannot be read | - |
| missing_region | region= is not in the file, or outline=true finds no markers | - (a missing region is appended) |
| duplicate_region | region= is opened more than once in the file | Same, in an existing target |
| malformed_region | invalid region= name, or its markers are unclosed or do not nest | Same, or two declared regions overlap |
| region_language_mismatch | region= is marked only in another language's comment syntax | Same, in an existing target |
| ambiguous_target | - | Several blocks write one file, but not each with its own region= in one language; with several documents, this includes blocks in different documents |
| missing_file_metadata | --strict: a selected block has no file= | Same |
update and extract enforce every rule here except --strict, which only validate applies, so a
document validate passes is one they will not refuse for these reasons. For extract, a region an
existing target lacks is appended, not an error. extract can still skip an existing file it would
overwrite whole without --force, and a symlinked or non-UTF-8 target it would splice.
$ mdcode validate --for extract doc.md
✗ line 5: out.ts: blocks on lines 5, 9 all write this file, but not every one declares region=; give each block a region= of its own, or a file of its own (ambiguous_target)
✗ line 9: out.ts: blocks on lines 5, 9 all write this file, but not every one declares region=; give each block a region= of its own, or a file of its own (ambiguous_target)
2 problem(s) found; extract would refuse them.Watch Command
Keep a terminal open while you edit, and watch tells you which documents and blocks drift from
their sources as soon as you save.
# Report drift in README.md after each change, without writing
mdcode watch README.md
# Every document in mdcode.config.json; edits to the configuration take effect at the next change
mdcode watch --project
# Write each change into the markdown as it happens
mdcode watch --apply README.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.
Run Command
Execute shell commands on each code block.
run needs --allow-shell. It passes <command> to the shell once per selected block, and a
command such as node {file} executes the block's code, so running it over markdown you did not
write runs code you did not write. Without the flag, run fails with invalid_usage before reading
any input. The command always comes from your command line; metadata in the markdown never supplies
one. See Security: Untrusted Markdown.
Basic Usage
Use {file} as a placeholder for the temporary file path:
# Run node on JavaScript blocks
mdcode run --allow-shell "node {file}" --lang javascript README.md
# Run Python scripts
mdcode run --allow-shell "python {file}" --lang python README.md
# Compile and run C code
mdcode run --allow-shell "gcc {file} -o out && ./out" --lang c docs/guide.mdFilter by Language
# Long form
mdcode run --allow-shell --lang js "node {file}" README.md
# Short form
mdcode run --allow-shell -l js "node {file}" README.md
# Multiple languages (run separately)
mdcode run --allow-shell -l python "python {file}" docs/guide.md
mdcode run --allow-shell -l js "node {file}" docs/guide.md
# run reads one Markdown file; loop for several
for doc in docs/*.md; do mdcode run --allow-shell -l js "node {file}" "$doc"; doneFilter by Name
Select blocks by their name metadata. Every command accepts -n, --name; see
Selecting Blocks by Name.
# Long form
mdcode run --allow-shell --name test-example "node {file}" README.md
# Short form
mdcode run --allow-shell -n calculate "python {file}" docs/API.md
# With language filter
mdcode run --allow-shell -l js -n integration-test "node {file}" tests/README.mdCustom Working Directory
Specify where to save temporary files and run commands:
# Long form
mdcode run --allow-shell --dir /tmp/mdcode "node {file}" README.md
# Short form
mdcode run --allow-shell -d ./temp "python {file}" docs/guide.md
# With filters
mdcode run --allow-shell -l js -d ./build "node {file}" README.mdKeep Temporary Files
Preserve temporary directory after execution (useful for debugging):
# Long form
mdcode run --allow-shell --keep "node {file}" README.md
# Short form
mdcode run --allow-shell -k "python {file}" docs/guide.md
# After the blocks, the command prints "Working directory: <path>"Combined Examples
# Run JavaScript tests with all flags
mdcode run --allow-shell -l js -n test -k -d ./temp "node {file}" README.md
# Run Python examples in custom directory
mdcode run --allow-shell -l python -m type=example -d ./examples "python {file}" docs/guide.md
# Run and keep files, filter by file metadata
mdcode run --allow-shell -k -f "calculator.py" "python {file}" README.mdAdvanced Usage
# Lint all JavaScript blocks
mdcode run --allow-shell -l js "eslint {file}" README.md
# Format code blocks
mdcode run --allow-shell -l python "black {file}" docs/guide.md
# Type check TypeScript blocks
mdcode run --allow-shell -l typescript "tsc --noEmit {file}" API.md
# Run tests with coverage
mdcode run --allow-shell -l js -n test "jest --coverage {file}" docs/guide.mdValidating Runnable Snippets in CI
run gives each block its own temporary file, which works for standalone snippets. When snippets
import each other, or your linter or test runner expects a directory, use
validate-snippets.mjs.
It is a ready-to-copy script that extracts the snippets into a fresh workspace and runs your command
there.
Only fences marked runnable=true are validated. Every other block, including runnable=false and
blocks with no runnable= at all, is left out. Add file= when a snippet needs a particular name,
for example so another snippet can import it. Without it, the file is named block-N with an
extension for its language:
```js runnable=true name=add file=lib/add.js
export const add = (a, b) => a + b;
```
```js runnable=true name=use-add
import { add } from "./lib/add.js";
console.log(add(1, 2));
```
```js
// Illustration only: not extracted, not run
add(1, 2);
```For each Markdown file, the script:
- 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.
Dump Command
Create a tar archive of all code blocks.
A file= that would unpack outside the archive's directory (an absolute path, or one that climbs
out with .., written with / or \) is refused as an unsafe_path error, and no archive is
produced.
Basic Usage (Output to stdout)
# Dump to stdout
mdcode dump README.md > code-blocks.tar
# Pipe to tar command
mdcode dump docs/guide.md | tar -xOutput to File
# Long form
mdcode dump --out archive.tar README.md
# Short form
mdcode dump -o archive.tar docs/API.md
# With custom name
mdcode dump -o examples-$(date +%Y%m%d).tar README.mdQuiet Mode
# Long form
mdcode dump --quiet --out archive.tar README.md
# Short form
mdcode dump -q -o archive.tar docs/guide.md
# Quiet to stdout
mdcode dump -q README.md > archive.tarFilter What to Dump
# Dump only JavaScript files
mdcode dump --lang js -o js-blocks.tar README.md
mdcode dump -l js -o javascript.tar docs/guide.md
# Dump the blocks for one file= value
mdcode dump --file build.py -o python.tar docs/guide.md
mdcode dump -f server.js -o server.tar README.md
# Dump by metadata
mdcode dump --meta type=example -o examples.tar docs/guide.md
mdcode dump -m region=main -o main.tar API.mdExtract Tar Archive
After creating a tar archive, you can extract it:
# Standard tar extraction
tar -xf code-blocks.tar
# Extract to specific directory
tar -xf code-blocks.tar -C ./extracted
# List contents without extracting
tar -tf code-blocks.tarFiltering Examples
All commands support the same filtering options. Here are comprehensive filtering examples. --file
matches a block's file= exactly, and list, run and dump read one Markdown file, while
extract, update and validate take several:
Single Filters
# By language
mdcode list -l js README.md
mdcode extract -l python docs/*.md
mdcode dump -l sql -o queries.tar API.md
# By file metadata
mdcode list -f app.js README.md
mdcode extract -f app.test.js docs/*.md
mdcode run --allow-shell -f server.py "python {file}" README.md
# By custom metadata
mdcode list -m region=main README.md
mdcode extract -m type=example docs/*.md
mdcode update --apply -m author=admin API.mdMultiple Filters (AND Logic)
When you combine filters, ALL filters must match:
# Language AND file
mdcode list -l js -f app.js README.md
# Language AND metadata
mdcode extract -l python -m type=example docs/*.md
# File AND metadata
mdcode dump -f server.js -m region=main -o server.tar README.md
# All three filters
mdcode list -l js -f app.js -m region=main README.mdComplex Filtering Scenarios
# Extract one JavaScript test file
mdcode extract -l js -f app.test.js -d ./tests docs/*.md
# List Python examples in main region
mdcode list -l python -m type=example -m region=main docs/guide.md
# Run tests only for specific component
mdcode run --allow-shell -l js -f "auth.test.js" -n "login-test" "node {file}" README.md
# Update only SQL queries in specific file
mdcode update --apply -l sql -f queries.sql README.mdSelecting Blocks by Name
Give a block a stable name with name=, then select it by that name from any command. Names are
unique within one markdown document, so each --name picks out at most one block; repeat --name
to select several. Elsewhere, the document path plus the name identifies the block.
```js name="quick start" file="examples/getting started.js"
console.log('Hello, world!');
```mdcode list --name "quick start" README.md
mdcode extract -n "quick start" -d ./out README.md
mdcode update --apply --name "quick start" README.md
mdcode update --check -n "quick start" -n setup README.md
mdcode run --allow-shell -n "quick start" "node {file}" README.md
mdcode dump --name "quick start" -o quick-start.tar README.mdupdate fails with invalid_usage when no selected block has a given name, so a misspelt name
cannot make --check pass by checking nothing.
Project Configuration
A repository can list the Markdown documents it keeps in sync, and the defaults for them, in
mdcode.config.json. update and extract read it when you pass --project, which looks in the
current directory, or --config <path>. Without either flag no configuration is read, and stdin stays
the default input. Configuration needs Node 22.17 or later.
A complete consumer repository:
my-project/
├── mdcode.config.json
├── package.json
├── README.md # ```ts file=src/greet.ts
├── docs/
│ └── guide.md # ```ts file=src/add.ts region=main
└── src/
├── greet.ts
└── add.tsThe 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.
Command-Line Precedence
What you pass on the command line replaces the configuration's value for that run. Command-line paths resolve against the current directory, as without a configuration.
| On the command line | Replaces |
| --- | --- |
| Markdown files | documents |
| --base <dir> | sourceRoot |
| -d, --dir <dir> | outputRoot |
| --lang, --file, --meta | The matching filter field. --meta replaces all of filter.meta |
Several Documents
update and extract work through the documents in order, and with more than one, every line of text
output starts with the document it is about. --stdout needs exactly one document.
update --apply writes every document or none of them: when one fails, nothing is written. With
--continue-on-error, update carries on past a failed document and applies the others. extract
writes nothing until every document has been checked: when any document breaks a rule, or two
documents write one file in a way that rule refuses, nothing is written for any of them. It still stops
at a document that fails while writing, after extracting the ones before it. validate --for extract
runs the same check across its documents.
Under --json, result.documents holds one entry per document, and each error names its document;
see JSON Contract.
Invalid Configuration
A missing file, invalid JSON, an unknown field, a value of the wrong type, or a documents entry that
matches nothing fails with invalid_config before any document is read. The message names the file
and the field:
Error: mdcode.config.json: unknown field "documnets"; expected documents, sourceRoot, outputRoot, filter
Error: mdcode.config.json: documents[1] "guide/*.md" matched no files in /work/my-projectLocally and in CI
# Plan, check and apply every configured document
mdcode update --project
mdcode update --project --check
mdcode update --project --apply
# One document, with the configured roots and filters
mdcode update --project --diff docs/guide.md
# Extract the configured documents' blocks into outputRoot
mdcode extract --project
# A configuration somewhere else
mdcode update --config config/mdcode.config.json --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:checkCI/CD Integration
mdcode 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.
Gating a Release
When you publish from your own machine, the smallest gate is prepublishOnly. npm publish runs it
first and stops without publishing if it fails:
{
"scripts": {
"test": "node --test",
"docs:check": "node scripts/check-docs-sync.mjs README.md docs/guide.md",
"docs:snippets": "node scripts/validate-snippets.mjs README.md docs/guide.md -- node {file}",
"prepublishOnly": "npm test && npm run docs:check && npm run docs:snippets"
},
"devDependencies": {
"mdcode-ts": "^0.1.0"
}
}When GitHub Actions publishes, put the checks in their own job and make the publish job need it.
Each check is a named step, so a failed run names the gate that stopped it, and the publish job never
starts. This workflow publishes when you push a v* tag, using
npm trusted publishing, so no npm token is stored:
name: Release
on:
push:
tags: ['v*']
permissions:
contents: read
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: 'Gate: tests'
run: npm test
- name: 'Gate: docs in sync'
run: npm run docs:check
- name: 'Gate: runnable snippets'
run: npm run docs:snippets
publish:
needs: checks
runs-on: ubuntu-latest
environment: npm
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
# Trusted publishing needs npm 11.5.1 or later; Node 22 ships npm 10
- run: npm install -g npm@latest
- run: npm ci
- run: npm run build --if-present
# The checks job already passed; skip prepublishOnly so snippets never run here
- run: npm publish --ignore-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.
GitHub Action: check-sync
The check-sync action fails a job when Markdown code blocks and the files they link to disagree,
in either direction, and writes nothing:
- files → Markdown runs
mdcode update --check: does each block show its file? - Markdown → files runs
mdcode extract --check --force: would extracting the blocks change a file? See Check Without Writing.
The same drift seen from both sides is reported once, as both. Each problem becomes an error
annotation on the document and line, naming the direction, the block's name=, the file and the
region, and the job summary lists them all in a table.
name: Docs
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
check-sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: adrianbrowning/mdcode-ts/.github/actions/check-sync@<commit-sha> # mdcode-ts@<version>
with:
documents: |
README.md
docs/*.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.
GitHub Action: update-readme
The update-readme action treats the source files as authoritative. It runs mdcode update --apply
on the selected documents and opens one pull request holding only their Markdown changes, or
refreshes the one it opened before:
name: Update docs
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: update-docs
jobs:
update-readme:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: adrianbrowning/mdcode-ts/.github/actions/update-readme@<commit-sha> # mdcode-ts@<version>
with:
documents: |
README.md
docs/*.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.
CLI Flags Reference
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
Security: Untrusted Markdown
mdcode is often pointed at markdown it did not write: a pull request, a downloaded README, or a document an agent produced. It treats the markdown as untrusted and whoever runs it as trusted.
- Untrusted: everything in the markdown, meaning the block code and the info strings with their
metadata (
file=,region=,name=and the rest). - Trusted: what the caller supplies. That is the command-line flags, the
--baseand--dirdirectories, the--transformmodule, and the<command>given torun.
Containment
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.
Failure Policy
A refusal leaves no partial work behind. extract checks every target before writing any, and dump
checks every entry before building the archive, so one unsafe or ambiguous target fails the whole
command with exit 1. update stops at its first failed read, broken region rule, unsafe path or
transform and writes nothing. With --continue-on-error it reports every failure instead, --apply
writes only the documents whose every file= and region= could be read, and the command still
exits 1. validate reports every problem for a document without writing anything.
Running Code
run passes <command> to the shell once per selected block. When the command executes its file, as
node {file} does, run executes the block's code, so running it over untrusted markdown runs
untrusted code with your permissions. --allow-shell is the acknowledgement: without it, run fails
with invalid_usage before reading any input. The library run() function needs no such flag,
because calling it is already explicit.
validate-snippets.mjs follows the same model. Its command
comes only from its own command line, and a command that executes the extracted snippets runs
untrusted code.
Limits
mdcode is not a sandbox.
- The path checks are check-then-use. A symlink swapped in between the check and the read or write is not detected, so containment holds only while nothing else is changing the directory tree.
- A
--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.
JSON Contract
Every command except watch, which prints a line per change until you stop it, accepts --json.
With it, the command prints exactly one JSON object, the envelope, on stdout and nothing else: no
colours and no progress text. Without --json, the output is text.
Envelope
version- The contract version, currently1. It changes only when the contract changes incompatibly. The library exports it asCONTRACT_VERSION.command- The command that ran:list,extract,update,validate,runor `d
