Skip to content

Check a MadCap Flare project

A MadCap Flare project is a folder of XHTML files, and many teams keep it in Git. Flare checks a topic when it builds the output. Nothing checks it on a pull request, so a missing description or a misspelled cross-reference ships.

manni reads the project as it is, with no Flare install and no export step. Topics and snippets are HTML to every domain, so manni meta validate and manni graph run on them unchanged. manni lint and manni graph read each topic as its target builds it, and a TOC as a map. The glossary is a termbase, and a meta tag set is a schema. Where Flare is installed, manni site builds and serves the target. This page walks one small project from a first validation to a CI gate.

The project is the fixture test/fixtures/flare/widget/ in the manni repository, so you can run each command there.

Widget.flprj
manni.config.yaml
schemas/snippet.schema.json
Content/Topics/Install.htm
Content/Topics/Configure.htm
Content/Resources/Snippets/BeforeYouStart.flsnp
Project/TOCs/Main.fltoc
Project/VariableSets/General.flvar
Project/ConditionTagSets/Default.flcts
Project/Targets/HTML5.fltar
Project/Targets/Print.fltar
Project/Glossaries/Glossary.flglo
Project/Advanced/Topics.flmeta

Install.htm includes the snippet, and its cross-reference to the other topic is misspelled Configur.htm. It names the variable General.ProductVersion, holds a paragraph conditioned Default.PrintOnly, and carries an index keyword marker. It marks one word as the glossary term Glossary.Term0, and sets the meta tag Status to Reviewed. Configure.htm has an empty <title>, as Flare writes it, and no description. The variable set defines ProductVersion as 4.2. The HTML5 target excludes PrintOnly content and sets ProductVersion to 4.3. The Print target includes only PrintOnly content. The glossary defines widget and node version, plus one entry conditioned PrintOnly. The meta tag set lists Draft, Reviewed and Published as the values of Status.

  • Topics and snippets are XHTML. A page is XHTML when it opens with an XML declaration, or when its <html> element declares a namespace. Every Flare topic and snippet does both, so there is no flag and no config key.
  • A self-closed element holds nothing. In <p>Version <MadCap:variable name="General.ProductVersion" /> needs Node 20.</p>, the text after the variable stays in the paragraph, beside the variable.
  • The title is the one Flare shows. Flare leaves <title> empty, or leaves it out, and shows the first heading instead. manni reads an empty or missing <title> as the first h1 to h6, or as the file name when the topic has no heading.
  • Cross-references and snippet references are links. href on <MadCap:xref> is one, and so is src on <MadCap:snippetBlock> and <MadCap:snippetText>. A broken one is a broken link. A target that names a variable, such as href="[%=Links.Video%]", resolves through the variable set. One that cannot resolve is skipped.

The formats reference has the full read rules, and How graph works lists the elements each format’s links come from.

Put one manni.config.yaml at the project root, beside Widget.flprj.

manni.config.yaml
collections:
- name: help
paths:
- "Content/**/*.htm"
- "Content/Resources/Snippets/*.flsnp"
- "Project/TOCs/*.fltoc"
- "Project/Glossaries/*.flglo"
meta:
overrides:
- files: "Content/**/*.htm"
defaults: false
schemas:
- manni:core:1.0.0
- ./Project/Advanced/Topics.flmeta
- files: "Content/Resources/Snippets/*.flsnp"
defaults: false
schemas: [./schemas/snippet.schema.json]
graph:
out: graph/graph.ttl
tools:
flare:
target: HTML5

The help collection names the topics, the snippets, the TOC and the glossary. Each topic is held to manni:core:1.0.0, which requires a title and a description. It is also held to the project’s meta tag set, as a later section shows. A snippet is a fragment of a topic, so its schema is an empty object that holds it to no page contract. graph.out is where manni graph build writes. tools.flare.target names the target each topic is read as, as the next sections show.

defaults: false keeps the built-in default schemas off these files. The configuration reference has every key.

Terminal window
$ manni meta validate
Using manni.config.yaml (.)
✓ Content/Resources/Snippets/BeforeYouStart.flsnp
✗ Content/Topics/Configure.htm
(root) must have required property 'description' (line 1) [manni:core:1.0.0]
manni:core:1.0.0 description: One full non-empty sentence saying what the page is for.
Guide: https://hawkeyexl.github.io/manni/meta/reference/schemas/manni-core/
✓ Content/Topics/Install.htm
3 files checked, 2 passed, 1 failed, 1 error
# exit 1

Configure.htm fails on description alone. Its <title> is empty, so its title comes from its first heading, as Flare shows it.

Terminal window
$ manni meta get title Content/Topics/Configure.htm
Using manni.config.yaml (.)
Content/Topics/Configure.htm: title=Configuring the widget (asserted)

The snippet passes. .flsnp is one of the HTML extensions, so a directory walk collects snippets beside the topics.

Fill what is missing, and keep the topic openable

Section titled “Fill what is missing, and keep the topic openable”

manni meta fill proposes the missing description with a model and writes it into the topic.

Terminal window
$ manni meta fill Content/Topics/Configure.htm

The write adds one line just inside <head>. The value is the model’s.

<head>
<meta name="description" content="How to set the widget options." />
<title></title>

Each write into an XHTML page follows the same rules, so Flare opens the topic as before.

  • An inserted tag closes itself. A new <meta> ends in />.

  • A self-closed head opens. In BeforeYouStart.flsnp, <head /> becomes <head> and </head> around the new tags.

    <head />
    <head>
    <meta name="description" content="What to have in place before you install." />
    </head>
  • A page with no head gains one. Most Flare snippets have no <head>. A write adds one just inside <html>.

  • A write to title fills the <title> element, or adds one.

  • A write never leaves malformed XML. When the page parsed as XML before the write, the result must parse too. Otherwise the write is refused with exit 2, and the file is untouched.

Refusing to write "<key>": the result would not be well-formed XML (<parser message>). <file> is unchanged.

The rules hold for every command that writes a page. That covers meta fill, meta derive, meta relocate, graph fill and key rotate. A plain HTML page keeps HTML’s own spelling, so a new tag there ends in >.

Terminal window
$ manni graph build
Wrote …/graph/graph.ttl (4 docs, … triples, 4 concepts (2 from the termbase))
$ manni graph stats --check
…
Broken internal links (1):
Content/Topics/Install.htm -> Configur.htm
…
# exit 1

The four documents are the two topics, the snippet and the TOC. graph does not count the glossary as a document. Two concepts are the topic’s index keywords, and two are the glossary’s terms. Install.htm cross-references Configur.htm, which does not exist. stats --check exits 1 on a broken internal link. Fix the href, build again, and it exits 0.

A missing snippet is found the same way. graph counts .flsnp, .fltoc and .flbrs among the extensions that name a document. So a link to a missing one is broken, rather than an asset.

A snippet reference is a dcterms:references edge, as a link is. Ask the graph which topics point at the snippet before you change it.

Terminal window
$ manni graph query --predicate dcterms:references --object urn:manni:graph:doc/Content/Resources/Snippets/BeforeYouStart.flsnp
<urn:manni:graph:doc/Content/Topics/Install.htm> dcterms:references <urn:manni:graph:doc/Content/Resources/Snippets/BeforeYouStart.flsnp>
1 match(es)

manni graph traverse <snippet> --reverse walks the same edges inbound. The explore guide covers both.

Flare builds a topic for one target at a time. The target decides which conditioned content is in the output, and it can override a variable’s value. manni reads each topic for one target per run.

  • The project is found. A topic belongs to the project in the nearest folder above it that holds a .flprj. Two .flprj files in one folder stop the run with exit 2.
  • tools.flare.target names the target. Flare keeps each author’s primary target outside Git, so the config names it.
  • A project with one target uses it. With no name and one target, that target applies.
  • Otherwise no target applies. Every condition is included, and every variable takes its default. The run says so once, on stderr.
manni: Widget.flprj has 2 targets and tools.flare.target names none, so every condition is included and every variable takes its default.

A name the project does not define stops the run with exit 2, and the message lists the targets there are.

manni: tools.flare.target "PDF" names no target in Widget.flprj. Its targets are HTML5 and Print.

A topic with no .flprj above it still reads, with its variables and conditions left as written. The run prints one notice that names the file.

tools.flare is one of the outside tools under tools:. The configuration reference lists its keys, and tools.flare.home names the folder that holds madbuild.exe.

manni lint structure checks a topic’s sections against a template. Pass --explain to see the target the topic resolves against, and how its headings line up.

Terminal window
$ manni lint structure Content/Topics/Install.htm -t tgdp:how-to:1.6 --explain
Using manni.config.yaml (.)
▸ Content/Topics/Install.htm
· flare Widget.flprj, target HTML5 (tools.flare.target)
→ cli tgdp:how-to:1.6 --template
alignment
(page) ← "Installing the widget"
overview ← (missing)
before-you-start ← "Before you start"
task ← "Steps"
sub-task ← (none)
see-also ← (missing)
1 file, 1 routed, 0 unrouted

The flare line names the project and the target, then why that target applies. It reads (the only target) for a project with one target, and Widget.flprj, no target when none applies. The Before you start heading lives in the snippet, and it fills the template’s before-you-start section.

lint and graph read the topic as the target builds it.

  • Snippets expand in place. A snippet reference becomes the snippet’s content, nested snippets included. A condition expression on the reference, or on the topic’s root, applies inside the snippet. So do the reference’s snippet variables.
  • Variables resolve. A snippet’s variable override wins over the target’s, and the target’s wins over the variable set. System.Title and System.SourceFile resolve too. A date variable keeps its reference.
  • Excluded content leaves the tree. The PrintOnly paragraph is not in the HTML5 build, so no rule counts it.
  • Links stay what each file says. A cross-reference in print-only content is still checked, under every condition.
  • A finding keeps a line in its own file. A block from a snippet reports at the line of the snippet reference. A snippet checked directly reports at its own lines, so a fault in a snippet is fixed once.

A topic whose root carries a condition the target excludes is not linted. --explain ends its block with the reason.

skipped: target HTML5 excludes the page by its conditions

A variable the project does not define is kept as text, and a snippet that includes itself is expanded once. Each warns once on stderr, and the exit code does not change.

manni: <file>: variable <Set.Name> is not defined in <flprj>. The reference is kept as text.
manni: <file>: snippet <snippet> includes itself through <chain>. It is expanded once.

The graph’s passages read the same way. manni graph export chunks writes each passage’s text as a reader of the HTML5 output sees it. Its line span stays the topic’s own source lines.

Terminal window
$ manni graph export chunks -o .manni/chunks
$ grep -c "Version 4.3 needs Node 20." .manni/chunks/chunks.en-US.jsonl
2
$ grep -c "MadCap:variable" .manni/chunks/chunks.en-US.jsonl
0

The target’s 4.3 replaces the set’s 4.2. The two rows are the topic’s whole body and its first section.

A topic takes its language from its own lang first, then a route’s language, then the project’s xml:lang. The fixture’s project says en-us, so every document reads as en-US.

A topic’s <MadCap:keyword> and <MadCap:concept> markers read as the page field keywords.

Terminal window
$ manni meta get keywords Content/Topics/Install.htm
Using manni.config.yaml (.)
Content/Topics/Install.htm: keywords=["install","setup"] (asserted)
  • A term splits on ;. term="install;setup" is two keywords.
  • A subentry stays whole. A:B is one keyword, and \: is a literal colon.
  • Each keyword counts once, in document order.
  • A keywords meta tag wins. A <meta name="keywords"> in the page wins over the markers.

graph files the topic under each keyword as a dcterms:subject, and term’s keyword-candidate rule reads them. A write to keywords read from markers is refused with exit 2, because the markers belong to Flare.

manni: Refusing to write "keywords": Content/Topics/Install.htm reads them from its MadCap:keyword and MadCap:concept markers. Edit the markers in Flare.

A TOC (.fltoc) or browse sequence (.flbrs) reads as a map in lint and graph, the way a DITA map does.

  • A titled entry is a section. So is a book entry with no Link, such as Get started.
  • A linked title is transparent. An entry titled [%=System.LinkedTitle%] shows its topic’s title in Flare. manni leaves it out of the structure and keeps its children.
  • Each Link is a link. A link to /Content/Topics/Install.htm resolves against the folder holding the .flprj, with no routes: entry.
  • An excluded entry leaves the structure. Its link stays, so a missing topic is still a broken link.
Terminal window
$ manni graph query --subject urn:manni:graph:doc/Project/TOCs/Main.fltoc --predicate dcterms:references
<urn:manni:graph:doc/Project/TOCs/Main.fltoc> dcterms:references <urn:manni:graph:doc/Content/Topics/Configure.htm>
<urn:manni:graph:doc/Project/TOCs/Main.fltoc> dcterms:references <urn:manni:graph:doc/Content/Topics/Install.htm>
2 match(es)

A TOC carries no metadata. manni meta does not read it, so meta validate passes over the TOC in the collection.

A Flare glossary (.flglo) reads as a termbase in manni term. The help collection names it, so term list and term check read its entries with no other setup.

Terminal window
$ manni term list
Glossary.Term0 widget gadget
Glossary.Term1 node version
2 terms

The glossary holds three entries. The HTML5 target excludes the PrintOnly entry, so term reads two.

  • Each entry is one term. Its first Term is the label, and each later one is an alt-label. gadget is an alt-label of widget.
  • The id is the entry’s glossTerm, as written, such as Glossary.Term0. That is the id a topic’s marker names. An entry with no glossTerm takes the slug of its label.
  • The Definition text is the definition. A definition that links to a topic reads as none, so the missing-definition rule reports it.
  • The target filters entries. An entry whose conditions the target excludes is not read. The conditions on <CatapultGlossary> itself filter nothing, as in Flare.
Terminal window
$ manni term check
Project/Glossaries/Glossary.flglo:15
notice manni:term/unused-term no page's concepts: names this term.
Name it in the concepts: of a page that uses it.
manni:term unused-term: No page's concepts: names the entry, or an entry in another language with its id.
Guide: https://hawkeyexl.github.io/manni/term/reference/rules/#unused-term
1 notice in 2 terms

No topic names node version, so unused-term reports it. A notice never fails the run, and term check exits 0. manni reads the glossary and never writes it. manni term write renders it into another format with -f and -o.

A topic’s <MadCap:glossaryTerm> marker names a glossary entry by its glossTerm id. meta reads each one as an entry of the page field concepts.

Terminal window
$ manni meta get concepts Content/Topics/Install.htm
Using manni.config.yaml (.)
Content/Topics/Install.htm: concepts=["Glossary.Term0"] (asserted)
  • Each id is the glossTerm value, as written. The text the marker wraps is the topic’s prose, and it stays in the passage.
  • Each id counts once, in document order.
  • A concepts meta tag wins. A <meta name="concepts"> in the page wins over the markers.

term checks each concepts entry against the termbase, and graph files the topic under it as a dcterms:subject. A marker that names no entry is an undefined-term error, at the marker’s line. Here the marker names Glossary.Term9, which the glossary does not define.

Terminal window
$ manni term check
Content/Topics/Install.htm:10
error manni:term/undefined-term concepts: "Glossary.Term9" names no entry.
Correct the value, or run manni term fill to add the term.
manni:term undefined-term: A page's concepts: names no entry, by label, id or alt-label.
Guide: https://hawkeyexl.github.io/manni/term/reference/rules/#undefined-term
…
1 error, 2 notices in 2 terms
# exit 1

A write to concepts read from markers is refused with exit 2, because the markers belong to Flare.

manni: Refusing to write "concepts": Content/Topics/Install.htm reads them from its MadCap:glossaryTerm markers. Edit the markers in Flare.

A Flare meta tag set (.flmeta) names the meta tags a topic carries. A path to one is a schema ref in meta.schemas, meta.overrides[].schemas, -s and a page’s $schema. The topics’ override names the project’s set beside manni:core:1.0.0.

manni.config.yaml
meta:
overrides:
- files: "Content/**/*.htm"
defaults: false
schemas:
- manni:core:1.0.0
- ./Project/Advanced/Topics.flmeta

meta reads the set into a JSON Schema.

  • Each <meta> is a string property. Its desc is the property’s description.
  • An Enum tag holds its list. It matches one or more of its enum values, joined by commas, as Flare writes a list of several. An empty value is Flare’s “not set”, and it passes.
  • Nothing is required, because Flare requires nothing.

A contributor sets Status to Reviewd, and meta validate fails the topic.

Terminal window
$ manni meta validate
Using manni.config.yaml (.)
✓ Content/Resources/Snippets/BeforeYouStart.flsnp
✗ Content/Topics/Configure.htm
…
✗ Content/Topics/Install.htm
/Status must match pattern "^((Draft|Reviewed|Published)(,(Draft|Reviewed|Published))*)?$" (line 6) [./Project/Advanced/Topics.flmeta]
./Project/Advanced/Topics.flmeta Status: Where the topic is in review.
3 files checked, 1 passed, 2 failed, 2 errors
# exit 1

The second line under the finding is the tag’s desc. A file that is not a meta tag set stops the run with exit 2. manni reads a meta tag set and never writes one, so a meta query schema change that would write it exits 2 too.

manni: <ref> is not a Flare meta tag set: its root element is <name>.
manni: <ref> is a Flare meta tag set, which manni reads and never writes.

manni docevals grades a Flare project’s topics as it grades any page. A topic’s metadata is HTML, with no room for a YAML block, so its evals live in a manifest. One {page}.meta.yaml beside each topic holds them.

manni.config.yaml
collections:
- name: help
paths:
- "Content/**/*.htm"
- "Content/Resources/Snippets/*.flsnp"
- "Project/TOCs/*.fltoc"
- "Project/Glossaries/*.flglo"
externalMetadata:
- file: "{page}.meta.yaml"
keys: [evals, eval-suite, eval-skip, meta-provenance]
Content/Topics/Install.meta.yaml
Content/Topics/Install.htm:
evals:
- Every step names the command it runs.

A deterministic grader reads the topic’s source, so its line numbers are the file’s. The judge reads the topic as text, with its variables, snippets and conditions resolved against the target. docevals fill writes its proposals into the manifest. The frontmatter reference has the rules.

manni site builds the project on a machine with Flare installed. A folder holding a .flprj is a Flare site, so there is no config key to set.

  • site build runs madbuild. It builds the target the run picks. A project with several targets and no tools.flare.target stops with exit 2, because a build needs one.
  • madbuild is found. manni looks on PATH, then in the folder tools.flare.home names, then in the newest MadCap Flare install under Program Files.
  • site preview serves the last build. madbuild writes it to Output/<USERNAME>/<target>, where USERNAME is the account that ran it. manni serves that folder on port 4321, starting at Default.htm. It does not build first, because a Flare build is slow.
Terminal window
$ manni site build
manni: Flare in ./. Running madbuild -project Widget.flprj -target HTML5
$ manni site preview
http://127.0.0.1:4321/
$ manni a11y check http://127.0.0.1:4321/

manni a11y check checks the served output, as it checks any site. With a collection url: and graph.export.llms set, manni graph export llms links each topic as Flare publishes it, ending in .htm.

Flare has no development server, and site annotate does not read Flare output. Each of these exits 2. So does a build where madbuild is not found, and a preview before any build.

manni: Flare has no development server. Run manni site build, then manni site preview.
manni: manni site annotate does not read Flare output.
manni: madbuild was not found. Install MadCap Flare, or set tools.flare.home to the folder that holds madbuild.exe.
manni: No Flare output at Output/maya/HTML5. Run manni site build first.

A CI runner has no Flare install, so it runs every check but the build. The site reference has the Flare row of the framework table.

manni check runs every domain the config sets up. Here that is meta validate, term check over the glossary, and graph check against the bundled shapes.

Terminal window
$ manni check
meta validate
✗ Content/Topics/Configure.htm
(root) must have required property 'description' (line 1) [manni:core:1.0.0]
…
3 files checked, 2 passed, 1 failed, 1 error
term check
Project/Glossaries/Glossary.flglo:15
notice manni:term/unused-term no page's concepts: names this term.
…
1 notice in 2 terms
graph check
0 errors, 0 warnings, 0 notices
skipped cite check no page carries citations
skipped lint check no lint: section
skipped docevals run no page resolves any evals
skipped strategy check no strategy records in any collection
7 checks over 3 files: 1 failed, 4 skipped
# exit 1

The runner needs Node and nothing from MadCap, so an ubuntu-latest runner works. graph check judges the shapes and not the link report, so graph stats --check stays a step of its own.

.github/workflows/help.yml
jobs:
manni:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 24
- name: Run every manni check
run: npx -y @hawkeyexl/manni check -f github < /dev/null
- name: Find broken links and includes
run: |
npx -y @hawkeyexl/manni graph build
npx -y @hawkeyexl/manni graph stats --check

Gate every check a repo has set up explains the skipped lines and the exit codes. The graph CI page covers the graph steps.