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.flprjmanni.config.yamlschemas/snippet.schema.jsonContent/Topics/Install.htmContent/Topics/Configure.htmContent/Resources/Snippets/BeforeYouStart.flsnpProject/TOCs/Main.fltocProject/VariableSets/General.flvarProject/ConditionTagSets/Default.flctsProject/Targets/HTML5.fltarProject/Targets/Print.fltarProject/Glossaries/Glossary.flgloProject/Advanced/Topics.flmetaInstall.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.
What manni reads in a Flare project
Section titled “What manni reads in a Flare project”- 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 firsth1toh6, or as the file name when the topic has no heading. - Cross-references and snippet references are links.
hrefon<MadCap:xref>is one, and so issrcon<MadCap:snippetBlock>and<MadCap:snippetText>. A broken one is a broken link. A target that names a variable, such ashref="[%=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.
Point manni at the project
Section titled “Point manni at the project”Put one manni.config.yaml at the project root, beside Widget.flprj.
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.ttltools: flare: target: HTML5The 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.
Validate the topics
Section titled “Validate the topics”$ manni meta validateUsing 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 1Configure.htm fails on description alone. Its <title> is empty, so its
title comes from its first heading, as Flare shows it.
$ manni meta get title Content/Topics/Configure.htmUsing 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.
$ manni meta fill Content/Topics/Configure.htmThe 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
titlefills 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 >.
Find the broken cross-reference
Section titled “Find the broken cross-reference”$ manni graph buildWrote …/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 1The 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.
See which topics include a snippet
Section titled “See which topics include a snippet”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.
$ 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.
Pick the target
Section titled “Pick the target”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.flprjfiles in one folder stop the run with exit2. tools.flare.targetnames 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.
Read a topic as its target builds it
Section titled “Read a topic as its target builds it”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.
$ manni lint structure Content/Topics/Install.htm -t tgdp:how-to:1.6 --explainUsing 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 unroutedThe 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.TitleandSystem.SourceFileresolve too. A date variable keeps its reference. - Excluded content leaves the tree. The
PrintOnlyparagraph is not in theHTML5build, 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 conditionsA 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.
$ manni graph export chunks -o .manni/chunks$ grep -c "Version 4.3 needs Node 20." .manni/chunks/chunks.en-US.jsonl2$ grep -c "MadCap:variable" .manni/chunks/chunks.en-US.jsonl0The 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.
Index keywords are keywords
Section titled “Index keywords are keywords”A topic’s <MadCap:keyword> and <MadCap:concept> markers read as the page
field keywords.
$ manni meta get keywords Content/Topics/Install.htmUsing manni.config.yaml (.)Content/Topics/Install.htm: keywords=["install","setup"] (asserted)- A
termsplits on;.term="install;setup"is two keywords. - A subentry stays whole.
A:Bis 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.TOCs are maps
Section titled “TOCs are maps”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 asGet 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
Linkis a link. A link to/Content/Topics/Install.htmresolves against the folder holding the.flprj, with noroutes:entry. - An excluded entry leaves the structure. Its link stays, so a missing topic is still a broken link.
$ 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.
The glossary is a termbase
Section titled “The glossary is a termbase”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.
$ manni term listGlossary.Term0 widget gadgetGlossary.Term1 node version2 termsThe glossary holds three entries. The HTML5 target excludes the
PrintOnly entry, so term reads two.
- Each entry is one term. Its first
Termis the label, and each later one is an alt-label.gadgetis an alt-label ofwidget. - The id is the entry’s
glossTerm, as written, such asGlossary.Term0. That is the id a topic’s marker names. An entry with noglossTermtakes the slug of its label. - The
Definitiontext is the definition. A definition that links to a topic reads as none, so themissing-definitionrule reports it. - The target filters entries. An entry whose
conditionsthe target excludes is not read. The conditions on<CatapultGlossary>itself filter nothing, as in Flare.
$ manni term checkProject/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 termsNo 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.
Glossary references are concepts
Section titled “Glossary references are concepts”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.
$ manni meta get concepts Content/Topics/Install.htmUsing manni.config.yaml (.)Content/Topics/Install.htm: concepts=["Glossary.Term0"] (asserted)- Each id is the
glossTermvalue, 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.
$ manni term checkContent/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 1A 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 meta tag set is a schema
Section titled “A meta tag set is a schema”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.
meta: overrides: - files: "Content/**/*.htm" defaults: false schemas: - manni:core:1.0.0 - ./Project/Advanced/Topics.flmetameta reads the set into a JSON Schema.
- Each
<meta>is a string property. Itsdescis the property’s description. - An
Enumtag holds its list. It matches one or more of itsenumvalues, 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.
$ manni meta validateUsing 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 1The 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.Grade the topics with docevals
Section titled “Grade the topics with docevals”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.
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.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.
Build and preview the target
Section titled “Build and preview the target”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 buildruns madbuild. It builds the target the run picks. A project with several targets and notools.flare.targetstops with exit2, because a build needs one.- madbuild is found. manni looks on
PATH, then in the foldertools.flare.homenames, then in the newest MadCap Flare install underProgram Files. site previewserves the last build. madbuild writes it toOutput/<USERNAME>/<target>, whereUSERNAMEis the account that ran it. manni serves that folder on port 4321, starting atDefault.htm. It does not build first, because a Flare build is slow.
$ manni site buildmanni: Flare in ./. Running madbuild -project Widget.flprj -target HTML5$ manni site previewhttp://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.
Run it in CI
Section titled “Run it in CI”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.
$ manni checkmeta 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 checkProject/Glossaries/Glossary.flglo:15 notice manni:term/unused-term no page's concepts: names this term.…1 notice in 2 terms
graph check0 errors, 0 warnings, 0 notices
skipped cite check no page carries citationsskipped lint check no lint: sectionskipped docevals run no page resolves any evalsskipped strategy check no strategy records in any collection
7 checks over 3 files: 1 failed, 4 skipped# exit 1The 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.
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 --checkGate every check a repo has set up explains the skipped lines and the exit codes. The graph CI page covers the graph steps.