You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Depends on #100, which owns the markfluence.yaml loader, malformed-file handling, and project-wide settings. This issue adds the pages: key on top of it: per-file page metadata living in the project file instead of in the markdown, so a .md can be published to Confluence while staying pristine.
Motivation
A GitHub Actions workflow (#29) should be able to publish docs/**/*.md to Confluence on push. Today that requires every file to carry markfluence frontmatter, which is exactly what a README or a docs tree that has other readers does not want, and #38 explored sidecar files as the escape hatch without anyone being thrilled about the discovery, XOR, migration and orphan rules it needs.
The flag route does not scale, and it is worth being precise about why. update README.md --page-id 12345 --title X --page-width max already works today. But flags scale with fields while the problem scales with files: --title and --page-id are single-FILE-only, so docs/**/*.md cannot be expressed at all, and every new frontmatter field (labels, #138; layout, #21) would have to grow its own flag to stay usable from CI. A file that maps paths to metadata solves both at once, and the project already has a file for project-scoped truth.
Every .md there has no frontmatter at all. An entry is a whole frontmatter block that lives elsewhere — the same keys, the same values, the same validation, the same canonical order — so internal/frontmatter is reused rather than paralleled and there is one answer to "what fields exist". Deliberately no path: <id> shorthand: one form, not two.
The workflow is then one step with no matrix and no per-file inputs:
Page creation stays a human act. Creating in CI would mean a workflow committing a new page_id back to the repo — contents: write, a bot commit per new doc, and a race if two runs create at once — which is a lot of machinery for something that happens rarely and benefits from a person seeing where the page landed.
So the local bootstrap has to be straightforward, and it is:
$ vim docs/new-thing.md # content only, no frontmatter
$ markfluence create docs/new-thing.md --space ENG --title "New Thing" --parent docs/index.md
$ git add markfluence.yaml docs/new-thing.md && git commit
create writes a complete pages: entry — title, space, parent, page_id, page_width — because the project has a pages: block. Nothing is hand-edited, and CI's update docs/**/*.md picks it up on the next run.
Resolution rules
Both locations are legal, and agreement is silent. A file may carry frontmatter, or have a pages: entry, or both with the same values. That last case is what makes migration incremental: copy values into the manifest, verify, delete them from the files later, with everything working throughout. There is no project "mode" and no all-or-nothing switch.
Disagreement is what gets reported, graded by what it can destroy:
key
on disagreement
page_id, space, parent
error, abort that file
the clobber case: a page_id copy/pasted from an old file publishes over a live page. space/parent decide where a create lands, and #10 wants update to enforce and move, so they are coordinates now rather than after a rule change
title, page_width, labels
warning, frontmatter wins
visible and recoverable; frontmatter wins per #100's flag > frontmatter > project file chain — the answer closer to the content wins
For create, which is transactional and preflights every file, a coordinate disagreement in any file aborts the whole batch. For update, only that file fails and the rest proceed.
Diagnostics are scoped to the files being processed. Running on bar.md must not report anything about foo.md's entry. This constrains the loader: parse errors and unknown top-level keys abort immediately per #100, but a semantically bad entry — a non-numeric page_id, an invalid page_width, an invalid label — is diagnosed only when that entry's file is one of the arguments. One bad entry must not block every invocation in the repo.
A file with no metadata anywhere is skipped, not failed.status: skipped, ok: true, a notice naming it, exit 0. Repositories legitimately contain markdown that is not published to Confluence, and drafts are a normal state; a glob-driven CI run should not go red because someone added a file. This is a behavior change — update currently errors on a file with no page_id — and it is deliberate. A file that is registered but whose entry lacks page_id still errors: someone claimed it and create has not run.
New metadata is written wherever that file's metadata already is, and when there is none, to the manifest if the project has a pages: block, otherwise into the file's frontmatter. A project that has chosen the manifest never accidentally grows frontmatter; a project without one behaves exactly as today.
Path keys are lexically-cleaned, root-relative, slash-form, compared byte-for-byte, and arguments are normalized the same way before lookup — a mismatch now means a silent skip, so the rule has to be exact.
Lexical only, no EvalSymlinks, matching withinRoot/attachfile.Resolve/destPath. A symlinked docs/ is legitimate, and resolving it would make a key depend on the checkout's layout, which L2 (invocation-independent) forbids.
A key escaping the root (../elsewhere.md) is a load-time error: the project file declares the project's boundary.
Two keys normalizing to one path (docs/a.md and ./docs/a.md) is a load-time error naming both; YAML only catches literal duplicates.
No case folding. Known limit, to document beside pageslug's NFD/NFC note: on a case-insensitive filesystem Docs/a.md opens the file but matches no docs/a.md key, so it reads as unmanaged and is skipped.
Both load-time errors are deliberately not scoped per-file: an escaping or duplicate key means the manifest's structure is wrong, not that one entry is bad.
update loses its page-metadata flags
The rule to extract: flags describe the run; files describe the page.
flag
--title
removed — single-FILE-only, now expressible in frontmatter or an entry
--page-id
removed — same, and it is the flag that made the CI workaround tempting
--page-width
removed — the only batch-ok one, so the real loss; a page_width: project default (#100) covers it better and permanently
--message, --force, --dry-run
kept — invocation metadata and behavior, not page metadata
createkeeps its flags, for a principled reason rather than squeamishness: it is the verb that establishes metadata and then persists it, so --space/--parent/--title are how an entry that does not exist yet gets bootstrapped — the local flow above is exactly that. update only ever consumes metadata, so it should have no way to invent any.
This also settles, permanently, the question #138 kept bumping into: there is no --labels, and there will be no flag for whatever field comes after it.
Touchpoints
internal/linkindex is the sharp one.Build walks the tree reading each sibling's frontmatter for page_id/title (linkindex.go:68-76). It must resolve metadata per path through a lookup that consults both locations, or every cross-document link in a manifest project silently degrades to the "exists on disk, not published yet" warning and republishes as plain text. This is #38's "sibling ambiguity" edge, and per-file resolution handles a mixed tree correctly throughout a migration.
check gains a lint — a file with inline markfluence keys in a project that also uses pages: is reported, as a warning, not an error. "No half-and-half" is a real preference and this is how it gets enforced without becoming a wall someone hits mid-migration. Scoped to the files being checked, like everything else.
fix can move a file's inline keys into its manifest entry, reported as an ordinary change. This is a convenience rather than a prerequisite — since agreement is legal, migration needs no special command — but it is what gives the check warning an obvious remedy, and it is read-only against Confluence, so migrating touches no live page.
--json gains metadata_source on update/create results: "frontmatter", "manifest", or null when the file is unmanaged. Debugging "why did it publish to that page" in CI otherwise means reproducing the resolution by hand. Q9's skipped status and the disagreement warnings surface through the existing status/warnings fields.
Docs: README (a project-file section, the CI flow, the removed flags), docs/guarantees.md (L2 is why keys are lexical and root-relative), and a _plans/ file before the branch.
Out of scope
Creating pages in CI. See above; if it is ever wanted, it is its own issue with its own answer for committing ids back.
export writing manifest entries.export creates a fresh tree and writes frontmatter; whether an exported tree can be manifest-shaped is a later question.
A --persist-to file|manifest flag. It is an invocation flag, so it would not violate the rule above, but the inference rule covers every case anyone has named. Add it if someone asks.
Depends on #100, which owns the
markfluence.yamlloader, malformed-file handling, and project-wide settings. This issue adds thepages:key on top of it: per-file page metadata living in the project file instead of in the markdown, so a.mdcan be published to Confluence while staying pristine.Motivation
A GitHub Actions workflow (#29) should be able to publish
docs/**/*.mdto Confluence on push. Today that requires every file to carry markfluence frontmatter, which is exactly what a README or a docs tree that has other readers does not want, and #38 explored sidecar files as the escape hatch without anyone being thrilled about the discovery, XOR, migration and orphan rules it needs.The flag route does not scale, and it is worth being precise about why.
update README.md --page-id 12345 --title X --page-width maxalready works today. But flags scale with fields while the problem scales with files:--titleand--page-idare single-FILE-only, sodocs/**/*.mdcannot be expressed at all, and every new frontmatter field (labels, #138;layout, #21) would have to grow its own flag to stay usable from CI. A file that maps paths to metadata solves both at once, and the project already has a file for project-scoped truth.The shape
Every
.mdthere has no frontmatter at all. An entry is a whole frontmatter block that lives elsewhere — the same keys, the same values, the same validation, the same canonical order — sointernal/frontmatteris reused rather than paralleled and there is one answer to "what fields exist". Deliberately nopath: <id>shorthand: one form, not two.The workflow is then one step with no matrix and no per-file inputs:
CI updates; humans create
Page creation stays a human act. Creating in CI would mean a workflow committing a new
page_idback to the repo —contents: write, a bot commit per new doc, and a race if two runs create at once — which is a lot of machinery for something that happens rarely and benefits from a person seeing where the page landed.So the local bootstrap has to be straightforward, and it is:
createwrites a completepages:entry — title, space, parent, page_id, page_width — because the project has apages:block. Nothing is hand-edited, and CI'supdate docs/**/*.mdpicks it up on the next run.Resolution rules
Both locations are legal, and agreement is silent. A file may carry frontmatter, or have a
pages:entry, or both with the same values. That last case is what makes migration incremental: copy values into the manifest, verify, delete them from the files later, with everything working throughout. There is no project "mode" and no all-or-nothing switch.Disagreement is what gets reported, graded by what it can destroy:
page_id,space,parentpage_idcopy/pasted from an old file publishes over a live page.space/parentdecide where acreatelands, and #10 wantsupdateto enforce and move, so they are coordinates now rather than after a rule changetitle,page_width,labelsflag > frontmatter > project filechain — the answer closer to the content winsFor
create, which is transactional and preflights every file, a coordinate disagreement in any file aborts the whole batch. Forupdate, only that file fails and the rest proceed.Diagnostics are scoped to the files being processed. Running on
bar.mdmust not report anything aboutfoo.md's entry. This constrains the loader: parse errors and unknown top-level keys abort immediately per #100, but a semantically bad entry — a non-numericpage_id, an invalidpage_width, an invalid label — is diagnosed only when that entry's file is one of the arguments. One bad entry must not block every invocation in the repo.A file with no metadata anywhere is skipped, not failed.
status: skipped,ok: true, a notice naming it, exit 0. Repositories legitimately contain markdown that is not published to Confluence, and drafts are a normal state; a glob-driven CI run should not go red because someone added a file. This is a behavior change —updatecurrently errors on a file with nopage_id— and it is deliberate. A file that is registered but whose entry lackspage_idstill errors: someone claimed it andcreatehas not run.New metadata is written wherever that file's metadata already is, and when there is none, to the manifest if the project has a
pages:block, otherwise into the file's frontmatter. A project that has chosen the manifest never accidentally grows frontmatter; a project without one behaves exactly as today.Path keys are lexically-cleaned, root-relative, slash-form, compared byte-for-byte, and arguments are normalized the same way before lookup — a mismatch now means a silent skip, so the rule has to be exact.
EvalSymlinks, matchingwithinRoot/attachfile.Resolve/destPath. A symlinkeddocs/is legitimate, and resolving it would make a key depend on the checkout's layout, which L2 (invocation-independent) forbids.../elsewhere.md) is a load-time error: the project file declares the project's boundary.docs/a.mdand./docs/a.md) is a load-time error naming both; YAML only catches literal duplicates.pageslug's NFD/NFC note: on a case-insensitive filesystemDocs/a.mdopens the file but matches nodocs/a.mdkey, so it reads as unmanaged and is skipped.Both load-time errors are deliberately not scoped per-file: an escaping or duplicate key means the manifest's structure is wrong, not that one entry is bad.
updateloses its page-metadata flagsThe rule to extract: flags describe the run; files describe the page.
--title--page-id--page-widthpage_width:project default (#100) covers it better and permanently--message,--force,--dry-runcreatekeeps its flags, for a principled reason rather than squeamishness: it is the verb that establishes metadata and then persists it, so--space/--parent/--titleare how an entry that does not exist yet gets bootstrapped — the local flow above is exactly that.updateonly ever consumes metadata, so it should have no way to invent any.This also settles, permanently, the question #138 kept bumping into: there is no
--labels, and there will be no flag for whatever field comes after it.Touchpoints
internal/linkindexis the sharp one.Buildwalks the tree reading each sibling's frontmatter forpage_id/title(linkindex.go:68-76). It must resolve metadata per path through a lookup that consults both locations, or every cross-document link in a manifest project silently degrades to the "exists on disk, not published yet" warning and republishes as plain text. This is #38's "sibling ambiguity" edge, and per-file resolution handles a mixed tree correctly throughout a migration.checkgains a lint — a file with inline markfluence keys in a project that also usespages:is reported, as a warning, not an error. "No half-and-half" is a real preference and this is how it gets enforced without becoming a wall someone hits mid-migration. Scoped to the files being checked, like everything else.fixcan move a file's inline keys into its manifest entry, reported as an ordinarychange. This is a convenience rather than a prerequisite — since agreement is legal, migration needs no special command — but it is what gives thecheckwarning an obvious remedy, and it is read-only against Confluence, so migrating touches no live page.--jsongainsmetadata_sourceonupdate/createresults:"frontmatter","manifest", ornullwhen the file is unmanaged. Debugging "why did it publish to that page" in CI otherwise means reproducing the resolution by hand. Q9'sskippedstatus and the disagreement warnings surface through the existingstatus/warningsfields.Docs: README (a project-file section, the CI flow, the removed flags),
docs/guarantees.md(L2 is why keys are lexical and root-relative), and a_plans/file before the branch.Out of scope
.mdwhose coordinates live elsewhere — without discovery, XOR, migration or orphan rules, because the location is one known file rather than N inferred ones. Explore: sidecar frontmatter files (in-file XOR sidecar) so .md stays clean #38 can be closed as superseded once this lands, or kept for the "metadata next to each file" preference if that turns out to be wanted separately.exportwriting manifest entries.exportcreates a fresh tree and writes frontmatter; whether an exported tree can be manifest-shaped is a later question.--persist-to file|manifestflag. It is an invocation flag, so it would not violate the rule above, but the inference rule covers every case anyone has named. Add it if someone asks.space:,page_width:,message:) — that is markfluence.yaml: support project-wide settings #100.Related
goccy/go-yamla direct dependency.labels, which is how labels reach CI without a flaglayout:; another field that would otherwise want a flagupdateenforcing space/parent and supporting moves; whyspace/parentare coordinates here