mdeval

star 56

Evaluates JavaScript in markdown HTML comments and interpolates results in-place. Use when editing markdown files that contain mdeval script blocks or value markers, when the user wants computed/dynamic values in markdown, or when maintaining README badges, version numbers, or stats.

privatenumber By privatenumber schedule Updated 5/27/2026

name: mdeval description: Evaluates JavaScript in markdown HTML comments and interpolates results in-place. Use when editing markdown files that contain mdeval script blocks or value markers, when the user wants computed/dynamic values in markdown, or when maintaining README badges, version numbers, or stats.

mdeval

When to use

  • Markdown contains values derived from code, files, APIs, or shell commands
  • README stats, version numbers, dependency counts, or computed tables
  • Any value where accuracy matters — counts, sizes, dates, calculations
  • Auditable content — the expression proves the value is correct, not just asserted

Syntax

Two types of HTML comments — invisible when rendered:

Type Syntax Purpose
Script block <!--mdeval\n...\n--> Define variables, imports, logic. Starts with <!--mdeval + newline
Value marker <!--mdeval EXPR-->value<!--/mdeval--> Interpolate expression result. Starts with <!--mdeval + space

Script blocks run as ESM with full Node.js access and top-level await. All blocks in a file merge into one module — imports and variables are shared across blocks and markers. import.meta points to the markdown file. Marker expressions are auto-awaited, so promises resolve automatically.

Marker Expressions

Any JavaScript expression valid on the right side of const x =:

Expression Example
Variable <!--mdeval name-->value<!--/mdeval-->
Property access <!--mdeval data.version-->value<!--/mdeval-->
Computation <!--mdeval items.length + " items"-->value<!--/mdeval-->
IIFE <!--mdeval (() => { const x = 1 + 1; return x; })()-->value<!--/mdeval-->

Duplicate expressions across markers are evaluated once and reused.

Value Coercion

Type Result
string As-is
number, boolean, bigint String(value)
object, array JSON.stringify(value)
object with Symbol.toPrimitive String(value) (e.g. zx ProcessOutput)
Promise Auto-awaited, then coerced
undefined, null Error

Helpers

Helper Description
block(value) Wraps value with newlines for block-level rendering
$ zx shell — run commands via tagged templates: $`git branch`
File Access
.md Globals — call directly: block(x), $`cmd`
.js, .ts, anything else import { block, $ } from 'mdeval'

CLI (and --import mdeval/loader) seeds the helpers on globalThis at startup. Modules imported transitively from a .md see them too, but in non-.md files prefer the explicit import. import { block, $ } from 'mdeval' is side-effect-free.

Importing .md exports from a script

Use when a Node script (validation, migration, agent tooling) needs to read exports from a .md. Run the script with --import mdeval/loader:

node --import mdeval/loader ./consumer.js

consumer.js can use ordinary static imports against .md files:

import { todos } from './TODOS.md';

console.log(todos);
  • --import mdeval/loader is a side-effect-only entry — seeds block/$ on globalThis and registers the Node ESM loader before any of the script's imports link.
  • Without it, static import of a .md fails — Node can't resolve .md until the loader is registered.
  • The plain mdeval import (import { block, $ } from 'mdeval') stays pure — no globals, no loader. Use it when you only want the helpers.
  • Works on .md directly too: node --import mdeval/loader ./TODOS.md.
  • Runtime stack traces from a .md point at original lines and columns automatically.

CLI

mdeval README.md                    # single file
mdeval README.md docs/guide.md      # multiple files
mdeval "docs/**/*.md"               # glob pattern
mdeval "**/*.md"                    # recursive — node_modules and dotdirs auto-excluded

Supports full glob syntax including ** recursive, {a,b} brace expansion, and ! negation.

node_modules and hidden directories (.git, .next, etc.) are automatically excluded from glob expansion. No need to manually negate them. To explicitly include node_modules, reference it in the pattern: mdeval "node_modules/pkg/*.md".

Patterns

Shell commands

<!--mdeval $`git branch --show-current`-->main<!--/mdeval-->

Read package.json

<!--mdeval
import fs from 'node:fs/promises';
const pkg = JSON.parse(await fs.readFile('package.json', 'utf8'));
-->

Version: <!--mdeval pkg.version-->0.0.0<!--/mdeval-->

Import from other .md files

Only script blocks are executed — markers are not processed:

<!--mdeval
import { version } from './data.md';
-->

<!--mdeval version-->1.0.0<!--/mdeval-->

If the imported .md may not yet have mdeval content (stubs filled in over time), use a namespace import — named imports against an empty module are rejected by Node's ESM linker, but missing properties on a namespace resolve to undefined:

<!--mdeval
import * as data from './stub.md';
const version = data.version ?? 'tbd';
-->

Generate Markdown with md-pen

Use md-pen for formatted output (tables, lists, headings):

<!--mdeval
import { table, bold, link } from 'md-pen';
const deps = [['cleye', '^2.3.0'], ['md-pen', '^0.0.2']];
const depsTable = table(deps.map(([name, v]) => [link(`https://npm.im/${name}`, bold(name)), v]));
-->

<!--mdeval block(depsTable)-->
| Package | Version |
| - | - |
| [__cleye__](https://npm.im/cleye) | ^2.3.0 |
<!--/mdeval-->

Gotchas

Block-level values need block(). Without it, block elements don't render:

<!-- ❌ Heading stays on same line as comment, won't render -->
<!--mdeval heading-->### Title<!--/mdeval-->

<!-- ✅ block() adds newlines so the heading renders correctly -->
<!--mdeval block(heading)-->
### Title
<!--/mdeval-->

Script code cannot contain --> — it closes the HTML comment:

<!-- ❌ --> in the string literal closes the comment prematurely -->
<!--mdeval
const x = "<!--/mdeval-->";
-->

<!-- ✅ Build the string without --> -->
<!--mdeval
const x = String.fromCharCode(45, 45, 62);
-->

Values cannot contain mdeval syntax. Producing <!--mdeval or <!--/mdeval--> in a value throws an error to prevent document corruption on re-parse.

Place scripts at the top. Order doesn't affect execution — scripts can appear after the markers that reference them — but top placement signals the file contains generated content.

Markers can span multiple lines — use them to inline logic. Don't cram expressions into one line. Co-locate marker-specific computation with its output:

<!--mdeval block(table([
  { name: 'cleye', version: '^2.3.0' },
  { name: 'md-pen', version: '^0.0.2' },
]))-->
| Package | Version |
| - | - |
| cleye | ^2.3.0 |
| md-pen | ^0.0.2 |
<!--/mdeval-->

For statements or control flow, wrap in an IIFE: <!--mdeval (() => { ... })()-->. Reserve script blocks for shared imports or values referenced by multiple markers.

Standalone markers suppress inline markdown in the value. When a marker opens a line — standalone paragraph, list item, or blockquote — GitHub treats the entire line as a raw HTML block. Inline markdown in the value (`code`, **bold**, [link](url)) is not processed and appears literally.

<!-- ❌ backtick shows literally — line is an HTML block, not inline markdown -->
<!--mdeval expr-->`value`<!--/mdeval-->

Use a multi-line marker so the value occupies its own lines, which are processed as normal markdown. Use md-pen to generate formatted values:

<!--mdeval
import { code, bold, link } from 'md-pen';
-->

<!--mdeval code('asdf')-->
`asdf`
<!--/mdeval-->

Markers inside headings are always inline — markdown in the value always renders there.

Comments don't work in link URLs or image alt text. GitHub escapes comment syntax in these positions:

<!-- ❌ comment is URL-encoded as href -->
[text](<!-- comment -->)

<!-- ❌ comment appears as visible alt text -->
![<!-- comment -->](image.png)

<details> requires blank lines around the </summary> close. Block-level Markdown inside a <details> only renders when blank lines surround the closing </summary> tag — otherwise lists, code fences, tables, and other block elements render as literal text. md-pen's details(summary, content) emits the correct shape:

<!--mdeval
import { details, ul } from 'md-pen';
-->

<!--mdeval block(details(
  'Click me',
  ul(['item one', 'item two']),
))-->
<details>
<summary>Click me</summary>

- item one
- item two

</details>
<!--/mdeval-->

Set up a git hook so values never go stale. Use Lefthook with this config:

# lefthook.yml
pre-commit:
  jobs:
    - name: mdeval
      # lefthook's default `gobwas` matcher requires `**` to span 1+ dirs,
      # so `**/*.md` alone misses root-level files like README.md
      glob: ["*.md", "**/*.md"]
      run: |
        files=$(npx mdeval "**/*.md")
        [ -z "$files" ] || git add $files

git add $files re-stages only the files mdeval actually rewrote. Assumes .md paths without spaces.

Markers in code blocks are safe. Fenced, indented, and inline code won't be touched — safe to document mdeval syntax in your own README.

Never create a .md file with only a script block and no real content. Markdown files must contain actual prose/documentation. For shared logic or utilities, create a .js or .ts file and import it from your markdown instead.

Install via CLI
npx skills add https://github.com/privatenumber/mdeval --skill mdeval
Repository Details
star Stars 56
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
privatenumber
privatenumber Explore all skills →