Skip to content

Write a template

Your team agreed what a how-to has to contain. This page turns that agreement into one reviewable file, so every page of that doctype is held to it.

You will start from a page that already has the shape you want and infer a first template from it. Then you will loosen the parts one page cannot show, assert the content the doctype is actually defined by, and route it. Each step runs a real command.

  • manni installed. manni lint --help should print the verb list. If you did not install globally, prefix every command below with npx @hawkeyexl/manni.
  • One page you are happy with. Not the average page. The one you would send someone as the example.
  • Somewhere to put the file. ./templates.yaml at the repository root is the convention, beside manni.config.yaml.

Here is the exemplar. It is the same how-to the get-started page lints:

docs/rotate-key.md
---
type: how-to
---
# Rotate an API key
## Overview
Rotating a key replaces the old secret without interrupting live traffic.
## Before you start
You need an account with the `admin` role.
## Rotate the key
Issue the replacement, then retire the old one.
```bash
widget keys rotate --id abc123
```
## See also
Read about key scopes and expiry.
  1. Infer a template from it.

    Terminal window
    manni lint templates infer docs/rotate-key.md
    templates:
    how-to:
    heading: Rotate an API key
    sections:
    - heading: Overview
    contains:
    paragraphs:
    min: 1
    - heading: Before you start
    contains:
    paragraphs:
    min: 1
    - heading: Rotate the key
    contains:
    paragraphs:
    min: 1
    codeBlocks:
    min: 1
    language: bash
    - heading: See also
    contains:
    paragraphs:
    min: 1

    The template is named how-to because that is the page’s own type:. Pass --name <name> to call it something else.

  2. Write it to a file.

    Terminal window
    manni lint templates infer docs/rotate-key.md --out ./templates.yaml
    Wrote template "how-to" to ./templates.yaml

    The confirmation goes to stderr, so stdout stays empty. An existing path is refused unless you pass --force.

  3. Check that it holds.

    Terminal window
    manni lint structure docs/rotate-key.md -t ./templates.yaml#how-to
    ✓ docs/rotate-key.md
    1 file checked, 1 passed, 0 failed, 0 skipped

    An inferred template always lints its own source clean. That is the contract, and it is why the next step exists.

One page cannot show what varies, so nothing optional and nothing repeating is invented. Every heading is the exact text this page used, every rule is exactly one occurrence, and no pattern is guessed. Generalising is your judgement, and these are the four moves.

The page’s own heading is the title of one document. Drop the key to accept any title:

templates.yaml
templates:
how-to:
heading: Rotate an API key
sections:

Real corpora rarely use one word for the last section. A list of headings matches any one of them:

templates.yaml
- id: see-also
heading:
- See also
- Further reading
- Next steps
max: 1

Where the wording varies but the form does not, use a pattern instead. Anchor on the part that is stable:

heading:
pattern: '^[Ss]ymptoms?\b'

min: 0 makes a rule optional. max: 1 says it may appear once at most, and leaving max off means no limit:

templates.yaml
- id: before-you-start
heading: Before you start
min: 0
max: 1

Add the trailing wildcard while you are here. It is one rule, and it turns every future section nobody anticipated from a failure into a non-event:

templates.yaml
- min: 0

Some doctypes are a unit repeated. A troubleshooting page is symptoms, each with a cause and the solution to that cause. repeat: groups sibling rules into one repeated unit, and the group’s own min and max count the units:

templates.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 group has no heading of its own. Put the heading on the first rule inside it. When a whole unit is absent you get one missing-group finding rather than one per member.

A doctype is often defined by its content rather than its headings. A reference page is its table. A how-to step is its command. Say so, and the assertion is checked on every page.

templates.yaml
- id: fields
heading: Fields
max: 1
contains:
tables:
min: 1
columns: [Field, Type, Description, Default]

A table whose header cells differ reports tables-columns and names both the expected and the actual header.

contains: asks what is there. sequence: asks what is there and in what order, one entry per run of one kind. A step is prose and then a command, never the other way round:

templates.yaml
- id: steps
repeat:
- id: step
heading:
pattern: '^Step \d+:'
sequence:
- paragraphs:
min: 1
- codeBlocks:
min: 1
language: bash

A rule carries sequence or contains, never both. The templates reference lists every block key and what each one adds.

A -t flag on every command is not a doctype policy. Declare the file once and the routing happens by itself.

Give the template a types: list, and every page declaring one of those doctypes is held to it:

templates.yaml
templates:
how-to:
types: [how-to]
sections:
# ...
manni.config.yaml
lint:
templates:
- ./templates.yaml

Your template now outranks the built-in tgdp:how-to:1.6 for the whole repository. No page is edited, and no flag is passed.

Plenty of docsets carry no type: frontmatter at all. Route by glob instead, and leave types: off the template so it does not claim a doctype nothing uses:

manni.config.yaml
lint:
templates:
- ./templates.yaml
overrides:
- files: "docs/**/reference/*.mdx"
template: ./templates.yaml#cli-reference

An override outranks a page’s own type:, because it is repository policy. examples/lint/manni-docs.yaml is a worked example of a corpus routed this way.

Build on a built-in instead of replacing it

Section titled “Build on a built-in instead of replacing it”

Where a built-in is right in all but one place, inherit it. The merge is by rule id, so everything you do not mention is kept:

templates.yaml
templates:
house-how-to:
types: [how-to]
extends: tgdp:how-to:1.6
sections:
- id: see-also
min: 0
  1. Confirm the template is loaded and claims what you think.

    Terminal window
    manni lint templates
    Templates:
    tgdp:how-to:1.6 — How-to guide (The Good Docs Project) types: how-to [builtin]
    ...
    how-to types: how-to [./templates.yaml]

    The entry from your file is listed last, which is also the order routing applies them in. A later entry claiming the same doctype is the one that wins.

  2. Run it over everything.

    Terminal window
    manni lint structure docs/

    Read any finding against what you meant to assert. A rule firing on a page everyone agrees is correct means the template is wrong, not the page.

  3. Read the pairing when a finding surprises you.

    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

    The top half says which lever chose the template. The alignment says which rule took which section. ← (missing) is a rule that took nothing, (unexpected) is a section no rule took, and (coerced) is a rule pushed onto a section whose heading it rejects.

The linter lines your rules up against the page’s sections and picks the pairing with the fewest problems. Two facts about “fewest” change how you write a template.

A named rule beats a wildcard. Where both could take a section, the rule that names its heading gets it. So a trailing wildcard never steals a section a real rule wanted.

A required rule would rather be wrong than absent. Pushing a required rule onto the wrong section costs less than reporting it missing and the section unexpected. That is what turns one bad heading into one finding instead of two. It also means a required rule can adopt an unrelated section. When that happens, min: 0 is the fix. An optional rule loses nothing by standing aside, so it never adopts anything.

The full cost table and the three tie-breaks are in the templates reference.

The doctype’s shape is now one file your team can review in a pull request. Put it behind a gate so drift is caught on the way in.