Keep citations out of the page
A citation entry is eleven lines of YAML, and three of them are hashes. On a page that carries four, the frontmatter a writer opens is mostly pins. This page moves those entries out, into a sidecar manifest, and leaves the pages as prose with a title.
Nothing about the check changes. The same manni cite check covers the same
pages, add and update write the manifest instead of the page, and a
finding lands where a reviewer can act on it. What changes is where the YAML
lives.
Every transcript below is real output from the built tool.
Keep each page’s citations beside it
Section titled “Keep each page’s citations beside it”externalMetadata[].file may hold one placeholder, {page}. It stands for
the page’s path relative to the config file, without its extension. So
"{page}.citations.yaml" gives every page a manifest of its own, beside it:
collections: - name: site paths: ["docs/**/*.md"] externalMetadata: - file: "{page}.citations.yaml" keys: [citations]docs/limits.mddocs/limits.citations.yaml limits.md's entries, and no other page'sdocs/retries.md nothing cited yet, so no manifestlib/limits.tsThis is the recommended layout. Two branches that edit two pages touch two
manifests, so they merge without a conflict. A reviewer finds a page’s pins in
the file beside it, not in one file that every page changes. Quote the value,
because a bare { opens a YAML mapping.
A page with no manifest yet has no citations, and the check passes it. Each page’s line names its own manifest:
manni cite check✓ docs/limits.md docs/limits.citations.yaml ✓ fetch-timeout :6 current lib/limits.ts:2 current✓ docs/retries.md
2 files checked, 2 passed, 0 failed, 0 findingsThe first add for a page creates its manifest:
manni cite add docs/retries.md:6 lib/limits.ts:3 --id retriesdocs/retries.md: added retries to docs/retries.citations.yaml:3 (claim at line 6, sha256-d33051d7…; source lib/limits.ts:3 "export const RETRIES = 3;", sha256-e9f5bdf9…, 6cace3a)The configuration
reference has the
full rule for {page} and every refusal.
A file without the placeholder names one manifest for the whole collection.
The walkthrough below uses that shape, docs-citations.yaml, so every entry
sits in one file you can read at once. Each step reads the same with
{page}. Only joining by a field
needs the shared shape, because a per-page manifest is found by the page’s
path.
The example repository
Section titled “The example repository”manni.config.yamldocs-citations.yaml the site collection's entriesguides-citations.yaml the guides collection's entriesdocs/ limits.md, retries.md, moved.md, moved-two.md, own.md, entry-invalid.mdguides/guide.mdlib/ limits.ts, moved.tsTwo collections, site and guides, and one manifest each. The pages under
docs/ carry a title and prose. Every citation they have is in
docs-citations.yaml.
-
Declare the manifest on the collection. There is no
cite:key for this. The mechanism is the metadata tool’s external metadata, and the collection declares a manifest that owns thecitationskey:manni.config.yaml collections:- name: sitepaths: ["docs/**/*.md"]externalMetadata:- file: ./docs-citations.yamlkeys: [citations]fileis relative to the config file.keysis the list of page keys this manifest owns, andcitationsis the one cite reads and writes. A manifest may own other keys beside it, and a collection may declare more than one manifest, as long as only one of them ownscitations. -
Move each page’s entries into it. The manifest is a mapping from page path, relative to the config file, to that page’s keys. That holds wherever the manifest sits. Cut the
citations:block out of the page’s frontmatter and paste it under the page’s path:docs-citations.yaml # The site collection's citations, keyed by page path relative to the config file.docs/limits.md:citations:- id: fetch-timeoutclaim:lines: 3integrity: sha256-921b21cccab21a4577f224ec4171aa56a3414bb3a5a4704ab8b6f314c46aa094source:file: lib/limits.tslines: 2integrity: sha256-78af1d3321f9cbb177a7e4c958e39be56fd14cb93c1e441778bc4232e0fe4b1fclaim.linesstill counts the page body, not the manifest. The page decides where its body starts, whoever keeps the entry. So moving an entry into a manifest never changes a single number in it.docs/limits.md ---title: Limits---# LimitsThe fetch timeout is 10 seconds. -
Confirm the page came out clean.
meta getreads the page alone under--no-config, and reads the merged view when the config governs the run. The pair is the proof:Terminal window manni meta get docs/limits.md --no-config --fields title,citationsdocs/limits.md: title=Limits (asserted)docs/limits.md: citations=(unset)Terminal window manni meta get docs/limits.md --fields title,citationsdocs/limits.md: title=Limits (asserted)docs/limits.md: citations=[{"id":"fetch-timeout","claim":{"lines":3,…}] (asserted)The page’s own bytes carry no citation metadata. Every tool that reads the config still sees the key.
-
Check exactly as before. The manifest’s path now follows the page, on the page’s own line:
Terminal window manni cite check docs/limits.md✓ docs/limits.md docs-citations.yaml✓ fetch-timeout :6 current lib/limits.ts:2 current1 file checked, 1 passed, 0 failed, 0 findingsThe claim end reads
:6, a line of the page. The manifest is named once, because it keeps every entry the page has. A page’s entries may come from more than one place. Then each manifest row ends with the manifest and the entry’s line instead, and a frontmatter row leaves that column empty.Which manifest owns a page is decided by every collection in the config, whatever
--collectionor the positional paths select. So a page checked by path still finds its sidecar.--no-configreads frontmatter only, and then the page looks like it has no citations at all.
Where a finding lands
Section titled “Where a finding lands”A claim finding or a marker finding sits on the page line, where a reviewer reads the sentence. A finding about the entry itself sits on the manifest, on the entry’s own first line:
✗ docs/entry-invalid.md ✗ bad-pin /source/integrity must match pattern "^(?:sha256|hmac-sha256)-[0-9a-f]{64}$" (docs-citations.yaml:42) manni:cite entry-invalid: An entry is malformed, or an id is repeated. Guide: https://hawkeyexl.github.io/manni/cite/fix/#entry-invalid
1 file checked, 0 passed, 1 failed, 1 findingThe file that failed is still the page, because the page is what stopped being
checkable. The location is the manifest, because that is the file to open.
-f github annotates it there, so the comment appears on the manifest’s diff
in the pull request:
::error file=docs-citations.yaml,line=42,title=manni%3Acite/entry-invalid::bad-pin (lib/limits.ts): /source/integrity must match pattern "^(?:sha256|hmac-sha256)-[0-9a-f]{64}$"%0Amanni:cite entry-invalid: An entry is malformed, or an id is repeated.%0AGuide: https://hawkeyexl.github.io/manni/cite/fix/#entry-invalidSARIF drops a location outside the repository, so a manifest kept outside it is reported on the page instead.
Half-moved pages are caught
Section titled “Half-moved pages are caught”If you declare the manifest and forget to delete a page’s own citations:,
the page has two owners for one key. That is an error under both tools, and
the message names the manifest and the collection:
✗ docs/own.md ✓ on-the-page lib/limits.ts current ✗ page "citations" is owned by manifest docs-citations.yaml (collection site); remove it from the document (line 3) manni:cite entry-invalid: An entry is malformed, or an id is repeated. Guide: https://hawkeyexl.github.io/manni/cite/fix/#entry-invalid
1 file checked, 0 passed, 1 failed, 1 findingmeta validate reports the same page as external:owned. So a half-finished
migration cannot pass quietly, whichever gate runs first.
Writing the manifest
Section titled “Writing the manifest”add, update and manni key rotate edit the manifest in place. They splice
only that page’s citations value, so comments, key order and every other
byte survive, and they read the file back to confirm it.
add needs no flag. Where the entry goes follows the config:
manni cite add docs/retries.md:6 lib/limits.ts:3 --id retries --dry-run--- docs-citations.yaml+++ docs-citations.yaml@@ -55,1 +55,13 @@ integrity: sha256-aebba92fe4cddf100cc781281d1f24ad7c234b6189413e2130d5fe71ed86e023++docs/retries.md:+ citations:+ - id: retries+ claim:+ lines: 3+ integrity: sha256-d33051d7acffdfa8567c4615e8daf62d8c77672a15e9fcdab8cb5077f867abb6+ source:+ file: lib/limits.ts+ lines: 3+ integrity: sha256-e9f5bdf94a12c610b54573d2b66347592887805e59c69b64803a8c0d30edaea3+ commit-sha: 9265563d8ea84a13fea0001b7e531c119f47d070docs/retries.md: added retries to docs-citations.yaml:59 (claim at line 6, sha256-d33051d7…; source lib/limits.ts:3, sha256-e9f5bdf9…, 9265563)The report says the claim is at line 6, which is the line you typed. On a
frontmatter page the same add would have pushed the sentence ten lines down
and reported line 16. The page was not touched here, so nothing moved.
update writes each manifest once per run, however many entries it
repaired:
manni cite update --dry-run docs/moved.md docs/moved-two.mddocs/moved-two.md: fetch-timeout source lib/moved.ts:2 -> lib/moved.ts:4 (moved)docs/moved.md: fetch-timeout source lib/moved.ts:2 -> lib/moved.ts:4 (moved)--- docs-citations.yaml+++ docs-citations.yaml@@ -22,3 +22,3 @@ file: lib/moved.ts- lines: 2+ lines: 4 integrity: sha256-78af1d3321f9cbb177a7e4c958e39be56fd14cb93c1e441778bc4232e0fe4b1f@@ -35,3 +35,3 @@ file: lib/moved.ts- lines: 2+ lines: 4 integrity: sha256-78af1d3321f9cbb177a7e4c958e39be56fd14cb93c1e441778bc4232e0fe4b1f2 citations rewritten in 2 files, 0 skippedKeying by a page field instead of by path
Section titled “Keying by a page field instead of by path”A manifest keyed by path is orphaned by a git mv. A join: key says to
match on a page field instead, and the entry then travels with the page:
collections: - name: guides paths: ["guides/**/*.md"] externalMetadata: - file: ./guides-citations.yaml keys: [citations] join: slugguide-one: citations: - id: fetch-timeout claim: lines: 3 integrity: sha256-921b21cccab21a4577f224ec4171aa56a3414bb3a5a4704ab8b6f314c46aa094 source: file: lib/limits.ts lines: 2 integrity: sha256-78af1d3321f9cbb177a7e4c958e39be56fd14cb93c1e441778bc4232e0fe4b1fThe page that carries slug: guide-one in its frontmatter owns that entry,
wherever the file moves to:
✓ guides/guide.md guides-citations.yaml ✓ fetch-timeout :7 current lib/limits.ts:2 current
1 file checked, 1 passed, 0 failed, 0 findingsTwo pages carrying one join: value is refused, because a write would land on
whichever page the run reached last.
The three refusals
Section titled “The three refusals”Each is an operational error, exit 2. The first is raised when the config is
read, before any page is. The second is raised on a page both collections
match. The third is raised after add reads the page from stdin.
| You wrote | You hear |
|---|---|
A URL in file: on the manifest that owns citations. | manni.config.yaml: collection site: citations cannot come from a URL manifest, because cite writes them. |
Two collections matching one page, both with a manifest owning citations. | docs/page.md is in collections site and api, and both keep citations in a manifest. |
- on add, to read the page from stdin. | A page read from stdin has no path, and its citations live in docs-citations.yaml, which is keyed by path. |
The first two are about writing. A manifest cite writes has to be a file it can open. A page whose entries have two owners has nothing to say which manifest wins. A URL manifest would also put a private source path into public CI output, which the output rule exists to prevent.
The third is about identity. A page read from stdin has no path and no recorded fields, so there is no key to look the entry up under. Name the file instead of piping it.