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.