Markdown outline validator

Check that Markdown documents have the expected structure.

Outlint validates headings and YAML frontmatter against a declarative schema. Use the command-line tool in local checks and CI, or embed the IO-free Rust library in your own tooling.

Current release: Outlint 0.1.0 is the first public release. See the installation commands, the changelog, and the pre-built binaries on GitHub Releases.

.outlint.ymlyaml
version: 1
title: "*"
sections:
  - match: "Overview"
    required: true
  - match: "Decision"
    required: true
adr.mdmarkdown
# Move audit logs to object storage

## Overview

Store immutable audit logs in the archive bucket.
Terminaloutlint 0.1.0
$ outlint check adr.md --schema .outlint.yml
adr.md:1:1 [missing-section] matched 0 sections, but at least 1 are required
  expected: "Decision"
  rule: .outlint.yml:6:5

1 diagnostic in 1 file

What Outlint checks

Outlint parses each Markdown document into a section tree and checks that tree against the schema. It reports diagnostics; it never rewrites the document.

Headings and scopes

Require, allow, deny, and count headings at each level of a document. Rules bind in document order by default, and strict scopes reject headings that no rule matches.

Matching and constraints

Match exact text, globs, regular expressions, or any heading. Express ordering, dependencies, conflicts, and groups between named rules.

YAML frontmatter

Require or forbid frontmatter, validate it with an inline or linked JSON Schema, and use frontmatter values in outline constraints.

Tooling-friendly results

Diagnostics have stable IDs, document locations, and structural schema locations. The CLI also provides complete JSON output.

Constraint catalog

Constraints express relationships that a section tree alone cannot: alternatives, dependencies, exclusions, and orders that the rule list does not already impose. They can connect headings to each other and to YAML frontmatter.

References are scoped. A bare ref such as summary names a direct child rule where the constraint is attached. Use a dotted path such as deployment.rollback-plan to descend, or $.overview to anchor at the schema root. Exact matchers receive implicit IDs; pattern matchers need an explicit id. A constraint attached to a rule is evaluated separately for each instance of that parent.

A heading ref is true when at least one complete matching path exists. Put structural requirements such as “every API section has Errors” on the nested rule with required: true; constraints test presence, not every repeated instance.

one_of

two or more refs

Exactly one listed proposition must be true. Use it when the document must choose one alternative.

one_of: [changelog, release-notes]

any_of

two or more refs

At least one listed proposition must be true; several may be true.

any_of: [summary, abstract, overview]

at_most_one

two or more refs

Zero or one listed proposition may be true. This permits no selection but rejects overlapping alternatives.

at_most_one: [stable-api, experimental-api]

all_or_none

two or more refs

Either every listed proposition is true or none is. Use it for sections that form one optional unit.

all_or_none: [request, response, examples]

requires

one if; one or more then

When if is true, every then ref must be true. A single consequence may be a scalar; write a list when several are required.

requires: { if: deployment, then: [rollback-plan, monitoring] }

conflicts

one if; one or more then_not

When if is true, every then_not ref must be false. Use it for sections or frontmatter states that cannot coexist.

conflicts: { if: deprecated, then_not: roadmap }

ordered

two or more heading refs

For the listed refs that are present, every occurrence of each must precede every occurrence of the next. Unlisted siblings may appear between them. Every scope is ordered by its rule list by default, so the constraint is refused there (ordered-scope-mismatch); use it in a scope declared ordered: false (options.ordered_sections: false for the top-level scope) to spell a partial order, or an order that differs from matching precedence. Refs must share one concrete scope; fm. refs and paths through repeated ancestors are not orderable.

ordered: [overview, design, examples]

Frontmatter propositions

Use these anywhere a constraint accepts a ref, except in ordered. They address the document from any constraint scope.

fm.status
True when status exists and is not null. Dotted keys descend through mappings: fm.release.channel.
fm.status=deprecated
True when the non-null value equals the YAML scalar, with no type coercion; string equality follows match_case. An =null proposition is therefore never true.
requires: { if: fm.status=deprecated, then: migration }
requires: { if: breaking-changes, then: fm.semver=major }

Frontmatter refs deliberately stop at presence and typed scalar equality. Put patterns, comparisons, type checks, and keys containing . or = in the JSON Schema.

Schema example

Rules use first-match-wins semantics within a heading scope, and their list order is the document order by default. Cardinality is checked per parent, and constraints refer to rules by ID. The specification is the normative reference.

docs/guide.mdmarkdown
---
status: published
---

# Storage guide

## Overview

How the storage service is organised.

## API: Upload

### Parameters

`bucket`, `key`, and `body`.

### Examples

See the client repository.

## Appendix A

Glossary.
docs/legacy.mdmarkdown
---
status: deprecated
---

# Legacy storage API

## Overview

This API is retired.

## API: Upload

### Parameters

`bucket` and `key`.
.outlint.ymlyaml
version: 1

options:
  match_case: false

frontmatter:
  required: true
  schema:                 # inline JSON Schema, or a path to a .json file
    type: object
    properties:
      status:
        enum: [draft, published, deprecated]
    required: [status]

title: "*"
sections:
  - match: "Overview"
    required: true

  - id: api
    match: "/API: .+/"       # anchored regular expression
    repeat: "0..n"
    strict: true
    sections:
      - match: "Parameters"
        required: true
      - match: "Examples"
        required: false

  - match: "Migration"
    required: false

  - match: "Appendix *"   # glob

constraints:
  - requires:
      if: fm.status=deprecated
      then: migration
Terminaloutlint 0.1.0
$ outlint schema check .outlint.yml
(no output; exit status 0)
$ outlint check docs/guide.md
(no output; exit status 0)
$ outlint check docs/legacy.md
docs/legacy.md:1:1 [requires] a required consequence is missing
  references:
    - fm.status="deprecated"
    - migration (exact "Migration")
  constraint: .outlint.yml:36:5

1 diagnostic in 1 file

Exact matchers receive generated IDs, so the constraint can refer to migration without an explicit id field; pattern matchers such as the regular-expression rule receive none and need one, here id: api, to be referenced. No ordered constraint is needed: Overview must precede the API sections because the rule list is the document order. Use strict: true to reject unmatched children in a scope. docs/guide.md passes; docs/legacy.md is deprecated without a Migration section, so the requires constraint reports it.

Command-line use

outlint check discovers the nearest .outlint.yml separately for each Markdown file unless you pass --schema. Human output is quiet when validation succeeds; use --format json in scripts and integrations.

Cargo builds from source with Rust 1.86 or newer

cargo install outlint

npm fetches the matching pre-built binary on first run

npm install --global @outlint/cli
.outlint.ymlyaml
version: 1
title: "*"
sections:
  - match: "Context"
    required: true
  - match: "Decision"
    required: true
  - match: "Consequences"
    required: true
docs/adr-0042.mdmarkdown
# ADR 0042: Retire the legacy upload API

## Context

The legacy endpoint has no remaining callers.

## Consequences

Clients must migrate to the v2 upload API.

## Decision

Remove the endpoint in the next major release.
Terminaloutlint 0.1.0
$ outlint check docs/adr-0042.md
docs/adr-0042.md:1:1 [ordered] sections are out of the declared order: `Decision` must precede `Consequences`
  observed order:
    docs/adr-0042.md:7:1 "ADR 0042: Retire the legacy upload API > Consequences"
    docs/adr-0042.md:11:1 "ADR 0042: Retire the legacy upload API > Decision"
  schema: .outlint.yml:2:8

1 diagnostic in 1 file
$ outlint check docs/adr-0042.md --format json
{
  "results": [
    {
      "diagnostics": [
        {
          "id": "ordered",
          "involved_headers": [
            {
              "header_path": [
                "ADR 0042: Retire the legacy upload API",
                "Consequences"
              ],
              "location": {
                "column": 1,
                "line": 7
              }
            },
            {
              "header_path": [
                "ADR 0042: Retire the legacy upload API",
                "Decision"
              ],
              "location": {
                "column": 1,
                "line": 11
              }
            }
          ],
          "location": {
            "column": 1,
            "line": 1
          },
          "message": "sections are out of the declared order: `Decision` must precede `Consequences`",
          "schema_location": {
            "column": 8,
            "line": 2,
            "path": ".outlint.yml"
          },
          "schema_node": {
            "kind": "title"
          },
          "target": {
            "kind": "document"
          }
        }
      ],
      "kind": "document",
      "path": "docs/adr-0042.md",
      "schema": ".outlint.yml"
    }
  ],
  "summary": {
    "diagnostics": 1,
    "documents": 1,
    "files": 1,
    "schemas": 0
  },
  "version": 2
}
{"results":[{"diagnostics":[{"id":"ordered","involved_headers":[{"header_path":["ADR 0042: Retire the legacy upload API","Consequences"],"location":{"column":1,"line":7}},{"header_path":["ADR 0042: Retire the legacy upload API","Decision"],"location":{"column":1,"line":11}}],"location":{"column":1,"line":1},"message":"sections are out of the declared order: `Decision` must precede `Consequences`","schema_location":{"column":8,"line":2,"path":".outlint.yml"},"schema_node":{"kind":"title"},"target":{"kind":"document"}}],"kind":"document","path":"docs/adr-0042.md","schema":".outlint.yml"}],"summary":{"diagnostics":1,"documents":1,"files":1,"schemas":0},"version":2}

Exit status is 0 for valid inputs, 1 for validation diagnostics, and 2 for usage or operational errors. Every example on this page is a real file under examples/ in the site repository; the output shown is captured from the outlint build named in the terminal heading.

Rust library

outlint-core is the pure, IO-free library behind the command-line tool. It never reads files, touches the network, or sets an exit status: callers provide schema and Markdown source text and receive normalized data and diagnostics.

Compile once, validate many

load_schema turns schema text into a checked schema; PreparedValidator compiles it once and validates any number of parsed documents. Schema errors are collected together, never returned as a partial schema.

Structured diagnostics

Each diagnostic has a stable ID, a document location, a typed target that distinguishes a heading that exists from one the schema expected, and a schema-node address that resolves back to the schema source.

Caller-owned IO

A schema whose frontmatter.schema names a JSON Schema file is loaded through load_schema_with_resources: the library lists the files to read and the caller supplies their contents, so validation is reproducible and never fetches remote references.

Documented API

Usage examples, the loading and validation boundary, and every public type are documented on docs.rs. The specification defines the semantics the library implements.

Outlint is currently a 0.x project. Expect breaking changes to the Rust API, schema language, and diagnostic set before 1.0. The minimum supported Rust version is 1.86.