npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@redocly/recheck

v0.8.0

Published

Content linting (configurable + built-in rules)

Downloads

3,468

Readme

Recheck

Recheck combines a markdown linter (structure/format — full markdownlint rule parity: 53 built-in rules with auto-fix) and a prose linter (style/voice — Vale-style scopes like sentence/paragraph/heading with swap/pattern/repetition/consistency/capitalization rules) in one tool with one simple YAML config — replacing a markdownlint + Vale combo with one line:

extends: [recheck/markdown, recheck/prose]

Recheck is also built to be embedded by other tools: it exposes a library-first API (parseMarkdown, extractScopes, lintContent, lintFiles, runRules — see Library API) so tools like Redocly CLI's lint command can add markdown and prose linting too, including linting markdown strings embedded inside API descriptions.

Features

Modern Scope-Based Architecture

  • File-first processing: each file is parsed once into a micromark AST, then segmented into scopes
  • Full scope vocabulary: all, raw, summary (alias: default), sentence, paragraph, heading (+ heading.h1-h6), code, list-item, blockquote, table.header, table.cell, markdoc.tag, frontmatter, html, comment, alt, link
  • Selector syntax for precise targeting: ~ negates a term, & joins terms into a conjunction — e.g. scope: ['~blockquote & ~heading']
  • Vale-compatible scope notation (e.g., heading.h1, heading.h2)
  • Efficient rule indexing for fast processing at scale

Flexible Configuration Format

  • Modern assertions-based rule definitions
  • severity levels: off, info, warn, error
  • Array-based scope targeting for precise control
  • Comprehensive JSON Schema validation

Production-Ready Engine

  • High-performance JavaScript engine optimized for large repositories
  • Successfully processes 300+ files with 1,000+ issues efficiently
  • Built-in rules for common content quality checks
  • Safe auto-fix capabilities for appropriate rules

Developer-Friendly CLI

  • Table, JSON, SARIF, and GitHub Actions output formats for CI/CD integration
  • Universal output-path option for file export
  • Inline PR annotations with GitHub Actions format
  • Severity filtering and detailed statistics
  • Auto-fix with granular control
  • Comprehensive error reporting

Installation

# Navigate to the recheck package
cd packages/recheck

# Install dependencies
pnpm install

# Build the project
pnpm build

Contributing to Recheck itself (build-cache problems, pnpm parity's required --corpus flag, the generate-examples.mjs/oxfmt coupling) is covered in CONTRIBUTING.md, not here — this README is for people adopting Recheck as a linter.

Usage

Validate Configuration

# Validate with explicit config file
node dist/cli.js validate --config recheck.example.yaml

# Auto-discover config file in current directory
node dist/cli.js validate

Run content linting

# Run on current directory
node dist/cli.js run . --config recheck.example.yaml

# Run on specific file
node dist/cli.js run README.md --config recheck.example.yaml

# Filter by severity (only show errors)
node dist/cli.js run . --severity error

# Show all enabled rules (info and above)
node dist/cli.js run . --severity info

# Work one rule at a time. This helps you clear a large list of findings.
# Give the name that the report shows, or the full config key. Use the flag
# more than one time for more than one rule. A rule from a namespace other
# than `recheck/` keeps that namespace: use `google/passive-voice`.
node dist/cli.js run . --rule semantic-line-breaks
node dist/cli.js run . -r us-spelling -r recheck/oxford-comma

# ...and its inverse, to silence a rule you have already triaged
node dist/cli.js run . --exclude-rule semantic-line-breaks

# Use --rule with --fix to clear one rule's findings across all documents
node dist/cli.js run . --rule semantic-line-breaks --fix

# A name that matches no rule in your config is an error, not an empty run:
# a misspelled filter that reported "no issues" would look the same as a
# clean document set. The error message lists the rules your config loaded.

# Output formats (table is default)
node dist/cli.js run . --output table           # Human-readable table (default)
node dist/cli.js run . --output json            # Structured JSON for CI
node dist/cli.js run . --output sarif           # SARIF format for security tools
node dist/cli.js run . --output github-actions  # GitHub Actions annotations (inline PR comments)

# Show detailed statistics
node dist/cli.js run . --stats

# Auto-fix safe issues (35 fixable rules total: swap + semantic-line-breaks
# natively, plus 33 of the 53 markdownlint-parity rules — see the rule table
# under "Markdownlint parity" below for the full per-rule breakdown)
node dist/cli.js run . --fix

# Combine auto-fix with statistics
node dist/cli.js run . --fix --stats

# Limit annotations for CI (applies to file output, default: 20)
node dist/cli.js run . --annotations-limit 50

# Output to file (works with all formats)
node dist/cli.js run . --output json --output-path report.json
node dist/cli.js run . --output sarif --output-path recheck.sarif
node dist/cli.js run . --output json --output-path limited.json --annotations-limit 50

# Emit run summary to a file (json or text)
node dist/cli.js run . --summary json --summary-path recheck-summary.json

# Scan only changed files (via file list or stdin)
# From file:
node dist/cli.js run . --changed-only --changed-list changed.txt
# Or with stdin:
git diff --name-only origin/main... | node dist/cli.js run . --changed-only

Library API

The CLI is a thin wrapper around a public library API, published from packages/recheck's dist/index.js. This is the intended integration point for embedding Recheck in another tool (a build step, an editor extension, or another CLI like Redocly CLI's lint) rather than shelling out:

import { lintContent, lintFiles } from '@redocly/recheck';

// A config is a flat map of `recheck/<rule>` -> rule definition — the same
// shape a YAML config file resolves to once `extends` presets are expanded.
// Load from YAML (via `loadConfig`, which resolves `extends` for you) or
// build one programmatically, as here:
const config = {
  'recheck/no-hard-tabs': {
    severity: 'error' as const,
    message: 'Hard tabs',
    assertions: { 'no-hard-tabs': {} },
  },
};

// Lint an in-memory string — no file I/O. Useful for linting markdown that
// isn't on disk, e.g. a `description` field pulled out of an OpenAPI document.
const problems = await lintContent('# Title\n\nSome *text*.\n', config);

// Lint files from disk, optionally writing auto-fixes back:
const { problems: fileProblems, fixedFiles } = await lintFiles(['README.md'], config, {
  fix: true,
});

Key exports:

  • parseMarkdown(content, options?) — parses a markdown string into a micromark-based token tree, once. Every other API in this list builds on this tree rather than re-parsing. options.markdoc is a boolean here: true also tokenizes {% ... %} Markdoc tag spans into markdocTag tokens, and false or omitted gives you the same tree as passing no options at all. The object form ({ schema, extend }) is a config-file concept only — it resolves down to this boolean before any file is parsed, and ParseOptions.markdoc does not accept it.
  • extractScopes(tree, content) — segments a parsed token tree into Vale-style scopes (sentence, paragraph, heading, list-item, blockquote, table.cell, etc.) for prose/style rules to run against.
  • lintContent(content, config, opts?) — lints a single in-memory markdown string against a config; no disk access. Rules that need on-disk facts (e.g. max-image-size) require opts.metadata to be supplied by the caller.
  • lintFiles(paths, config, opts?) — lints markdown files from disk; pass { fix: true } to also write auto-fixes back, looping lint → fix → re-lint until the file converges. Files that can't be read are skipped (with a console warning) and reported in the returned skippedFiles ({ path, reason }[]), so callers can detect incomplete coverage programmatically. opts.root sets the lint root that image-metadata loading is confined to (default process.cwd()) — image refs resolving outside it are treated as missing without touching the disk. opts.maxProblems caps the total problems collected: once a file's lint pushes the run to the cap, later files aren't linted at all and the returned truncated flag is set.
  • runRules(files, rules) — the lower-level engine entry point for callers that already have a NormalizedRule[] (e.g. from loadConfig) and want to run against an explicit in-memory file list, bypassing lintFiles's own config loading/validation. Under { fix: true } its RunResult separates the fixes that genuinely landed (fixes) from proposals dropped by overlap resolution (skippedFixes).
  • applyFixesToContent(content, fixes) — applies Fix edits to a string, preserving the file's own line endings (CRLF files stay CRLF). Returns { content, applied, skipped }: every input fix is classified as genuinely applied or skipped (overlapping edits, out-of-range lines), so callers can report what actually changed rather than every proposal.
  • computeTextStatistics(prose) — computes word/sentence/syllable/character/complex-word counts for a plain prose string (not markdown — extract prose from a scope first). Sentence counting reuses splitSentences internally, so it agrees with the rest of the engine on sentence boundaries. Tokenization is ASCII-only by design (accented or non-Latin letters don't count as word characters), so readability scores are meaningful for English prose.
  • computeReadability(formula, stats) — scores a TextStatistics object with one of six standard readability formulas: flesch-reading-ease, flesch-kincaid-grade, gunning-fog, smog, coleman-liau, automated-readability. Returns 0 (rather than NaN/Infinity) when stats.words or stats.sentences is 0.
  • TECHNICAL_PROPER_NOUNS — the built-in technical proper-noun vocabulary capitalization/spelling consume by default; re-exported so you can read it or build your own tooling around the same list.

This is exactly the surface a host tool needs to add both markdown-structure linting and prose/style linting to content it already has in memory — for example, linting the markdown inside an OpenAPI description field without writing it to a temp file first.

Configuration Format

Configuration uses a modern assertions-based format with severity levels and flexible scope targeting.

A top-level excludes applies to every rule, so a path you never lint is stated once rather than repeated on each rule. It is merged ahead of a rule's own excludes, which still apply:

excludes:
  - "**/_partials/**"
  - "CHANGELOG.md"
recheck/us-spelling:
  scope: all # default
  severity: error
  message: 'Use the US spelling "%s" instead of British "%s".'
  link: https://docs.microsoft.com/en-us/style-guide/word-choice/use-us-spelling-avoid-non-english-words
  appliesTo:
    - "docs/**"              # Only apply to documentation
  assertions:
    swap:
      ignoreCase: true
      wordBoundary: true
      pairs:
        color: colour
        behavior: behaviour
        organize: organise
  exceptions:
    files: [docs/style-guide.md]
    lines:
      - "British spellings such as 'color'"

recheck/no-gerund-headings:
  severity: error
  scope: 
    - heading.h1
    - heading.h2
    - heading.h3
  message: 'Do not start headings with a gerund.'
  excludes:
    - "**/drafts/**"         # Exclude draft documents
  assertions:
    pattern:
      ignoreCase: true
      tokens:
        - '^\\w*ing.*'

recheck/config-line-length:
  severity: error
  message: 'Config docs: keep lines under %s characters.'
  appliesTo:
    - "docs/config/**"       # Only apply to config documentation
  assertions:
    line-length:
      lineLength: 100
      codeBlocks: false

recheck/ul-style-dash:
  severity: error
  message: "Use '-' for unordered list bullets."
  excludes:
    - "**/examples/**"       # Allow mixed styles in examples
  assertions:
    ul-style:
      style: dash

Baseline

A baseline lets a team adopt recheck on a large document set with no cleanup project first: record the findings that exist today, then fail only on new ones.

recheck baseline            # writes recheck-baseline.yaml next to your config

Activate it with one config line:

baseline: ./recheck-baseline.yaml

The file stores one count per file per rule, errors only, sorted for stable diffs:

version: 1
files:
  docs/index.md:
    recheck/semantic-line-breaks: 3

With the baseline active, recheck run:

  • suppresses findings whose (file, rule) count matches the baseline, and reports how many matched;
  • fails when a count rises — the group's findings are printed with (baseline 3, found 5) context;
  • fails when a count falls, because the baseline is stale — the message says to run recheck baseline and commit the result. Counts only step down, so the file equals reality at every green commit.

Warnings are never baselined; they do not affect exit codes. Partial runs (--rule, --changed-only, a narrower path) compare only the files they scanned and the rules they ran, so they never false-alarm about what they did not see. Line numbers are deliberately not stored: counts survive unrelated edits, and a baseline diff in review reads as "this PR pays down 4 findings." A renamed file is a new path with no budget, so its pre-existing findings report as new until you regenerate — the baseline diff then shows the counts moving from the old path to the new one.

Readability

recheck readability reports scores per file: Flesch reading ease, Flesch-Kincaid grade, Automated Readability Index (ARI), words, and sentences, plus medians. ARI is a grade level computed from exact character counts, with no syllable heuristic, which makes it steadier on technical vocabulary. It is score-shaped, not rule-shaped: it never gates and always exits 0 when it ran. To gate on a bound, use the metric assertion — both read the same prose and the same formulas, so they can never disagree.

recheck readability docs
recheck readability docs --output json
recheck readability docs --changed-only < changed.txt   # score only listed files

The score reads flowing prose the way standard readability tools do: headings, code, and Markdoc tags are excluded, and every block ends a sentence. A file with no prose reports (null in JSON) rather than a fake zero. In CI, run it twice — once on the PR head and once on the merge-base worktree — and join on file to show each changed page's score change.

Agent skills

Agents write a growing share of markdown, and a skill makes each one a recheck user with no person in the loop. Two skills ship in the npm package under skills/:

  • recheck-lint — run recheck on touched markdown before committing or outputting it, fix errors, and never suppress findings to pass.
  • recheck-config — write and tune recheck.yaml: measure the corpus, set severities from counts, prefer fixes over exceptions, and adopt a baseline for large corpora.

To use them with Claude Code, copy them into your project:

cp -r node_modules/@redocly/recheck/skills/recheck-lint .claude/skills/
cp -r node_modules/@redocly/recheck/skills/recheck-config .claude/skills/

Each skill is one SKILL.md with a trigger description and instructions, so other agent runtimes can adapt them with a rename.

Exceptions

Rules can be configured with exceptions to skip specific files or lines:

File Exceptions

Skip entire files using glob patterns or exact matches:

recheck/us-spelling:
  # ... other config
  exceptions:
    files:
      - "docs/style-guide.md"      # Exact filename
      - "docs/api/*.md"            # Glob pattern
      - "**/CHANGELOG.md"          # Recursive glob

File matching supports:

  • Basename matching: style-guide.md matches any file with that name
  • Relative path matching: docs/style-guide.md matches the specific path
  • Glob patterns: docs/*.md matches all markdown files in docs directory

Line Exceptions

Skip specific lines using fragment matching:

recheck/no-trailing-spaces:
  # ... other config
  exceptions:
    lines:
      - "British spellings such as"    # Fragment match
      - "Code example:"                # Beginning of line
      - "// ignore-lint"               # Comment-based exception

Line matching behavior:

  • Fragment matching: If the line contains the exception text anywhere, it's skipped
  • Case-sensitive: "Code Example" does not match "code example"
  • Multiple patterns: Any matching pattern will skip the line

Exception Examples

# Skip documentation style guides for spelling rules
recheck/us-spelling:
  exceptions:
    files: ["docs/style-guide.md", "**/*style*"]
    lines: ["British spellings such as 'colour'"]

# Skip auto-generated files and code blocks
recheck/no-trailing-spaces:
  exceptions:
    files: ["**/generated/**", "CHANGELOG.md"]
    lines: ["```", "Code example:", "// formatter-ignore"]

Inline Directives

Beyond config-level exceptions, individual Markdown files can silence rules inline with HTML comments — the same mechanism ESLint/Vale users expect. A directive names rules by their short name (oxford-comma) or full name (recheck/oxford-comma) — both work. A directive is inert inside a fenced code block (it has to be real, parsed HTML, not just matching text).

<!-- recheck-disable -->
Everything below this point is unchecked, for every rule.
<!-- recheck-enable -->
Checking resumes here.

<!-- recheck-disable oxford-comma us-spelling -->
Only these two rules are off from here on.
<!-- recheck-enable oxford-comma -->
us-spelling is still off; oxford-comma is back on.

<!-- recheck-disable-next-line oxford-comma -->
This one line is exempt from oxford-comma; the rest of the file isn't.

<!-- recheck-disable-file -->
Nothing in this file is linted at all, no matter where this comment sits.

The five forms:

| Directive | Effect | |---|---| | <!-- recheck-disable --> | Disables all rules from this line to the end of the file, or until a matching recheck-enable. | | <!-- recheck-disable rule… --> | Disables only the listed rules from this line on (same end conditions). | | <!-- recheck-enable --> | Re-enables all rules (or, with rule names, only the listed ones) from this line on. | | <!-- recheck-disable-next-line --> | Disables all rules (or, with rule names, only the listed ones) for exactly the next line. | | <!-- recheck-disable-file --> | Disables every rule for the whole file, regardless of where the comment appears. |

Rule naming: list one or more rules space-separated, by short name (oxford-comma) or full name (recheck/oxford-comma) — both work on every form that accepts names; omitting names targets every rule. Naming a rule that isn't configured produces a warning (recheck-directive, severity warn) pointing at the directive's line — useful for catching a typo in the disabled rule name — but disables nothing.

Rule Types and Assertions

Assertion Types

Rules are defined using assertions that specify their behavior:

Swap Assertions (swap)

Text replacement with configurable options. Fixable: each match is replaced with its pair's value, with the matched text's own casing applied to the replacement -- an all-lowercase match inserts the replacement as configured, a Capitalized match capitalizes just the replacement's first word, and an ALL-CAPS match (2+ letters) uppercases the whole replacement; any other (mixed-case) casing is left as configured, since it carries no reliable intent to infer. This matters most with ignoreCase: true: without it, a sentence-initial 'Behaviour' would be fixed to literal 'behavior', silently lowercasing the start of the sentence -- with it, it fixes to 'Behavior'. (With keysAreRegex: true, casing is inferred from the MATCHED text, not the regex key, so this applies uniformly to regex keys too.) When two pairs' matches overlap in the source (a compound key together with the shorter keys it contains), the longest match wins and is reported and fixed as one span.

assertions:
  swap:
    ignoreCase: true
    wordBoundary: true
    pairs:
      color: colour
      behavior: behaviour

| Option | Type | Required | Description | | --- | --- | --- | --- | | pairs | object | Yes | Find → replace entries: each key is searched for in the segment's content and reported/fixed with its value. Keys must be non-empty strings; values must be strings. | | ignoreCase | boolean | No | Matches keys case-insensitively. Default false. | | wordBoundary | boolean | No | Wraps each key in \b...\b so only whole words match. Default false. | | keysAreRegex | boolean | No | Keys are literal text by default; set true to treat each key as a regex (for example, favou?rite matches both spellings). An invalid regex key is ignored and matches nothing; the rule's other pairs still apply. Default false. | | includeCode | boolean | No | Matches inside inline code spans (`like this`) are skipped by default, so a pair like master: primary doesn't fire inside `git checkout master`. Set true to scan inline code too. Default false. |

A missing or empty pairs, an empty-string key, a non-string replacement value, or an unknown option key under swap is a validation error.

The rule's message gets two positional %s substitutions, in this order: 1st = the replacement, 2nd = the matched text — with the pair utilize: use, the message 'Use "%s" instead of "%s".' renders as 'Use "use" instead of "utilize".'

Pattern Assertions (pattern)

Regex-based pattern matching:

assertions:
  pattern:
    ignoreCase: true
    tokens:
      - '^\\w*ing.*'

| Option | Type | Required | Description | | --- | --- | --- | --- | | tokens | string[] | Yes | Regex patterns matched against each segment's content. An invalid regex is caught and silently produces zero problems rather than crashing the run. | | ignoreCase | boolean | No | Matches every token case-insensitively. Default false. | | includeCode | boolean | No | Matches inside inline code spans (`like this`) are skipped by default, so a token like master doesn't fire inside `git checkout master`. Set true to scan inline code too. Default false. |

Occurrence Assertions (occurrence)

Vale-parity occurrence check: counts regex matches within each scoped segment and flags the segment when the count falls outside [min, max]. min: 1 with no max acts as an existence check — it flags a segment where the pattern is missing entirely.

assertions:
  occurrence:
    pattern: '[.!?]'
    max: 3

| Option | Type | Required | Description | | --- | --- | --- | --- | | pattern | string | Yes | Regex matched against each segment's content (whole segment, not per-line). | | min | number | At least one of min/max | Minimum allowed match count; fewer matches is a violation. min: 1 with no max reads as "the pattern must be present". | | max | number | At least one of min/max | Maximum allowed match count; more matches is a violation. | | ignoreCase | boolean | No | Matches pattern case-insensitively. Default false. |

Omitting both min and max is a validation error — an occurrence assertion with no bound can never report anything. An unknown option key under occurrence is likewise a validation error.

The rule's message gets two positional %s substitutions, in this order: 1st = the actual match count, 2nd = the bound that was violated (min or max, whichever applied), e.g. 'Too many sentences (%s found, max %s).''Too many sentences (4 found, max 3).'. Not fixable (detection-only): a count-based violation has no single match position to anchor an edit to.

Repetition Assertions (repetition)

Vale-parity repetition check: flags an adjacent repeated word — two tokens matching pattern, separated only by whitespace (which may include a single hard-wrap newline), so 'the theory' is not flagged (different words) but 'the the' and a hard-wrapped 'the\nthe rest' are. Fixable: collapses the pair back to one occurrence, keeping the FIRST token's casing/text (so 'The the' fixes to 'The', not 'the').

assertions:
  repetition:
    ignoreCase: true # default

| Option | Type | Required | Description | | --- | --- | --- | --- | | pattern | string | No | Regex used to tokenize each segment's content. Default \w+. | | ignoreCase | boolean | No | Compares adjacent tokens case-insensitively. Default true — unlike every other assertion's case-sensitive default, since 'The the' is the overwhelmingly common typo this check exists to catch. Set false to require an exact-case repeat. |

An unknown option key under repetition is a validation error, as is a non-string pattern or a non-boolean ignoreCase; both options are optional, so an empty repetition: {} is valid.

The rule's message gets one positional %s substitution: the repeated word itself, e.g. 'Repeated word "%s".''Repeated word "the".'. Fix idempotency holds under repeated --fix passes: 'the the the' converges to 'the'.

Consistency Assertions (consistency)

Vale-parity consistency check: each either entry declares one alternative group — the key and the value are the two variants (both matched as literals with word boundaries, like swap keys). Whichever variant appears first in the file (by source order) wins file-wide; every later occurrence of the other variant is flagged. Fixable: each later occurrence is replaced with the winning variant literally as written in either — unlike swap, the losing match's own casing is not preserved here (with ignoreCase: true, a later 'Behaviour' in a behavior-first document fixes to 'behavior').

assertions:
  consistency:
    either:
      behavior: behaviour
      color: colour

| Option | Type | Required | Description | | --- | --- | --- | --- | | either | object | Yes | Map of variant pairs; key and value are the two alternatives of one group. Each pair gets its own independent first-seen winner. Must be non-empty. | | ignoreCase | boolean | No | Matches variants case-insensitively (so 'Behaviour' counts as an occurrence of behaviour). Default false. |

Omitting either, leaving it empty, or giving it non-string or empty-string keys or values is a validation error — a consistency assertion with no variant pairs can never report anything, and an empty-string key would otherwise reach the scan loop as a zero-width regex that never terminates. An unknown option key under consistency is likewise a validation error.

Matches from overlapping scopes (e.g. scope: [paragraph, sentence], where every sentence segment sits inside its paragraph segment) are deduplicated by source position before the winner is decided, so each occurrence is counted — and fixed — exactly once.

The rule's message gets two positional %s substitutions, in this order: 1st = the offending (later) match, 2nd = the first-seen winner, e.g. 'Inconsistent spelling: "%s" conflicts with first-seen "%s".''Inconsistent spelling: "behaviour" conflicts with first-seen "behavior".'.

Conditional Assertions (conditional)

Vale-parity conditional check: if first (a regex pattern) matches anywhere within the rule's scoped segments, second (a regex pattern) must exist somewhere in the whole file — checked against the full raw file content, not just the rule's own scope, so a second match sitting inside a code block still satisfies a rule scoped to paragraph. When second is absent file-wide, every first match becomes its own problem, at its exact source position. Detection-only (not fixable) — there is no single well-defined edit that would "introduce" second.

assertions:
  conditional:
    first: '\bTODO\b'
    second: '\bDONE\b'

| Option | Type | Required | Description | | --- | --- | --- | --- | | first | string | Yes | Regex; if it matches anywhere in the rule's scoped segments, second is required. Non-empty. | | second | string | Yes | Regex; must match somewhere in the whole file content once first has matched. Non-empty. | | ignoreCase | boolean | No | Matches both first and second case-insensitively. Default false. |

Unlike swap/consistency's escaped-literal variants, first and second are raw user regex patterns (like pattern's tokens). Missing, empty, or non-string first/second is a validation error, as is an unknown option key or a non-boolean ignoreCase — but first/second are not validated as compilable regexes at config-load time; an invalid regex in either one silently produces zero problems at runtime instead (same convention as pattern).

Matches from overlapping scopes (e.g. scope: [paragraph, sentence]) are deduplicated by source position, so each occurrence of first is reported exactly once.

The rule's message gets two positional %s substitutions, in this order: 1st = the offending first match, 2nd = the second pattern that was never introduced, e.g. '"%s" appears but "%s" was never introduced.''"TODO" appears but "DONE" was never introduced.'.

Capitalization Assertions (capitalization)

Vale-parity capitalization check: flags (and — for four of its match values — fixes) a scoped segment whose text doesn't already match the required casing. match is one of $title, $sentence, $lower, $upper, or else a custom regex the whole segment text must satisfy.

assertions:
  capitalization:
    match: $title
    style: chicago # optional, default 'ap' — only affects $title
    exceptions: [GitHub, iPhone]

| Option | Type | Required | Description | | --- | --- | --- | --- | | match | string | Yes | $title, $sentence, $lower, $upper, or a regex the whole segment text must satisfy. Non-empty. | | style | 'ap' \| 'chicago' | No | Stopword list $title uses (see below). Default 'ap'. Accepted alongside any match, but only has an effect on $title — a documented no-op elsewhere, not a validation error. | | exceptions | string[] | No | Words (or phrases — see below) kept in their EXACT as-written casing from this list, everywhere they appear — including the first/last word — overriding every other rule. Unioned with the built-in technical proper-noun vocabulary unless builtinVocabulary: false. | | builtinVocabulary | boolean | No | Default true. Whether TECHNICAL_PROPER_NOUNS is unioned into exceptions. Set false for a closed vocabulary of only this rule's own exceptions. |

An exceptions entry containing whitespace or a dot (e.g. Node.js, VS Code) is matched as a whole PHRASE against the segment text — case-insensitively but otherwise literally, longest-match-first when phrases overlap — and preserved verbatim, instead of being looked up per word.

Unknown option keys, a missing/empty match, an invalid style, a non-string-array exceptions, or a non-boolean builtinVocabulary are all validation errors.

$title — AP or Chicago title case, implemented in rules/scope/title-case.ts's apTitleCase/chicagoTitleCase:

  • The first and last word are always capitalized, regardless of any stopword list.
  • A hyphenated compound (e.g. well-known) runs each hyphen part through the same stopword test a standalone word gets for the active style — well-knownWell-Known, but editor-in-chiefEditor-in-Chief (in is a stopword in both styles). The compound's first part always capitalizes when the compound opens the title, and its last part always capitalizes when the compound closes the title — e.g. the new state-of-the-artThe New State-of-the-Art (changed from the original simplification by product decision during execution, 2026-07-27).
  • A word already in ALL-CAPS (2+ letters, e.g. an acronym like API) is left exactly as written.
  • AP (default) lowercases articles (a, an, the), coordinating conjunctions (and, but, or, nor, for, so, yet), and prepositions of 3 letters or fewer (at, by, in, of, off, on, out, to, up, via).
  • Chicago lowercases the same articles/conjunctions, plus every preposition regardless of length (the short ones above, plus about, above, across, after, against, along, among, around, before, behind, below, between, during, through, toward, under, until, with, within, without) — e.g. Chicago lowercases '...walking through the park''...walking through the Park', where AP capitalizes Through.

$sentence — only the first word is capitalized; every other word is lowercased unless it's an exceptions entry (as-written) or already ALL-CAPS (left alone).

Word position counts a phrase exception as one word. A phrase exception (one containing whitespace or a dot, like Node.js or VS Code — see the phrase-matching note above) is a single atomic token in the word sequence the $-styles case: it's emitted in its exact as-written form, and it occupies a position, so it never changes which word counts as first or last. With exceptions: [VS Code], the already-correctly-cased heading ## VS Code actions for teams produces no finding under $sentence (actions is the second word, not the first), and ## a guide to Node.js becomes ## A Guide to Node.js under $title/AP (Node.js is the last word, so to is a mid-title stopword and stays lowercase). Single-word exceptions (e.g. GitHub) behave as they always have — resolved by lookup rather than position.

This used to be a bug, tracked as Redocly/redocly#25610 and fixed since: phrase exceptions were previously masked out of the text before word position was computed, which made a leading phrase promote the next word to sentence-initial under $sentence (## VS Code actions for teams was flagged, and under fix: true rewritten to ## VS Code Actions for teams) and made a trailing phrase promote the preceding word to last-word position under $title (a guide to Node.jsA Guide To Node.js). If you had worked around it by rephrasing headings or by swapping in a custom regex match, neither is needed any more. See rules/scope/title-case.ts's recaseWords for the tokenization that replaced the masking.

$lower / $upper — the whole segment must be all-lowercase / all-uppercase respectively; no exceptions/ALL-CAPS carve-out (unconditional, matching Vale's own $lower/$upper).

Custom regex — the whole segment text must satisfy the pattern. Detection-only: unlike the four $-styles, a failing regex is flagged but never auto-fixed, even though the rule itself is registered fixable. Like pattern's tokens, an invalid regex is caught and silently produces zero problems rather than crashing the run.

Inline code is frozen. A backtick-delimited span in the segment text (e.g. a heading like 'the `configFile` option') is treated like an exception: its content is never flagged or rewritten by any of the four $-styles, even if it would otherwise land on the first/last word.

Fixable for $title/$sentence/$lower/$upper only, one segment-wide edit per flagged segment. A multi-line segment (e.g. a soft-wrapped paragraph) is skipped entirely under these four styles — neither a problem nor a fix — since a Fix can only rewrite a single line; a custom regex match has no such restriction and still checks (and reports) multi-line segments, since it never produces a fix regardless of segment span.

The rule's message gets two positional %s substitutions, in this order: 1st = the segment's own text (first line only), 2nd = the match value itself (e.g. '$title', or the literal regex source for custom-regex mode), e.g. '"%s" should use %s capitalization.''"the great escape" should use $title capitalization.'.

Metric Assertions (metric)

Scores the document's prose with one of six published readability formulas and flags the file once when the score falls outside [min, max].

assertions:
  metric:
    formula: flesch-reading-ease
    min: 30

| Option | Type | Required | Description | | --- | --- | --- | --- | | formula | string | Yes | One of flesch-reading-ease, flesch-kincaid-grade, gunning-fog, smog, coleman-liau, automated-readability. | | min | number | At least one of min/max | Minimum acceptable score; a lower score is a violation. | | max | number | At least one of min/max | Maximum acceptable score; a higher score is a violation. |

Omitting both min and max, an unrecognized formula, or an unknown option key are all validation errors — a metric assertion with no bound can never report anything, and an unrecognized formula would otherwise reach the scoring engine's own exhaustive-switch failure at lint time instead of at config validation.

Always summary-scoped. Unlike every other assertion above, metric does not honor a configurable scope: — readability is a property of the WHOLE document's prose, not something a selector could sensibly narrow (a readability score isn't meaningful for one paragraph in isolation the way an occurrence count is). Config validation forces every metric rule to scope: summary. Omit scope on a metric rule (or write scope: summary explicitly); configuring any other scope prints a warning (metric is always summary-scoped; ignoring configured scope ...) and applies summary behavior anyway. Text from overlapping segments (e.g. a list nested inside a blockquote) is deduplicated by source position, same as consistency/conditional above.

What the score reads. The metric scores flowing prose the way standard readability tools do: paragraph, list-item, blockquote, table.cell, and table.header text counts; headings are excluded, and code, frontmatter, html, comment, alt, and link content is never counted. Every block that does not end in terminal punctuation ends a sentence — an unpunctuated list item is one sentence, not a fragment fused into its neighbors. Without that rule, a run of bullets scored as one enormous "sentence" and pushed Flesch reading ease far below zero; with it, scores line up with other readability tools within syllable-heuristic differences.

Non-prose stripping. Before scoring, each segment's text also has Markdoc tag-marker spans ({% tag attr="x" %}, {% /tag %}, and the {%- ... -%} trim variant) and backtick-delimited inline code spans stripped out — neither is readable prose, and both otherwise skew word/syllable counts. Prose between two block-tag markers still counts (only the marker spans themselves are removed); a paragraph consisting only of tag markers contributes nothing. Multi-backtick delimiters ( like this ) are handled conservatively as a simple open-run/close-run pair match, not a full CommonMark-correct implementation.

Detection-only (not fixable) — there is no single edit that would "fix" a readability score. Reports at most one problem per file, always at line: 1, column: 1 (there is no single source position a whole-document score belongs to) — never divided by zero: a file with no prose at all (empty, or only code/frontmatter) is never flagged, regardless of min/max.

The rule's message is substituted against up to four values, in this order: 1st = the formula name, 2nd = the computed score, 3rd = min (or -∞ if unset), 4th = max (or if unset) — e.g. the internal fallback 'Readability (%s) is %s; expected between %s and %s.''Readability (flesch-reading-ease) is 42.1; expected between 60 and ∞.'. The message validation cap is per-assertion: a metric rule's message may use up to 4 %s placeholders (one per value above), while every other assertion stays capped at 2. Fewer placeholders than values is fine — substitution is positional, so a 2-slot message receives the leading values (formula name, then score).

Spelling Assertions (spelling)

Vale-parity spelling check (detection-only): tokenizes each scoped segment's text into words and flags any word an nspell/Hunspell speller doesn't recognize, with up to three suggested corrections.

assertions:
  spelling:
    vocab: [Redocly, Reunite]
    ignore: ['\bAcme\w*']

| Option | Type | Required | Description | | --- | --- | --- | --- | | dictionary | string | No | Base path (WITHOUT the .aff/.dic extension) to a custom Hunspell dictionary pair, e.g. dictionary: dict/custom reads dict/custom.aff and dict/custom.dic. Resolved relative to process.cwd() (where the CLI is invoked from) unless absolute. Omit to use the bundled default English dictionary. | | vocab | string[] | No | Extra known-good words, matched case-insensitively; never flagged even when the speller itself doesn't recognize them (product names, jargon, etc.). Unioned with the built-in technical proper-noun vocabulary unless builtinVocabulary: false. | | ignore | string[] | No | Regex patterns; a token matching ANY of them is never flagged, e.g. ['\bAcme\w*'] to allow every inflection of a brand name. An invalid pattern is silently ignored, same convention as pattern's tokens. | | builtinVocabulary | boolean | No | Default true. Whether TECHNICAL_PROPER_NOUNS is unioned into the accepted-word set alongside vocab. A multi-token entry (Node.js, VS Code) is split into its individual words, each accepted separately — correct for a per-word spell check, unlike capitalization's whole-phrase matching. Set false for a closed vocabulary of only this rule's own vocab. |

All options are optional — an empty spelling: {} is valid (default dictionary, no extra vocabulary, no ignore patterns, built-in vocabulary on). Unknown option keys, a non-string/empty-string dictionary, a vocab/ignore entry that isn't a non-empty string, or a non-boolean builtinVocabulary are all validation errors.

Optional peer dependencies — install to enable. nspell and its default dictionary (dictionary-en) are optional peer dependencies: installing @redocly/recheck itself pulls in neither. Enable spelling with:

npm i nspell dictionary-en

...or, if every spelling rule in your config sets its own dictionary path, you only need the speller itself (the bundled dictionary is never touched):

npm i nspell

If a config enables spelling without the required peer(s) installed, recheck validate fails with an actionable error naming the exact command above — never a bare Cannot find module 'nspell' surfacing for the first time at lint time.

Dictionaries load lazily. Neither nspell nor dictionary-en is imported unless some rule in your config actually has a spelling assertion — a config without one never touches either package, at either validate or lint time. The loaded speller (including the ~500KB parsed dictionary) is cached per dictionary source for the process's lifetime, so every file/rule sharing the same dictionary (or the shared default) reuses one instance rather than reloading it per call.

Word tokenization. Words are matched with /\p{L}+(?:['’]\p{L}+)?/gu — Unicode letter runs, with an optional apostrophe-joined suffix so contractions (don't, it's) tokenize as one word. A token is skipped (never checked) when it's in vocab (case-insensitively), matches any ignore pattern, is ALL-CAPS (2+ letters, e.g. an acronym) — matching the same ALL-CAPS carve-out $title/$sentence capitalization use — or is digit-adjacent (see below). Because \p{L} can never match a digit, a token touching one is never captured WHOLE by the tokenizer in the first place: a digit-adjacent identifier like config2 still splits into a letter-only fragment (config) as its own regex match. Rather than checking that fragment like any other word, a digit-adjacency guard looks at the character immediately before and after each match and skips it when either neighbor is a digit — so common digit-bearing identifiers (sha256sha, utf8utf, oauth2oauth, es6es, log4j → both log and j, 2fastfast) are no longer flagged as false-positive misspellings. This mitigates, but doesn't eliminate, every false positive from the tokenizer's inability to capture digits at all — a token entirely surrounded by non-digit characters is still checked normally, so a genuine misspelling elsewhere in the same sentence is still flagged.

Code is never spell-checked, by construction of scope segmentation — not something this assertion special-cases. A fenced or indented code block is its own scope: 'code' segment, entirely distinct from paragraph/heading/etc.; scoping spelling to prose (the common case, e.g. scope: paragraph or an array of prose scopes) means ctx.segments never contains one. A backtick-delimited inline code span, though, remains embedded as raw text inside a prose segment's own content (verified directly against the extractor) — those spans are masked out before tokenizing, the same length-preserving technique capitalization's backtick-span freezing uses, so positions of any remaining flagged word stay exact. Scoping spelling to all/raw (or leaving scope at its default) checks the whole raw file, literal code included — same default-scope behavior every other native assertion (swap, pattern, ...) has.

Detection-only — no fix. The rule's message gets two positional %s substitutions, in this order: 1st = the unrecognized word, 2nd = a suggestion suffix — either '' (zero suggestions) or ' — did you mean: a, b, c?' (one to three, comma-joined) — e.g. the internal fallback 'Unknown word "%s"%s''Unknown word "wrold" — did you mean: wold, world?'.

Built-in technical proper-noun vocabulary

capitalization and spelling both ship a built-in list of common technical/product proper nouns — TECHNICAL_PROPER_NOUNS, exported from @redocly/recheck's public API (import { TECHNICAL_PROPER_NOUNS } from '@redocly/recheck') so you can read or extend it yourself. It exists so a config that turns on sentence-case headings or spelling doesn't immediately need to hand-list the same 15+ mixed-case technology names every project already has to deal with (OpenAPI, npm, Node.js, VS Code, ...).

On by default, per rule:

  • capitalization unions it into exceptions (so a listed name keeps its as-written casing under every $-style, including $sentence).
  • spelling unions it into vocab (so those words are never reported as misspellings), splitting any multi-token entry into its individual words first — a per-word spell check has no way to accept a whole phrase atomically the way capitalization's phrase matching does.
  • Either union is opted out of independently with that rule's own builtinVocabulary: false, restoring strict pre-built-in behavior (a closed vocabulary of only what you list yourself).
  • Your own exceptions/vocab on the same rule compose with the built-ins rather than replacing them — unlike a preset-shipped list on the same rule key, which a same-key override would replace entirely (see extends presets above). This is exactly how recheck/prose's capitalization rule gets its protection for common technical nouns without shipping any exceptions of its own.

Multi-token entries work. An entry containing a dot or whitespace (Node.js, VS Code, Visual Studio Code, GitHub Actions, Google Cloud, Azure DevOps) is matched by capitalization as a whole phrase against the segment text (longest-match-first, case-insensitive but otherwise literal) and preserved verbatim — not looked up per word, which is what a single-token entry like GitHub still gets.

Inclusion bar (why an entry is — or isn't — in the list, and the bar to clear before proposing one): an entry qualifies if it's an unambiguous technology, product, or company name whose exception listing wouldn't weaken capitalization/spelling checks — concretely, its lowercase form must not be a legitimate English word in its own right. That covers ordinary Title-Case brand names (Android, Kubernetes, Redocly) just as much as entries with an internal capital (OpenAPI, GraphQL), a dot (Node.js), or forced lowercase (npm) — $sentence lowercases every non-first word regardless of how "ordinary" its casing looks, so plain Title-Case names need protection too. Excluded, deliberately:

  • Pure ALL-CAPS acronyms (JWT, YAML) — already handled structurally by the ALL-CAPS carve-out both capitalization and spelling apply, so listing them adds maintenance for no behavior change. Note this is narrower than "looks like an acronym": OAuth and AsyncAPI are mixed-case, not pure ALL-CAPS, and are in the list.
  • Terms with legitimate lowercase prose usage — generic English (cloud, apps), words that are ALSO ordinary English words even though they're Redocly product names too (Realm, Replay, Respect — listing them would force-capitalize ordinary usage like "we respect your privacy"; Node — the common technical noun, superseded by the Node.js phrase entry for the platform specifically), and — caught by a later audit, not the original pass — ordinary brand-shaped words with a real dictionary meaning (Chrome, Markdown, Postman, Prettier, Safari, Swagger, Windows; see src/data/proper-nouns.ts's header for each one's disqualifying lowercase usage). A few real dictionary words (Android, Docker, TypeScript) were judged rare enough in ordinary lowercase usage to keep anyway — a documented, deliberate risk-acceptance, not an oversight. List your own such names in your rule's own exceptions/vocab, which compose with this list as described above.

Two automated tests in src/data/__tests__/proper-nouns.test.ts enforce this: one checks every entry's shape against the bar above — no pure ALL-CAPS, and, mechanically, no single-token entry whose lowercase form the REAL spelling dictionary (dictionary-en/nspell, the same pair spelling loads at runtime) accepts as a legitimate English word, unless it's named in an explicit accepted-risk allowlist — plus alphabetization and no duplicates. A round-trip guard separately drives every entry through the real capitalization and spelling rules and fails the suite if any entry can't actually be protected — the list can't silently regress into decoration.

Length Assertions (length)

Recheck-original, detection-only check: measures each scoped segment's size — in characters, words, or sentences — and flags a segment whose measurement falls outside [min, max]. Unlike metric (always whole-document), length honors whatever scope the rule configures — e.g. scope: alt to cap image alt text, or scope: sentence to cap sentence length in words. recheck/google ships this for the guide's stated "fewer than 26 words per sentence" limit (google/sentence-length); Microsoft's 150-character alt-text cap is the other published example of this shape.

assertions:
  length:
    unit: characters
    max: 150

| Option | Type | Required | Description | | --- | --- | --- | --- | | unit | 'characters' \| 'words' \| 'sentences' | Yes | What min/max count: raw character length, whitespace-delimited words (the same tokenizer metric uses for its own word counts — see metrics/statistics.ts's tokenizeWords), or sentences via the shared splitSentences sentence-boundary logic (scopes/sentences.ts). | | min | number | At least one of min/max | Minimum allowed size; a smaller segment is a violation. | | max | number | At least one of min/max | Maximum allowed size; a larger segment is a violation. |

Omitting both min and max, a missing/unrecognized unit, or an unknown option key are all validation errors — same reasoning as occurrence/metric above. An inverted range (min > max) is also an error.

Detection-only (not fixable) — there is no single edit that would resize a segment to fit. Reports at most one problem per flagged segment, at the segment's own startLine/startColumn.

The rule's message gets three positional %s substitutions, in this order: 1st = the segment's measured size, 2nd = the unit name, 3rd = the bound that was violated (min or max, whichever applied), e.g. the internal fallback 'Segment is %s %s; at most %s allowed''Segment is 151 characters; at most 150 allowed'. The message validation cap for length is 3 placeholders (one per value above), same reasoning as metric's 4-cap.

Built-in Prose Assertions

Beyond swap, pattern, occurrence, repetition, consistency, conditional, capitalization, metric, spelling, and length above, Recheck ships a small set of native prose/format checks:

  • semantic-line-breaks - Semantic line break validation ✅ Fixable
  • max-image-size - Oversized image detection

Three of the assertions above (repetition, consistency, capitalization) are bundled, pre-configured, in the recheck/prose preset, and capitalization/length are also used by recheck/google (sentence-case headings and list items, and a sentence-length cap); the remaining four (occurrence, conditional, metric, spelling) are documented opt-ins with copy-paste snippets, not shipped in any preset by default.

Recheck-original structural rules

Seven rules have no markdownlint counterpart, so they sit outside the 53-rule parity set (and outside the parity comparison). All seven are detection-only (fix: false). The canonical list is RECHECK_ORIGINAL_TOKEN_RULE_NAMES in src/rules/token/index.ts.

The table below covers five of them. The other two — markdoc-unknown-tag and markdoc-attributes — need a tag schema to check anything, so they are documented with the recheck/markdoc preset instead.

| Rule | Flags | Why | |---|---|---| | no-empty-headings | A heading whose text content is empty (a bare #, or markup that renders to nothing such as ## <span></span>) | An empty heading still lands in the document outline and in screen-reader heading navigation. Inline code counts as content, so # `config.yaml` is fine. | | no-duplicate-link-destinations | The second and later links to one destination when the link text differs from the first occurrence's | Screen-reader users listing a page's links hear one target described inconsistently; the texts also drift apart over time. Repeating the same text for the same destination is ordinary prose and is not flagged. Resolves reference links through their definition. | | list-length | A list (ordered or unordered) with fewer than min items (default 2) or more than max items (no default — unbounded unless set) | A single-item list usually reads better as a plain sentence, and a very long list asks readers to hold too many parallel items in mind. Every list is evaluated independently, including nested sublists — a short sublist is flagged even when its parent list is long enough. | | markdoc-syntax | A grammar-level Markdoc tag error — a malformed span, an unquoted "bareword" attribute/primary value, or a close tag carrying attributes | These are invalid under real Markdoc's own grammar regardless of any tag schema, so the rule fires on custom/unknown tags and under schema: false alike. See the recheck/markdoc preset bullet below for the full behavior and a config example. | | markdoc-pairing | An unclosed, orphaned, or interleaved (crossed) Markdoc tag pair, or a schema-declared self-closing tag written with a close it must not have | Same grammar-level scope as markdoc-syntax — see the recheck/markdoc preset bullet below. |

The first three rules are opt-in — not shipped in any preset; configure them individually as shown below.

markdoc-syntax and markdoc-pairing work the other way around: they ship only inside the recheck/markdoc preset, and both need markdoc: true (or the object form) to ever see a Markdoc tag token. Naming either rule key on its own, without the flag, validates but can never report anything — and you get no warning about it, because the stale-config warning fires on extends containing "recheck/markdoc" (warnStaleMarkdocPreset in config/validate.ts), not on individual rule keys. The preset bullet below covers both rules' full behavior with the flag on, plus a config example.

recheck/empty-headings:
  severity: error
  message: 'Headings should have text content.'
  assertions:
    no-empty-headings: {}

recheck/link-text-consistency:
  severity: warn
  message: 'Link destination "%s" is already linked by different text.'
  assertions:
    no-duplicate-link-destinations: {}

recheck/list-length:
  severity: warn
  message: 'List has %s item(s).'
  assertions:
    list-length: { min: 2, max: 10 }

For markdown structure/format rules (headings, lists, links, tables, whitespace, and 49 more), see Markdownlint parity below — no-trailing-spaces, no-hard-tabs, line-length, ul-style (bullet style), no-duplicate-heading, and link-fragments are all part of that 53-rule set, not this native list. (max-line-length, bullet-style, no-duplicate-headings, and no-broken-fragment-links were pre-parity native ids for those same rules; they were removed rather than kept as aliases — see Migrate from markdownlint.)

Enhanced Scope Support

The scope field supports a string, an array (OR'd together), and a ~negation / &-conjunction selector syntax:

scope: all              # Apply to all content (default)
scope: raw              # Apply to raw file content, bypassing scope segmentation
scope: summary          # Apply to the document's prose: paragraph, heading, list-item, blockquote, and table-cell text (alias: default)
scope: sentence         # Apply to sentences only
scope: paragraph        # Apply to paragraphs only
scope: heading          # Apply to all headings
scope: code             # Apply to code blocks only
scope: list-item        # Apply to list item text
scope: blockquote       # Apply to blockquote text
scope: table.header     # Apply to table header cells
scope: table.cell       # Apply to table body cells
scope: markdoc.tag      # Apply to Markdoc tag spans (`{% ... %}`), requires markdoc: true
scope: frontmatter      # Apply to YAML frontmatter
scope: html             # Apply to raw HTML blocks
scope: comment          # Apply to HTML comments
scope: alt              # Apply to image alt text
scope: link             # Apply to link text
scope:                  # Apply to specific heading levels
  - heading.h1
  - heading.h2
  - heading.h3
scope:                  # Selector syntax: '~' negates, '&' conjoins
  - "~blockquote & ~heading"

Markdoc-aware linting (markdoc: true)

Opt-in — off by default, since Liquid/Jinja templates use the same {% %} delimiters and would otherwise get mistokenized as Markdoc:

markdoc: true   # shorthand for `{ schema: 'realm' }`

Writing about Markdoc syntax rather than using it (docs like this one, a tutorial, a changelog entry)? Wrap the literal {% ... %} in a code span — `{% partial /%}` — instead of leaving it bare in prose. Code spans never tokenize as Markdoc tags whether the flag is on or off, so that's the escape hatch.

Object form: choosing or extending the tag schema

markdoc: true is shorthand for the common case. The object form adds two things the boolean can't express: turning the schema-aware checks off while keeping tag tokenization, and layering a project's own custom tags over the built-in schema.

markdoc:
  schema: realm        # required -- 'realm' (the built-in schema below) or `false`; there is no default if this key is omitted
  extend:               # optional: your own tags, merged over the base schema
    tags:
      myCustomTag:
        selfClosing: true
        attributes:
          level:
            type: string
            enum: [info, warning, danger]
            required: true
  • schema: realm — the same built-in schema markdoc: true uses: @markdoc/markdoc's own built-in tags composed with @redocly/theme's tag definitions. It's generated from a theme build rather than hand-written, and a test fails if it drifts out of sync (see CONTRIBUTING.md for the regeneration command). This is what most projects want, and what the four recheck/markdoc rules validate against by default.
  • schema: false — tokenization and tag pairing still run, so markdoc.tag scope, prose-scope exclusion, fix protection, and markdoc-syntax/markdoc-pairing's grammar-level checks all still work. Only the two schema-dependent rules (markdoc-unknown-tag, markdoc-attributes) go inert, since there's no schema left for "unknown tag" or "missing required attribute" to mean anything against. Use this if you write Markdoc tags but don't have (or don't want) a schema to validate them against.
  • extend.tags — merges your own tag definitions over the base schema. On a name collision the merge is a whole-tag replace, matching how Markdoc's own config composition works, not a per-attribute deep merge. Declare your project's custom tags here (for example, a docs site's own @theme/markdoc/schema.ts overrides) so markdoc-unknown-tag and markdoc-attributes validate against your real tag surface instead of flagging every custom tag as unknown. Under schema: false there is no base to merge over, so extend does nothing.
  • extend.tagsFile — the same tag-definition surface as extend.tags, but sourced from a separate YAML file instead of written inline into recheck.yaml. This is the shape recheck markdoc-schema below generates, so a project with tags defined in TypeScript (a @theme/markdoc/schema.ts module, say) never hand-transcribes them into YAML.
    markdoc:
      schema: realm
      extend:
        tagsFile: ./recheck-markdoc-tags.yaml
    • Resolution: the path is resolved relative to the directory containing the recheck.yaml/recheck.yml that names it — never the process's current working directory — so tagsFile: ./tags.yaml always reads the file next to that config, wherever recheck is invoked from.
    • Precedence: tags merge in the order built-in schema → tagsFile → inline extend.tags, each layer a whole-tag replace on a name collision (same rule as extend.tags above). tags and tagsFile can both be set on the same extend block; extend with neither key is rejected by config validation as a likely no-op.
    • Errors are fatal to the whole run, not a silent markdoc downgrade. A tagsFile that doesn't exist, isn't valid YAML, isn't a YAML map, or contains a tag entry with an invalid shape all fail recheck run/recheck validate outright (Configuration validation failed!, the same failure every other structurally-invalid config produces) — markdoc checking is never quietly switched off while the rest of the config keeps running.

Turning markdoc on (either form) changes how every prose rule sees a Markdoc tag, not just markdoc.tag (above):

  • Prose scopes exclude the tag itself. paragraph, heading, list-item, blockquote, and table.header/table.cell all blank a tag's own {% ... %} span out of their content before any rule runs — a swap/pattern/capitalization match can't fire on the tag's syntax, and a length/metric count doesn't include it. The blanking is position-preserving (same-width spaces, never a deletion), so real text on either side of a tag keeps its exact line and column.
  • A segment with no prose left isn't emitted at all. A heading or table cell whose entire text IS a tag (# {% #anchor %}) prod