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.
The built-in set
Section titled “The built-in set”Seven templates ship in the package, derived from
The Good Docs Project templates pinned at
v1.6.0:
| Id | Doctypes it serves |
|---|---|
tgdp:how-to:1.6 | how-to |
tgdp:tutorial:1.6 | tutorial |
tgdp:reference:1.6 | reference |
tgdp:concept:1.6 | concept, explanation |
tgdp:troubleshooting:1.6 | troubleshooting |
tgdp:release-notes:1.6 | release-notes |
tgdp:readme:1.6 | readme |
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.
Template refs
Section titled “Template refs”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.
| Kind | Looks like | Notes |
|---|---|---|
| Built-in id | tgdp:how-to:1.6 | Lowercase segments separated by :, no slash and no .yaml suffix. Names one template on its own. |
| File path | ./templates.yaml, docs/shapes.json | Relative to the config file’s directory when it comes from config, and to the page when it comes from a page’s $template. |
| URL | https://example.com/templates.yaml | Fetched 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, …The file
Section titled “The file”YAML or JSON. One file holds one or more named templates, plus any reusable fragments they share.
| Top-level key | Type | Required | What it is |
|---|---|---|---|
templates | object | yes | The doctype templates this file defines, keyed by name. A name starts with a letter and holds letters, digits, - and _. |
components | object | no | Reusable fragments, targeted by $ref from elsewhere in the same file. Left unconstrained, because a fragment is validated where it is inlined. |
info | object | no | Free-form metadata such as title, version and source. Never read by the linter, declared so a file that carries it is not rejected. |
$schema | string | no | URL 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:
$schema: https://hawkeyexl.github.io/manni/schemas/lint/template/2.jsoninfo: 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: 1Validation 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 is a rule
Section titled “A template is a rule”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.
| Key | Type | Default | What it does |
|---|---|---|---|
title | string | none | Human-readable name, shown by manni lint templates. |
types | string[] | none | Doctypes this template serves, matched against a page’s type frontmatter. This is how routing happens without a flag. |
extends | ref | none | Inherit 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.Rule keys
Section titled “Rule keys”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.
| Key | Type | Default | What it does |
|---|---|---|---|
id | string | none | Handle 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 _. |
heading | string, string[], object, or false | any heading | What the section is called. Four forms. |
repeat | rule[] | none | Sibling rules matched in order as one unit, repeated by this rule’s min and max. See repeat. |
min | integer | 1 | Fewest occurrences. min: 0 makes the rule optional. |
max | integer | no limit | Most occurrences. Write max: 1 for exactly once, and max: 0 to forbid the section outright. |
sequence | block rule[] | none | What the section holds, in order. Each entry names exactly one content kind. |
contains | block rule | none | What the section holds, in any order, keyed by content kind. |
sections | rule[] | none | Rules for the subsections, one heading level down, in document order. |
description | string | none | Prose 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.
The four forms of heading
Section titled “The four forms of heading”| Form | Written | Matches |
|---|---|---|
| Exact text | heading: Overview | A section titled exactly Overview. |
| One of | heading: [Next steps, Where to go next] | A section titled any one of them. |
| Pattern | heading: { pattern: '^[Ss]ymptoms?\b' } | An unanchored regular expression over the heading text. |
| No heading | heading: false | A 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”.
- 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: 1repeat
Section titled “repeat”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:
- 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: 1A 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: 0That 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.Block rules
Section titled “Block rules”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 key | Counts | Extra keys |
|---|---|---|
paragraphs | Paragraphs | pattern, an unanchored regular expression every paragraph in the run must match. |
codeBlocks | Fenced or literal code blocks | language, the tag every block must declare (a list is one-of); fenceInfo, a pattern over the info-string tail where the format carries one. |
lists | Bulleted and numbered lists | ordered, a boolean (absent accepts either); items, applied to every item of every list in the run. |
tables | Tables | columns, the header cells in order. |
admonitions | Notes, tips and warnings | variant, one of note, tip, important, caution, warning, danger. |
images | Images | url, where the image must point; alt, the alternative text it must carry; attributes. |
blockquotes | Block quotes | None. A count only. |
definitionLists | Definition lists | None. A count only. |
elements | Named wrappers, such as an MDX component or a DITA element | tag, 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.
sequence or contains
Section titled “sequence or contains”contains asks what is there. sequence asks what is there and in what order,
one entry per run of one kind.
- id: step heading: pattern: '^Step \d+:' sequence: - paragraphs: min: 1 - codeBlocks: min: 1That 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:
- elements: tag: CardGrid max: 1 contains: elements: tag: [Card, LinkCard] min: 1Which kinds each format reports
Section titled “Which kinds each format reports”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 key | Reported by |
|---|---|
paragraphs, codeBlocks, lists, tables, admonitions, images, blockquotes | Every format. |
definitionLists | HTML, AsciiDoc, reStructuredText and XML. Neither Markdown flavour has a definition list in the spec. |
elements | MDX alone. Plain Markdown has no JSX and reads <Steps> as raw HTML. |
The file extensions behind those columns:
| Format | Extensions |
|---|---|
| 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 that cannot run is a warning
Section titled “A rule that cannot run is a warning”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.
How sections are matched
Section titled “How sections are matched”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 happened | What it costs |
|---|---|
A rule matched a section its heading accepts | 0 |
A rule was pushed onto a section its heading rejects | 1 |
| A required occurrence found no section | 2 |
| A section found no rule | 2 |
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:
- A rule that names its heading beats a wildcard taking the same section.
- A repeating rule prefers its sections adjacent.
- 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.
Seeing the pairing it chose
Section titled “Seeing the pairing it chose”--explain prints the alignment under each routed file, so an unexpected
finding is one command away from an explanation:
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 unroutedA 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:
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.
extends
Section titled “extends”A template inherits another’s rules by ref, and the merge happens by id:
templates: house-how-to: types: [how-to] extends: tgdp:how-to:1.6 sections: - id: see-also min: 0A 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.Routing a page
Section titled “Routing a page”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.
---type: how-to---
# Rotate an API keyA 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.
$template
Section titled “$template”One page naming its own template, by ref, overriding type routing for itself:
---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.
Which types: wins
Section titled “Which types: wins”The doctype table is built from three sources, later overwriting earlier:
- The built-in TGDP templates.
- Every file in
lint.templates, in order, then every--templatesfile, in order. 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.
Keys the loader refuses
Section titled “Keys the loader refuses”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.
| Written | Message |
|---|---|
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.
instructions
Section titled “instructions”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: aiRelated
Section titled “Related”- Write a template. The journey from one page to a routed doctype shape.
- Configuration reference. Where templates are named, and the full resolution chain.
- CLI reference.
--templates,-t,--explain,manni lint templatesandmanni lint templates infer. - Fix a failing check. Each rule, and what to change.