mdschema
ActionsTags
(1)Lint the structure of your Markdown, not just its style.
Declare what your docs must contain in a small YAML schema,
then validate, scaffold, and enforce it in CI.
Installation · Quick Start · Schema Reference · GitHub Action · Examples
Style linters tell you a line is too long. mdschema tells you the README is missing its ## Installation section, that the install section has no bash snippet, or that a blog post's date isn't a valid date.
The same schema also generates a ready-to-fill template for new documents, and can be derived from a document you already like.
Tip
This README is validated against examples/README.mdschema.yml in CI.
- Schema-driven — describe the expected document in plain YAML, with nested sections and repeatable headings
- Content rules — require or forbid text, code blocks by language, images, tables, lists, and word counts per section
- Frontmatter checks — required fields, types, formats (
date,email,url), enums, and nested keys - Link checks — internal anchors, relative files, and (optionally) external URLs
- Dynamic headings — match headings against the filename with expressions like
slug(filename) == slug(heading) - Templates both ways —
generatea skeleton from a schema, orderivea schema from an existing document - AST-based — parses Markdown properly, so headings inside code blocks never confuse it
- Runs anywhere — single static binary for Linux, macOS, and Windows; also on npm and as a GitHub Action
- Editor support — JSON Schema for completion and inline validation of
.mdschema.yml
brew install jackchuka/tap/mdschemanpm install -g @jackchuka/mdschema
# or run without installing
npx @jackchuka/mdschema check README.mdgo install github.com/jackchuka/mdschema/cmd/mdschema@latestDownload a prebuilt binary from the releases page.
git clone https://github.com/jackchuka/mdschema.git
cd mdschema
go build -o mdschema ./cmd/mdschema-
Create a schema — start from defaults, or infer one from a document you already like:
mdschema init # writes .mdschema.yml mdschema derive README.md -o .mdschema.yml # or derive it from README.md
-
Validate your Markdown files:
mdschema check README.md "docs/**/*.md" -
Scaffold a new document that already satisfies the schema:
mdschema generate -o new-doc.md
| Command | Description |
|---|---|
mdschema check [globs] |
Validate files against the schema; exits 1 on violation |
mdschema generate |
Print (or -o save) a Markdown template from the schema |
mdschema init |
Create a starter .mdschema.yml |
mdschema derive <file> |
Infer a schema from an existing document |
mdschema schema |
Print the JSON Schema for .mdschema.yml files |
mdschema version |
Print the version |
Every command accepts --schema <path> (default: .mdschema.yml).
A schema has one structure tree plus optional document-wide rules:
structure: [...] # expected sections, in order
heading_rules: { ... } # heading levels and uniqueness
links: { ... } # link validation
frontmatter: { ... } # YAML frontmatter validationEach entry in structure (and in any children) describes one section:
| Key | Description |
|---|---|
heading |
"## Title" (literal), {pattern: "## .*"} (regex), or {expr: ...} |
description |
Guidance emitted as an HTML comment by generate |
optional |
Section may be omitted (default false) |
count |
Repeatable section: {min: 1, max: 0} (0 = unlimited) |
allow_additional |
Allow subsections not listed in children (default false) |
children |
Nested subsections, in order |
…and these content rules, checked inside that section:
| Rule | Example |
|---|---|
required_text |
["API key", {pattern: "v\\d+\\.\\d+"}] |
forbidden_text |
["TODO", "FIXME"] |
code_blocks |
[{lang: bash, min: 1, max: 3}] |
images |
[{min: 1, require_alt: true, formats: [png, svg]}] |
tables |
[{min: 1, min_columns: 2, required_headers: [Name]}] |
lists |
[{min: 1, type: ordered, min_items: 3}] |
word_count |
{min: 50, max: 500} |
Use expr to match headings dynamically, for example to require that my-file.md starts with # My File:
structure:
- heading:
expr: "slug(filename) == slug(heading)"
children:
- heading: "## Features"Variables: filename (without extension), heading (heading text), level (1–6).
Available functions
| Function | Description | Example |
|---|---|---|
slug(s) |
URL-friendly slug | slug("My File") → "my-file" |
kebab(s) |
PascalCase to kebab | kebab("MyFile") → "my-file" |
lower(s) / upper(s) |
Case conversion | lower("README") → "readme" |
trimPrefix(s, pattern) |
Remove regex prefix | trimPrefix("01-file", "^\\d+-") → "file" |
trimSuffix(s, pattern) |
Remove regex suffix | trimSuffix("file_draft", "_draft") → "file" |
hasPrefix(s, prefix) |
Check prefix | hasPrefix("api-ref", "api") → true |
hasSuffix(s, suffix) |
Check suffix | hasSuffix("file_v2", "_v2") → true |
strContains(s, substr) |
Check contains | strContains("api-ref", "api") → true |
match(s, pattern) |
Regex match | match("test-123", "test-\\d+") → true |
replace(s, old, new) |
Replace all | replace("a-b-c", "-", "_") → "a_b_c" |
heading_rules:
no_skip_levels: true # disallow h1 -> h3 without h2
unique: true # all headings must be unique
unique_per_level: false # unique within the same level only
max_depth: 4 # deepest allowed heading (h4)links:
validate_internal: true # anchors like (#section)
validate_files: true # relative files like (./file.md)
validate_external: false # external URLs (slower)
external_timeout: 10 # seconds
allowed_domains: [github.com, golang.org]
blocked_domains: [example.com]Frontmatter is required once a frontmatter block exists; fields are required unless marked optional.
frontmatter:
optional: false
fields:
- { name: "title", type: string }
- { name: "date", format: date }
- { name: "author", optional: true, format: email }
- { name: "tags", optional: true, type: array, enum: [go, cli, markdown] }
- { name: "status", enum: [draft, published, archived] }
- { name: "metadata.version" } # nested key via dot-notation- Types:
string,number,boolean,array,date,object - Formats:
date(YYYY-MM-DD),email,url - Enums: with
type: array, every element must be listed. Entries are checked against the declaredtype/formatwhen the schema loads, so an enum no value could satisfy (e.g.{ type: number, enum: [low, high] }) is reported as a schema warning. - Nested keys:
metadata.authorvalidatesauthorundermetadata. Escape a literal dot with a backslash:name: "weird\\.key".
name: Docs
on: [push, pull_request]
jobs:
mdschema:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: jackchuka/mdschema@v0.15.3
with:
files: "README.md docs/**/*.md"
schema: ".mdschema.yml"| Input | Description | Default |
|---|---|---|
version |
mdschema CLI version (use latest for newest) |
Action ref |
files |
Files or glob patterns | **/*.md |
schema |
Path to schema file | .mdschema.yml |
args |
Additional CLI arguments | (empty) |
working-directory |
Working directory for validation | . |
token |
Token for GitHub API version lookups | github.token |
For a monorepo, point working-directory at the package (e.g. ./packages/docs).
.mdschema.yml has a JSON Schema for completion, hover docs, and validation in any editor using the YAML Language Server (VS Code, Neovim, JetBrains, …). Add this line to the top of the file:
# yaml-language-server: $schema=https://raw.githubusercontent.com/jackchuka/mdschema/main/schema.jsonOr map it once in VS Code's settings.json:
{
"yaml.schemas": {
"https://raw.githubusercontent.com/jackchuka/mdschema/main/schema.json": ".mdschema.yml"
}
}Complete schema + document pairs live in examples/:
| Schema | Document | Shows |
|---|---|---|
README.mdschema.yml |
this README | Regex headings, link and heading rules |
blog-post.mdschema.yml |
blog-post.md |
Frontmatter, word counts, images |
tutorial.mdschema.yml |
tutorial.md |
Repeatable ## Step N sections |
requirements.mdschema.yml |
requirements.md |
Structured specification documents |
structure:
- heading: { pattern: "# .*" }
children:
- heading: "## Prerequisites"
- heading: { pattern: "## Step [0-9]+: .*" }
count: { min: 1, max: 0 } # one or more steps
code_blocks: [{ min: 1 }] # each step needs a code block
- heading: "## Next Steps"
optional: truestructure:
- heading: "# Project Name"
allow_additional: true # authors may add FAQ, Troubleshooting, ...
children:
- heading: "## Overview"
- heading: "## Installation"
code_blocks: [{ lang: bash, min: 1 }]go test ./...
go build -o mdschema ./cmd/mdschemaContributions are welcome! For major changes, please open an issue first to discuss what you would like to change. See CONTRIBUTING.md for details.
mdschema is not certified by GitHub. It is provided by a third-party and is governed by separate terms of service, privacy policy, and support documentation.