Keep metadata outside the document
External metadata is a YAML manifest that supplies a fixed set of
frontmatter keys for named documents. manni meta merges the values into each
page’s metadata before resolving its schema, so every command sees one object.
The page never holds the value. The contract on it stays with the page, in the
same schema set, checked by the same run, with the same exit code.
Three things people use it for. Each has a worked path on this page, and all three share one manifest format and one set of rules.
- Lean documents. A page carries what it is about, and nothing else. The design note it was written from, the ticket that tracks it, its owner and its review state live in the manifest. A contributor fills in fewer fields. A reader, or an agent that ingests the page, sees none of the bookkeeping. The run still sees all of it. Start at A lean docset.
- Private values for public documents. The manifest lives in a private repository. Public CI never sees it, and the private run validates the public pages against it. See Private values for public documents.
- Across repositories. One repository’s pages take keys from a manifest in another, by URL, public or private. See Across repositories.
A manifest belongs to a collection, the named document set every manni tool
reads. externalMetadata: sits inside the collection entry, beside the globs
that select the pages the manifest describes. The two are declared together, so
neither can drift from the other.
A lean docset
Section titled “A lean docset”The manifest sits beside the config, in the same repository as the pages. Nothing here is secret. The point is what the page does not have to carry.
The worked example is test/fixtures/external-metadata/, the corpus this
feature’s tests run. Four pages, one manifest, one schema. The schema’s file
name is the fixture’s, and plays no part:
manni.config.yamldocs-meta.yaml # the manifestprivate.schema.json # requires the keys the manifest suppliesdocs/auth.mddocs/billing.mddocs/new.mddocs/ops.md-
Write the manifest. One entry per page, keyed by the page’s path relative to the config file. Paths are exact. There are no globs. A page that moves often can be keyed by a frontmatter field instead; see Survive renames with a field join.
docs-meta.yaml docs/auth.md:source: internal/auth-design.mdjira: PLAT-412docs/billing.md:source: internal/billing.mdjira: PLAT-388 -
Write the schema. It requires the keys the manifest supplies, beside the keys the page carries itself. Leave
additionalPropertiesunset, or the page’s own keys fail it.private.schema.json {"type": "object","required": ["title", "jira"],"properties": {"title": { "type": "string" },"source": { "type": "string" },"jira": { "type": "string", "pattern": "^PLAT-[0-9]+$" }}} -
Declare the collection, and hang the manifest off it.
paths:selects the pages.externalMetadata.keys:names what the manifest owns. A page carrying one of those keys itself is a finding, and an entry setting any other key is an error. The override points at the collection by name, so the glob is written once.manni.config.yaml collections:- name: pagespaths: ["docs/**/*.md"]externalMetadata:- file: ./docs-meta.yamlkeys: [source, jira]meta:overrides:- collection: pagesschemas: [./private.schema.json] -
Run it. A bare
manni meta validatereads every collection, which is what makes the orphan check run.Terminal window npx -y @hawkeyexl/manni meta validate
The page itself carries its title and nothing else:
---title: Auth---
# AuthThe run sees the whole object. From the fixture directory,
manni meta get jira docs/auth.md prints docs/auth.md: jira=PLAT-412, and a
corpus check can phrase a rule over jira like
any other key. So can a query over the collection’s own
view:
SELECT _path FROM pages WHERE jira IS NULL.
What a failure looks like
Section titled “What a failure looks like”The fixture’s manifest is not the clean one above. One page has a malformed
ticket in the manifest, one has no entry, and one carries jira: in its own
frontmatter:
✓ docs/auth.md✗ docs/billing.md /jira must match pattern "^PLAT-[0-9]+$" (docs-meta.yaml:6) [./private.schema.json]✗ docs/new.md (root) must have required property 'jira' (line 1) [./private.schema.json]✗ docs/ops.md /jira "jira" is owned by manifest docs-meta.yaml (collection pages); remove it from the document (line 3) [external:owned]
4 files checked, 1 passed, 3 failed, 3 errorsEach line points at the place to fix. billing.md’s bad value is at
docs-meta.yaml:6, so the manifest is what to edit. new.md needs an entry.
ops.md needs its jira: line removed, because the manifest is the only place
that key may be set. The message names the collection whose manifest owns it.
All three are exit 1, and --format github annotates the manifest line for
the first and the page line for the other two.
A renamed page whose entry was not updated is different. On a run that reads the whole corpus it is exit 2. An entry naming a page the run did not load is a named input that is not there. The same fixture directory carries a second config whose manifest names a page that is gone:
$ manni meta validate -c manni.orphan.config.yamlmanni: Manifest docs-meta.orphan.yaml:3 names "docs/gone.md", which this run did not load. Fix the entry, or remove it.Private values for public documents
Section titled “Private values for public documents”The same manifest, moved. Your docs repository is public. Some of the metadata
its pages must carry is not, and in the public frontmatter it leaks. In a
private spreadsheet it is never validated, and the whole point of a jira:
key is that CI refuses a page without one.
The manifest goes in a private repository, with the config and the schema. The public repository never holds the value, and the private CI run validates the public pages against a schema that requires it. By the end you will have two repositories, one manifest, and a private CI job. The job fails when a public page has no ticket or a ticket is misspelled. It also fails when someone writes the ticket into the public page by hand.
The layout
Section titled “The layout”The private repository owns the config, the manifest, and the private schema. The public repository is a git submodule inside it:
private-repo/ manni.config.yaml # the collection, its manifest, and the override docs-meta.yaml # the manifest schemas/private.json # requires the private keys public/ # git submodule: the public docs repository manni.config.yaml # plain; never mentions external metadata docs/guides/auth.mdTwo sibling checkouts also run, and report worse. SARIF locates every finding relative to the nearest repository root and cannot represent a finding in another repository’s file. From a sibling layout every document finding is therefore dropped from the log. A path that climbs out of the config’s directory is also a member of no collection, so it would take no manifests at all. The submodule keeps every document under the private root. The reasons are in the reference.
Set it up
Section titled “Set it up”-
Add the public repository as a submodule. From the private repository:
Terminal window git submodule add https://github.com/your-org/public-docs public -
Write the manifest. The entries are the ones from the lean docset, keyed through the submodule:
docs-meta.yaml public/docs/guides/auth.md:source: internal/auth-design.mdjira: PLAT-412public/docs/guides/billing.md:source: internal/billing.mdjira: PLAT-388 -
Write the private schema. It is the lean docset’s schema, kept where the public repository cannot see it. Leave
additionalPropertiesunset, or the public keys fail it. -
Declare the collection in the private config.
manni.config.yaml collections:- name: public-pagespaths: ["public/docs/**/*.md"]externalMetadata:- file: ./docs-meta.yamlkeys: [source, jira]meta:overrides:- collection: public-pagesschemas: [./schemas/private.json]The public repository’s own
manni.config.yamlstays as it is. It never mentions external metadata, so no public run warns about a private file it cannot see. -
Run it from the private checkout.
Terminal window npx -y @hawkeyexl/manni meta validate
The two workflows
Section titled “The two workflows”The public repository keeps its ordinary gate. Nothing about the manifest reaches it:
name: Validate metadataon: [push, pull_request]jobs: manni: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: hawkeyexl/manni@v2The private repository checks out its submodule and runs from its own root.
submodules: true is what brings the public pages in. If the public repository
is public, the default token can clone it. If it is only public to your
organization, pass a token that can read it:
name: Validate metadataon: [push, pull_request]jobs: manni: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: submodules: true token: ${{ secrets.PUBLIC_DOCS_READ_TOKEN }} - uses: hawkeyexl/manni@v2The Action passes no paths, so the targets come from the private config’s
collections: and the orphan check runs. Bump the submodule pointer when the
public docs change, or the private run validates the pages as they were.
Across repositories
Section titled “Across repositories”file: accepts an https:// URL as well as a path. The manifest is fetched at
the start of every run, never cached, and merged exactly as a local one is.
Use it when the run cannot check the manifest out. The public form comes
first because it needs nothing else.
A public catalog
Section titled “A public catalog”Nothing here is private. One public repository keeps a catalog, say which team owns each page, and another public repository’s pages are judged by it. The docs run never clones the catalog. A plain URL, and no token anywhere:
collections: - name: pages paths: ["docs/**/*.md"] externalMetadata: - file: https://raw.githubusercontent.com/your-org/docs-catalog/main/owners.yaml keys: [team, owner]
meta: overrides: - collection: pages schemas: [./schemas/owned.json]The ordinary public workflow runs it unchanged. There is no secret to pass, and the public log shows only public values. A finding on a fetched value names the URL and the manifest line, so the catalog is what to edit.
A private manifest
Section titled “A private manifest”The manifest lives in a private repository the job does not check out, such as
a platform team’s catalog that serves several docsets. This is the
private values case with the manifest
moved. The run still starts from a private checkout, with the public pages as
its submodule. Add tokenEnv, the name of an environment variable holding a
bearer token:
collections: - name: public-pages paths: ["public/docs/**/*.md"] externalMetadata: - file: https://raw.githubusercontent.com/your-org/docs-catalog/main/docs-meta.yaml keys: [source, jira] tokenEnv: PRIVATE_DOCS_TOKEN
meta: overrides: - collection: public-pages schemas: [./schemas/private.json]The workflow passes the secret as that variable. The token never appears in the config, a message, or a report:
name: Validate metadataon: [push, pull_request]jobs: manni: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: submodules: true token: ${{ secrets.PUBLIC_DOCS_READ_TOKEN }} - uses: hawkeyexl/manni@v2 env: PRIVATE_DOCS_TOKEN: ${{ secrets.PRIVATE_DOCS_TOKEN }}Both hosts accept a bearer token on their raw-file route, so the same
tokenEnv serves either:
GitHub https://raw.githubusercontent.com/OWNER/REPO/REF/PATHGitLab https://gitlab.com/api/v4/projects/ID/repository/files/PATH/raw?ref=REF| Host | Token |
|---|---|
| GitHub | A fine-grained personal access token with read access to the repository’s contents. |
| GitLab | A project or personal access token with the read_repository scope. PATH is URL-encoded, so docs/docs-meta.yaml is written docs%2Fdocs-meta.yaml. |
A run that cannot reach the manifest stops before any page is judged, and says why. The status is named and the token never is:
$ PRIVATE_DOCS_TOKEN= manni meta validatemanni: Manifest https://raw.githubusercontent.com/your-org/docs-catalog/main/docs-meta.yaml: the environment variable PRIVATE_DOCS_TOKEN named by "tokenEnv" is not set. exit 2$ manni meta validate --offlinemanni: Manifest https://raw.githubusercontent.com/your-org/docs-catalog/main/docs-meta.yaml is remote and the run is offline. Vendor it to a path, or drop --offline. exit 2Every rule, including the timeout, the body cap, the redirect behaviour and
http://, is in the
reference.
Survive renames with a field join
Section titled “Survive renames with a field join”A path entry is orphaned the moment the page moves, and the next whole-corpus run is exit 2 until someone edits the manifest. That is the right default for a docset that rarely moves. It is the wrong shape for one that moves pages often. It is also wrong for a manifest kept by a team that does not watch the docs repository’s renames. The path is the one thing about a page the manifest author does not control.
join: keys the manifest by a top-level frontmatter field instead. The page
carries the key with it, so a rename never orphans an entry. Any field works.
A hand-written id, a Starlight slug, and a Docusaurus id are the same
mechanism.
-
Name the field on the manifest entry. Everything else in the config stays.
manni.config.yaml collections:- name: pagespaths: ["docs/**/*.md"]externalMetadata:- file: ./docs-meta.yamlkeys: [source, jira]join: idmeta:overrides:- collection: pagesschemas: [./private.schema.json] -
Key the manifest by that field’s values. The value is compared as a string, wherever the page lives.
docs-meta.yaml auth-guide:source: internal/auth-design.mdjira: PLAT-412billing-guide:source: internal/billing.mdjira: PLAT-388 -
Require the field in the schema, and give it a shape. A page without the field matches no entry, and nothing says so. What catches it is the
requiredfinding for the manifest’s key, which only fires when the schema demands both. Apatternkeeps an id well-formed.private.schema.json {"type": "object","required": ["title", "id", "jira"],"properties": {"title": { "type": "string" },"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },"source": { "type": "string" },"jira": { "type": "string", "pattern": "^PLAT-[0-9]+$" }}}
Two pages sharing one id is a finding on both, at the field’s line. One
entry matched both, and the manifest cannot tell them apart. Both pages
still receive the entry’s values, so the schema judges what the site would
publish. From test/fixtures/external-metadata-join/:
✗ docs/dup-a.md /id 2 documents carry id "shared"; docs-meta.yaml cannot tell them apart (docs/dup-b.md) (line 3) [external:duplicate]✗ docs/dup-b.md /id 2 documents carry id "shared"; docs-meta.yaml cannot tell them apart (docs/dup-a.md) (line 3) [external:duplicate]An entry whose id no page carries is still exit 2 on a whole-corpus run, checked once every page has been read:
$ manni meta validate -c manni.orphan.config.yamlmanni: Manifest docs-meta.orphan.yaml:3 names id "gone-guide", which no loaded document carries. Fix the entry, or remove it.The join key is contributor-controlled, and that is the trade. A pull request
that sets id: auth-guide on a new page inherits every assertion the manifest
makes about the real one. Three things bound that. The duplicate finding turns
the run red rather than green. The schema’s pattern keeps an id well-formed.
And the manifest is reviewed on its own, in the private repository when there
is one. An entry that suddenly matches two pages shows up there as a duplicate
first. A path join has none of this exposure, which is why it stays the
default.
query follows the same line. Renaming a field-joined page is allowed, which
is the point. Changing its id is a write to the join, and is refused:
$ manni meta query "UPDATE docs SET id = 'other' WHERE _path = 'docs/auth.md'"manni: "docs/auth.md": "id" is the field manifest docs-meta.yaml joins on, and this document has an entry; change the manifest first.When a page is in two collections
Section titled “When a page is in two collections”Two collections may each have a manifest supplying owner. That is ordinary: a
guides collection and a blog collection are separate sets with separate
catalogs. It only becomes a question when one page is a member of both, which
happens when their globs overlap:
collections: - name: guides paths: ["docs/**/*.md"] # includes docs/api/ externalMetadata: - file: ./guides-meta.yaml keys: [owner] - name: api paths: ["docs/api/**/*.md"] # a subset of the above externalMetadata: - file: ./api-meta.yaml keys: [owner]docs/api/auth.md is in both, and both own owner. There is no honest winner,
so the run stops rather than picking one:
manni: docs/api/auth.md: "owner" is owned by manifests in two of its collections, guides (guides-meta.yaml) and api (api-meta.yaml); a key has one manifest per file. Narrow one collection's paths or exclude.Two fixes, both in the config where the overlap was declared. Narrow guides so
it stops covering docs/api/. exclude: ["docs/api/**"] does it, since a
collection’s exclude decides membership. Or move owner out of one of the two
manifests, so only one owns the key for that page.
Overlapping collections are otherwise fine, and useful. A page in both appears in both views, and still resolves exactly one schema set by first-match-wins.
Editing the values
Section titled “Editing the values”A key the manifest owns is readable by every command, and every writer writes it into the manifest. The page is left alone. Again over the fixture corpus:
$ manni meta get jira docs/auth.mddocs/auth.md: jira=PLAT-412
$ manni meta query "UPDATE docs SET jira = 'PLAT-1' WHERE _path = 'docs/auth.md'" --dry-rundocs/auth.md: jira: PLAT-412 -> PLAT-1 [docs-meta.yaml]1 change across 1 file — dry run; run again without --dry-run to applyThe bracket names the manifest the change lands in. fill writes a proposal
for an owned key into the page’s entry the same way, and derive stamps a
managed field there, owner or last-reviewed included. Each splice keeps the
manifest’s comments. A corpus check still sees every key the manifest supplies.
So “every guide has a ticket in the open state” is a
SQL rule like any other.
Three writes are still refused. query refuses each before any file is
written (exit 2):
- A URL manifest. It is fetched, so nothing can be written to it:
"owner" is owned by manifest https://example.com/owners.yaml, which is fetched and cannot be written; set it in that repository. - A join field on a page that has an entry. Changing it would point the page at another entry, as the field join section shows.
ALTER TABLE … RENAME COLUMNof an owned key, because the manifest’skeys:would need renaming too.
Moving keys in or out
Section titled “Moving keys in or out”Adding a key to keys: by hand is only half the job. Every page that still
carries the key then fails external:owned until its value is copied across.
manni meta relocate does both
halves: it edits keys: and moves every value, in either direction. A schema
that marks a property
x-manni-location
tells it which way each key goes. It also creates what a move needs, and the
config edits keep comments and key order:
| Your config | What relocate writes |
|---|---|
| The page’s collection has a local manifest | The key is appended to that manifest’s keys:. |
| The collection has no manifest | <collection>.metadata.yaml beside the config file, and an externalMetadata: entry that owns the keys. |
No collections: at all |
A collection named default whose paths: are the targets you typed, and default.metadata.yaml. |
| One collection, and the page is outside it | The target is appended to the collection’s paths:. |
| Several collections, and the page is in none | Nothing. The value stays, and the run exits 1. |
A key moves back into the pages and empties keys: |
The externalMetadata: entry is removed and the manifest file is left on disk, with a line saying so. |
Keep maintainer metadata out of delivered pages walks through each case.