Skip to content

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 one sidecar manifest per collection, 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.

manni.config.yaml
docs-citations.yaml the site collection's entries
guides-citations.yaml the guides collection's entries
docs/ limits.md, retries.md, moved.md, moved-two.md, own.md, entry-invalid.md
guides/guide.md
lib/ limits.ts, moved.ts

Two 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.

  1. 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 the citations key:

    manni.config.yaml
    collections:
    - name: site
    paths: ["docs/**/*.md"]
    externalMetadata:
    - file: ./docs-citations.yaml
    keys: [citations]

    file is relative to the config file. keys is the list of page keys this manifest owns, and citations is 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 owns citations.

  2. Move each page’s entries into it. The manifest is a mapping from page path, relative to the manifest, to that page’s keys. 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 this file.
    docs/limits.md:
    citations:
    - id: fetch-timeout
    claim:
    lines: 3
    integrity: sha256-921b21cccab21a4577f224ec4171aa56a3414bb3a5a4704ab8b6f314c46aa094
    source:
    file: lib/limits.ts
    lines: 2
    integrity: sha256-78af1d3321f9cbb177a7e4c958e39be56fd14cb93c1e441778bc4232e0fe4b1f

    claim.lines still 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
    ---
    # Limits
    The fetch timeout is 10 seconds.
  3. Confirm the page came out clean. meta get reads 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,citations
    docs/limits.md: title=Limits (asserted)
    docs/limits.md: citations=(unset)
    Terminal window
    manni meta get docs/limits.md --fields title,citations
    docs/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.

  4. Check exactly as before. A fourth column appears on the row, naming the manifest and the line the entry starts on:

    Terminal window
    manni cite check docs/limits.md
    ✓ docs/limits.md
    ✓ fetch-timeout :6 current lib/limits.ts:2 current docs-citations.yaml:5
    1 file checked, 1 passed, 0 failed, 0 findings

    The claim end reads :6, a line of the page, and the manifest column reads docs-citations.yaml:5, a line of the manifest. A frontmatter entry leaves that column empty.

    Which manifest owns a page is decided by every collection in the config, whatever --collection or the positional paths select. So a page checked by path still finds its sidecar. --no-config reads frontmatter only, and then the page looks like it has no citations at all.

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)
1 file checked, 0 passed, 1 failed, 1 finding

The 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}$"

SARIF drops a location outside the repository, so a manifest kept outside it is reported on the page instead.

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)
1 file checked, 0 passed, 1 failed, 1 finding

meta validate reports the same page as external:owned. So a half-finished migration cannot pass quietly, whichever gate runs first.

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:

Terminal window
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: 9265563d8ea84a13fea0001b7e531c119f47d070
docs/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:

Terminal window
manni cite update --dry-run docs/moved.md docs/moved-two.md
docs/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-78af1d3321f9cbb177a7e4c958e39be56fd14cb93c1e441778bc4232e0fe4b1f
2 citations rewritten in 2 files, 0 skipped

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:

manni.config.yaml
collections:
- name: guides
paths: ["guides/**/*.md"]
externalMetadata:
- file: ./guides-citations.yaml
keys: [citations]
join: slug
guides-citations.yaml
guide-one:
citations:
- id: fetch-timeout
claim:
lines: 3
integrity: sha256-921b21cccab21a4577f224ec4171aa56a3414bb3a5a4704ab8b6f314c46aa094
source:
file: lib/limits.ts
lines: 2
integrity: sha256-78af1d3321f9cbb177a7e4c958e39be56fd14cb93c1e441778bc4232e0fe4b1f

The page that carries slug: guide-one in its frontmatter owns that entry, wherever the file moves to:

✓ guides/guide.md
✓ fetch-timeout :7 current lib/limits.ts:2 current guides-citations.yaml:4
1 file checked, 1 passed, 0 failed, 0 findings

Two pages carrying one join: value is refused, because a write would land on whichever page the run reached last.

Each is an operational error, exit 2, raised before any page is read.

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.