Skip to content

Templates reference

A template describes the shape a doctype promises. That is which sections a page of that kind has, in what order, and what has to be inside each one. manni lint structure resolves one template per page and aligns the page’s section tree against it.

Templates are data, not code. There is no language model anywhere in this, and no network call except fetching a template you pointed at yourself.

To write your first one, follow Write a template. This page is the exhaustive key list behind it.

Seven templates ship in the package, derived from The Good Docs Project templates pinned at v1.6.0:

IdDoctypes it serves
tgdp:how-to:1.6how-to
tgdp:tutorial:1.6tutorial
tgdp:reference:1.6reference
tgdp:concept:1.6concept, explanation
tgdp:troubleshooting:1.6troubleshooting
tgdp:release-notes:1.6release-notes
tgdp:readme:1.6readme

So the doctype vocabulary a page can declare out of the box is concept, explanation, how-to, readme, reference, release-notes, troubleshooting and tutorial. manni lint templates prints the same list from your own checkout, including whatever your config adds.

The version in an id is a claim about which upstream revision it mirrors. Upstream is the authority. The project’s own published Markdown templates are vendored and linted against these files, so the two cannot drift. Where they disagree, the template here is wrong.

Everywhere a template is named, the value is a ref. That covers -t/--template, lint.template, lint.types values, lint.overrides[].template, and extends. There are three kinds.

KindLooks likeNotes
Built-in idtgdp:how-to:1.6Lowercase segments separated by :, no slash and no .yaml suffix. Names one template on its own.
File path./templates.yaml, docs/shapes.jsonRelative to the config file’s directory when it comes from config, and to the page when it comes from a page’s $template.
URLhttps://example.com/templates.yamlFetched once per run, 10-second timeout. Never allowed from a page’s $template.

A file or URL ref may carry a #name fragment naming one template inside it, as in ./templates.yaml#how-to. Without a fragment, a file holding exactly one template resolves unambiguously. A file holding several is an error that lists them. A fragment on a built-in id is an error, because a built-in id already names one template.

manni: ./templates.yaml defines 2 templates; name one with a "#" fragment (e.g. "./templates.yaml#how-to"). Available: how-to, reference.
manni: Unknown built-in template "tgdp:howto:1.6". Available: tgdp:how-to:1.6, tgdp:tutorial:1.6, …

YAML or JSON. One file holds one or more named templates, plus any reusable fragments they share.

Top-level keyTypeRequiredWhat it is
templatesobjectyesThe doctype templates this file defines, keyed by name. A name starts with a letter and holds letters, digits, - and _.
componentsobjectnoReusable fragments, targeted by $ref from elsewhere in the same file. Left unconstrained, because a fragment is validated where it is inlined.
infoobjectnoFree-form metadata such as title, version and source. Never read by the linter, declared so a file that carries it is not rejected.
$schemastringnoURL of the template schema, so an editor can offer completion. Read by editors, never by the linter.

Point $schema at the published copy to get completion and inline errors while you type:

templates.yaml
$schema: https://hawkeyexl.github.io/manni/schemas/lint/template/2.json
info:
title: Acme doctypes
version: "1.0"
templates:
runbook:
types: [runbook]
contains:
paragraphs:
min: 1
sections:
- id: symptom
heading: Symptom
max: 1
- id: fix
heading: Fix
max: 1
contains:
codeBlocks:
min: 1
- id: see-also
heading: See also
min: 0
max: 1

Validation is strict. additionalProperties: false holds at every level, so a paragrafs typo is named rather than ignored.

manni: ./templates.yaml is not a valid template file: /templates/runbook/sections/0 must NOT have additional properties ("paragrafs").

A template describes the page, so it takes every rule key. Its heading constrains the page’s own title, its sequence or contains describes what sits before the first heading, and its sections are the headings under it.

Three keys belong to a template alone.

KeyTypeDefaultWhat it does
titlestringnoneHuman-readable name, shown by manni lint templates.
typesstring[]noneDoctypes this template serves, matched against a page’s type frontmatter. This is how routing happens without a flag.
extendsrefnoneInherit another template’s rules. See extends.

Two keys are refused on a template. min and max describe how often a rule occurs, and a page occurs once.

manni: ./templates.yaml: a template may not set min or max; a page is one page.

sections is a list, and its order is the order the document must use. Duplicate headings are legal, because two sections named Example are two entries in a list rather than two keys in a map.

KeyTypeDefaultWhat it does
idstringnoneHandle for this rule, unique among its siblings. It appears in messages and in --explain, and extends targets it. Starts with a letter, then letters, digits, - and _.
headingstring, string[], object, or falseany headingWhat the section is called. Four forms.
repeatrule[]noneSibling rules matched in order as one unit, repeated by this rule’s min and max. See repeat.
mininteger1Fewest occurrences. min: 0 makes the rule optional.
maxintegerno limitMost occurrences. Write max: 1 for exactly once, and max: 0 to forbid the section outright.
sequenceblock rule[]noneWhat the section holds, in order. Each entry names exactly one content kind.
containsblock rulenoneWhat the section holds, in any order, keyed by content kind.
sectionsrule[]noneRules for the subsections, one heading level down, in document order.
descriptionstringnoneProse about what the rule is for. Never checked.

A rule carries sequence or contains, never both. A rule carries heading or repeat, never both.

manni: ./templates.yaml: rule "steps" sets both sequence and contains; a rule says what it holds in order, or in any order, never both.

An absent sections says nothing about the subsections. An empty list says no subsection is permitted, so every one of them reports as unexpected.

FormWrittenMatches
Exact textheading: OverviewA section titled exactly Overview.
One ofheading: [Next steps, Where to go next]A section titled any one of them.
Patternheading: { pattern: '^[Ss]ymptoms?\b' }An unanchored regular expression over the heading text.
No headingheading: falseA section with no heading of its own, such as an include fragment whose host page owns the title.

Omitting heading altogether makes the rule a wildcard. It matches any heading, or none, and is how a doctype says “however many sections the task takes, each named by the author”.

examples/lint/nginx.yaml
- id: prerequisites
heading:
- Prerequisites
- Before you begin
- What you need
min: 0
max: 1
- id: task
description: However many sections the task itself takes, each named by the author.
min: 1

repeat groups sibling rules into one repeated unit, and the group’s own min and max say how often the unit occurs. The group has no heading of its own, so put the heading on the first rule inside it.

This is the shape a map of section rules could not express. A troubleshooting doctype is one or more symptoms, each with a cause and the solution to it:

templates/lint/tgdp/troubleshooting.yaml
- id: cause-or-solution
repeat:
- id: cause
heading:
pattern: '^[Cc]auses?\b'
max: 1
- id: solution
heading:
pattern: '^([Ss]olutions?|[Ww]orkarounds?)\b'
min: 0
max: 1

A copy of the group that is absent whole reports once, as manni:lint/structure/missing-group, rather than once per member.

Allowing sections the template does not describe

Section titled “Allowing sections the template does not describe”

A trailing rule with min: 0 and no heading absorbs whatever follows. It composes, so putting it after the steps allows extras only there:

- id: steps
heading: Steps
max: 1
- min: 0

That is also what the unexpected_section message tells you to write:

Unexpected section "Benchmarks". Add a trailing rule with min: 0 to allow sections the template does not describe.

sequence and contains are both written in the same block keys. A key is plural because it carries a count. The content model says codeBlock, and a template says codeBlocks: { min: 1 }.

Every block key takes min and max. These are the keys each one adds.

Block keyCountsExtra keys
paragraphsParagraphspattern, an unanchored regular expression every paragraph in the run must match.
codeBlocksFenced or literal code blockslanguage, the tag every block must declare (a list is one-of); fenceInfo, a pattern over the info-string tail where the format carries one.
listsBulleted and numbered listsordered, a boolean (absent accepts either); items, applied to every item of every list in the run.
tablesTablescolumns, the header cells in order.
admonitionsNotes, tips and warningsvariant, one of note, tip, important, caution, warning, danger.
imagesImagesurl, where the image must point; alt, the alternative text it must carry; attributes.
blockquotesBlock quotesNone. A count only.
definitionListsDefinition listsNone. A count only.
elementsNamed wrappers, such as an MDX component or a DITA elementtag, the element’s name as an author writes it (a list is one-of); attributes; sequence or contains, describing what the element holds.

items under lists takes min, max, and either sequence or contains. It recurses arbitrarily deep.

attributes is a map of name to value. A string is the required value, true requires the attribute to be present, and false requires it to be absent.

Some extra keys report a finding of their own, and the rest narrow what the count sees. language, columns, ordered, variant and elements’ attributes each report. fenceInfo, tag, and images’ url, alt and attributes filter, so a node that does not match is simply not counted and the count rule is what fires. An images rule with any attributes at all therefore counts nothing, because an image node carries no attributes in the content model.

contains asks what is there. sequence asks what is there and in what order, one entry per run of one kind.

examples/lint/doc-detective.yaml
- id: step
heading:
pattern: '^Step \d+:'
sequence:
- paragraphs:
min: 1
- codeBlocks:
min: 1

That section must hold a run of paragraphs followed by a run of code blocks. A code block above the prose reports as manni:lint/structure/content-order. Write contains instead where the order is the author’s business.

An element’s sequence or contains describes what the element holds, never the enclosing section’s content. A section holding a <Steps> component holds one thing, not the four paragraphs inside it, so a template reaches inside deliberately by naming the element:

examples/lint/manni-docs.yaml
- elements:
tag: CardGrid
max: 1
contains:
elements:
tag: [Card, LinkCard]
min: 1

A parser declares the content kinds it can emit. manni lint tools prints the matrix from your own installation, one kinds: line per format.

Only two keys are not universal, so the table says which those are rather than repeating “yes” fifty-four times.

Block keyReported by
paragraphs, codeBlocks, lists, tables, admonitions, images, blockquotesEvery format.
definitionListsHTML, AsciiDoc, reStructuredText and XML. Neither Markdown flavour has a definition list in the spec.
elementsMDX alone. Plain Markdown has no JSX and reads <Steps> as raw HTML.

The file extensions behind those columns:

FormatExtensions
Markdown.md, .markdown
MDX.mdx
HTML.html, .htm
AsciiDoc.adoc, .asciidoc
reStructuredText.rst
XML.xml, .dita, .ditamap

A directory walk picks up .dita and .ditamap but not every .xml, because a docs tree also holds pom.xml and sitemap.xml. Name such a file directly, or pass --as xml.

A DITA map is read as a tree of titled entries. A topicref nests the way a heading does, and so do topichead, mapref, glossref, and a bookmap’s chapter and appendix.

An entry that points by keyref alone carries no title of its own. That is ordinary in a real map. So a template that requires a title on every entry will fail maps that are correct.

A reltable and a keydef are not navigation. Neither appears in the tree.

A rule about a kind this file’s format cannot report is removed before matching and reported once, anchored at the top of the file:

1:1 warning manni:lint/structure/unsupported-content-kind The markdown parser does not report definition lists, so the "glossary" rule in template "reference" is not checked for this file.

Removing the rule is the point. Left standing, a table rule counts zero tables in a format with no tables. It then fails a page nothing is wrong with, while the report says the rule was never checked.

A warning never moves the exit code. A docset of mixed formats stays green while still saying which rules went unchecked, and where.

The linter lines the page’s sections up against the rule list and picks the pairing with the fewest problems. You do not need the algorithm. You need to know what “fewest problems” counts, because that is what decides which finding you see.

What happenedWhat it costs
A rule matched a section its heading accepts0
A rule was pushed onto a section its heading rejects1
A required occurrence found no section2
A section found no rule2

Coercion costing less than the alternative is why one wrong heading reads as one finding. Reporting it as a missing section plus an unexpected one would cost 4, so the pairing that says Expected title "Overview", but found "Overvue" wins.

Coercion is open only to a required rule whose max is 1. An optional rule loses nothing by standing aside, so it never adopts a stray section and then complains about its heading.

When two pairings cost the same, three tie-breaks settle it, in order:

  1. A rule that names its heading beats a wildcard taking the same section.
  2. A repeating rule prefers its sections adjacent.
  3. The earlier rule wins.

These weights and tie-breaks are interface. Changing one changes the report for documents nobody edited, so a test pins them.

--explain prints the alignment under each routed file, so an unexpected finding is one command away from an explanation:

Terminal window
manni lint structure docs/rotate-key.md --explain
▸ docs/rotate-key.md
· cli --template not given
· frontmatter-template no $template in frontmatter
· config-override no overrides configured
→ type tgdp:how-to:1.6 type: how-to -> builtin template
alignment
(page) ← "Rotate an API key"
overview ← "Overview"
before-you-start ← "Before you start"
task ← "Rotate the key"
see-also ← "See also"
1 file, 1 routed, 0 unrouted

A rule is named by its id, or by its literal heading, or (any) for a wildcard. A rule that took several sections collapses to ×N with the first and last. A rule that took nothing reads ← (missing), a section no rule took reads (unexpected), and a pushed pairing is marked (coerced). The CLI reference has the full grammar of the block.

$ref shares one rule between templates in the same file, usually through components:. The fragment’s keys are merged in beside whatever you write next to the $ref:

examples/lint/doc-detective.yaml
templates:
schema-reference:
types: [schema-reference]
sections:
- $ref: "#/components/fields"
components:
fields:
id: fields
heading: Fields
max: 1
contains:
tables:
min: 1
columns: [Field, Type, Description, Default]

Only same-document pointers are followed. A $ref at a path or a URL is refused, not fetched, and the schema then rejects the leftover $ref key. A template file must not decide what the linter reads off the disk or off the network. Cross-file reuse is extends, which goes through the same ref rules as everything else.

A template inherits another’s rules by ref, and the merge happens by id:

examples/lint/templates.yaml
templates:
house-how-to:
types: [how-to]
extends: tgdp:how-to:1.6
sections:
- id: see-also
min: 0

A child rule whose id the parent uses replaces that rule in place. A child rule with an unused id, or with none at all, is appended. Every other key the child sets replaces the parent’s outright, repeat included, because merging two repeated runs by position would be guesswork.

Matching by id rather than by position is what makes this safe. Inserting a rule in the parent cannot silently re-target an override below it. The handle is id rather than name so that name means one thing in the whole file, which is the name of an element.

The chain resolves innermost first, and a cycle is an error naming the loop:

manni: Template "extends" cycle: a -> b -> a.

Two keys on a page take part, and both are ordinary frontmatter.

The page’s doctype. It is matched against every template’s types: list and against lint.types in config. This is the normal way a page finds its template, and it needs no flag and no glob.

docs/rotate-key.md
---
type: how-to
---
# Rotate an API key

A type nothing serves is a finding rather than a skip, because the page made an assertion the tool could not honour. The message suggests the nearest known doctype, or lists them all:

1:1 manni:lint/structure/unknown-type No template serves type "how-two". Did you mean "how-to"? Declare it on a template with "types:", then pass that file with --templates.

A page with no type at all is skipped, not failed. That is what lets you point the tool at a whole tree on day one and only lint what has opted in.

A corpus that carries no doctype at all routes by path instead, through lint.overrides. examples/lint/manni-docs.yaml is written that way.

One page naming its own template, by ref, overriding type routing for itself:

docs/odd-one-out.md
---
type: how-to
$template: ./templates.yaml#legacy-how-to
---

It sits second in the resolution chain, under --template and above everything in config. A relative ref is resolved beside the page.

A $template naming a URL is refused, and the page gets a manni:lint/structure/template finding:

$template may not name a URL ("https://…"): a document must not choose what the linter fetches. Declare it in manni.config.yaml under "templates:" or "types:", or pass it with --template.

A document is content. Letting content pick a host for the linter to fetch from turns every contributor into someone who can make CI make an outbound request.

The doctype table is built from three sources, later overwriting earlier:

  1. The built-in TGDP templates.
  2. Every file in lint.templates, in order, then every --templates file, in order.
  3. lint.types, the explicit map in config.

So declaring types: [how-to] on your own template is all it takes to replace the built-in how-to shape for the whole repository. Nothing else changes, and no page is edited.

Each of these is named in a sentence of its own rather than reported as an unexpected property. A schema error says nothing about where the key went.

WrittenMessage
required: false"required" is not a template key. A rule is optional with "min: 0".
additionalSections"additionalSections" is not a template key. v2 has no equivalent, so drop it.
code_blocks"code_blocks" is not a template key. The v2 spelling is "codeBlocks".
sections as a map"sections" is a list of rules, not a map. Name each rule with "id" and list them in order.
repeat: true"repeat" is a list of rules, not a boolean. A rule repeats through "min" and "max".
paragraphs.patterns"patterns" is not a paragraphs key. The v2 spelling is "pattern", one regular expression.

manni lint had not shipped when the grammar replaced the earlier draft, so there is no published template to migrate and no alias for a renamed key. The refusals exist for a working copy on someone’s disk.

doc-structure-lint templates could carry an instructions: key, evaluated by a language model. Structure checking here is deterministic, so the key is refused outright rather than silently ignored. The message quotes your own instruction back, in the shape the eval takes:

manni: ./templates.yaml: "templates.how-to.sections.title" uses `instructions`, which manni lint no longer evaluates — structure checking is deterministic. Move it to a manni docevals assertion eval in manni.config.yaml:
docevals:
evals:
how-to-title:
assertion: Must mention the intent of the document
grader: ai