Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
acf2721
docs: record the properties markfluence holds itself to
willkg Aug 27, 2026
94a8ce8
docs: capture attachment name/comment length behavior
willkg Aug 28, 2026
816c32c
docs(plans): spec how files on disk map to Confluence, and back
willkg Aug 28, 2026
2c3483c
docs(plans): sequence 025's root model into an implementation plan
willkg Aug 28, 2026
3b94376
feat(project): add root discovery (026 commit 1)
willkg Aug 28, 2026
ff74415
feat(client): read .env from the discovered project root (026 commit 2)
willkg Aug 28, 2026
f9324a1
feat: add --root, overriding discovery for the whole invocation (026 …
willkg Aug 28, 2026
1e87713
feat(convert): thread the root through the converter (026 commit 4)
willkg Aug 29, 2026
0ffe102
docs(plans): record commit 4 as landed, and the JSON split
willkg Aug 29, 2026
b693ea6
feat(convert): root-relative link index, minimal R1 (026 commit 5)
willkg Aug 29, 2026
fa79467
docs(plans): record commit 5 as landed
willkg Aug 29, 2026
a3253d7
feat(create): S2 completion for parent: reads (026 commit 6)
willkg Aug 29, 2026
f5ef277
feat(client): attachment identity -- root-relative source, shorter co…
willkg Aug 29, 2026
3b2deb5
feat(create): three-phase restructure -- preflight, reserve, publish …
willkg Aug 29, 2026
6bf69e6
docs: the documentation root model (026 commit 9)
willkg Aug 29, 2026
6dd3dc9
docs: trim Scoped tokens commentary, add a decision guide to the root…
willkg Aug 29, 2026
acdcc0d
docs: drop historical notes with no real audience pre-release
willkg Aug 29, 2026
0a91bdb
refactor(client): drop legacy attachment-comment support
willkg Aug 29, 2026
df62123
fix: root-escape guard for attachment-upload, cache backfill, failure…
willkg Aug 29, 2026
cc0b3d3
docs: --root also redirects .env, for create/update/attachment-upload
willkg Aug 29, 2026
30caaee
feat(json): report the documentation root(s) in --json, top-level
willkg Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

144 changes: 108 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,13 @@ resolved with the precedence **flag > environment variable > `.env` file**:
| API token | *(none — never a flag)* | `CONFLUENCE_TOKEN` |
| Cloud ID *(optional)* | `--cloud-id` | `CONFLUENCE_CLOUD_ID` |

markfluence reads a `.env` file from the current directory automatically (no need
to `source` it), or from an explicit path via `--env-file PATH`.
markfluence reads a `.env` file automatically (no need to `source` it) from the
[documentation root](#the-documentation-root) — the directory holding
`markfluence.yaml`, found by walking up from the working directory, or the
working directory itself with no `markfluence.yaml` above it — or from an
explicit path via `--env-file PATH`. For `create`, `update`, and
`attachment-upload`, `--root PATH` redirects this too, the same as it does
the per-file root those commands otherwise resolve independently.

Copy `.env.example` to `.env` and fill in:

Expand Down Expand Up @@ -129,24 +134,22 @@ readonly:content.attachment:confluence
read:confluence-content.summary
```

> [!IMPORTANT]
> **The mixture of naming styles is correct, not a copy-paste error.** Atlassian
> has two scope vocabularies — *classic* (`read:confluence-user`) and *granular*
> (`read:page:confluence`) — and they are granted independently: holding one does
> **not** imply the other. markfluence talks to both API versions, and each
> version accepts only one vocabulary, so the list above is genuinely mixed. A
> token granted the classic names alone fails with
> `401 Unauthorized; scope does not match` on almost every command, which is what
> makes this an easy list to get wrong. The measurements behind that are in
> [docs/confluence/api.md](docs/confluence/api.md#scopes).

> [!NOTE]
> Scopes are fixed when a token is issued. A missing one needs a **new** token,
> not an edit to the existing one.

> [!NOTE]
> Currently, markfluence doesn't support deleting anything, so it doesn't need
> delete scopes. This might change in the future.
**The mixture of naming styles is correct, not a copy-paste error.** Atlassian
has two scope vocabularies — *classic* (`read:confluence-user`) and *granular*
(`read:page:confluence`) — and they are granted independently: holding one does
**not** imply the other. markfluence talks to both API versions, and each
version accepts only one vocabulary, so the list above is genuinely mixed.

A token granted the classic names alone fails with
`401 Unauthorized; scope does not match` on almost every command, which is what
makes this an easy list to get wrong. The measurements behind that are in
[docs/confluence/api.md](docs/confluence/api.md#scopes).
If you get a `401 Unauthorized; scope does not match` error, you need additional
scopes.

A **403** (rather than the 401 above) means the opposite problem: the token is
scoped for the call, but the service account lacks Confluence permission on that
Expand Down Expand Up @@ -252,7 +255,13 @@ written back (and the file won't record its new `page_id`).

A whole tree can be created in one pass: give each child a `parent:` that points at
its parent's `.md` file, and `create` orders creation parents-first and fills in the
real ids (see the `parent` field below).
real ids (see the `parent` field below). Creation is three-phase — every file is
validated (above), then a content-less stub is reserved for each, parents-first,
before any of them is converted — so a link from one file in the batch to another
resolves regardless of which direction it points, or whether the two link to each
other. A run interrupted after this point leaves a permanent, empty page version
behind rather than no page at all; every id is already persisted (unless
`--no-persist`), so a plain `update` finishes the job.

`--dry-run` validates every file (the same checks a real run makes, so it exits
non-zero on the same failures) and previews what would be created — pages,
Expand Down Expand Up @@ -638,7 +647,8 @@ Usage: markfluence attachment-upload PAGE FILE... [flags]
Upload or replace attachments on a page, complementing the automatic sync that
`create` and `update` perform for a page's images.

Each file is attached under its base name. A file whose contents already match
Each file is attached under its path relative to the documentation root (its
base name, with no `markfluence.yaml` above it). A file whose contents already match
what's on the page is skipped, using the same checksum bookkeeping
`create`/`update` use, so uploading by hand and publishing agree on what's
current. `--force` uploads anyway (bumping the attachment's version), which is
Expand Down Expand Up @@ -1001,11 +1011,15 @@ view the file on GitHub, so a page in a subdirectory can share an asset
directory above it:

```
docs/ ← run markfluence from here
docs/ ← needs a markfluence.yaml here for this to work
assets/logo.png
guide/page.md → ![logo](../assets/logo.png)
```

That layout needs a [documentation root](#the-documentation-root) declared at
`docs/` — without one, each page's root defaults to its own directory, and
`guide/page.md` reaching above itself for `assets/` is out of bounds.

> [!NOTE]
> An image path is a URL, not a filename, so a space or other special character
> has to be percent-encoded — `![shot](assets/my%20image.png)` for a file named
Expand All @@ -1020,24 +1034,21 @@ docs/ ← run markfluence from here
> `markfluence read` and `markfluence export` write the encoded form, so a page
> round-trips back to Markdown that still renders.

**Run markfluence from the root of your documentation tree.** That root bounds
which images may be published: an image resolving outside it (`../../secrets/x.png`)
is reported as `IMAGE BROKEN: … (outside the documentation root)` rather than
uploaded.

Confluence attachment names cannot contain `/`, so the path is percent-encoded
into the attachment name — `assets/logo.png` is attached as `assets%2Flogo.png`,
and `../assets/logo.png` as `..%2Fassets%2Flogo.png`. The encoding is reversible,
so `markfluence read` restores an image's original path instead of a flattened
Every image is bounded by the [documentation root](#the-documentation-root):
one resolving outside it (`../../secrets/x.png`) is reported as
`IMAGE BROKEN: … (outside the documentation root)` rather than uploaded, and a
symlink is refused even when it resolves inside the root.

Confluence attachment names cannot contain `/`, so the path — relative to the
root, not to the page — is percent-encoded into the attachment name:
`assets/logo.png` referenced from a page at the root is attached as
`assets%2Flogo.png`; the same file referenced as `../assets/logo.png` from a
page one directory down is attached under the *same* name, since both
resolve to the same root-relative path. The encoding is reversible, so
`markfluence read` restores an image's original path instead of a flattened
one. markfluence also records the source path in the attachment's comment, which
it prefers over decoding the name.

> [!NOTE]
> Pages published before this encoding existed used `/` → `_`. Republishing such
> a page uploads the image under its new name and updates the page to match, but
> the old attachment stays behind, unreferenced — markfluence never deletes.
> Remove those manually if the clutter bothers you.

Extra properties ride in the title as JSON:

```markdown
Expand Down Expand Up @@ -1071,8 +1082,9 @@ non-ASCII heading anchor may arrive as `#caf%C3%A9-section`. Both are decoded
before markfluence matches them against files and headings on disk, so either
spelling resolves. A link it cannot resolve — a target with no `page_id`, or a
file that isn't there — is left exactly as written and published as-is, which on
Confluence is a dead relative link. There is no warning for this, so check the
targets when a link matters.
Confluence is a dead relative link. A `.md` link shaped like a same-tree
reference gets a warning when this happens; a mention, an attachment link, or
an external URL was never meant to resolve here and stays silent.

**Comment directives:**
- `<!-- confluence-toc -->` — replaced with Confluence table-of-contents macro.
Expand Down Expand Up @@ -1113,6 +1125,66 @@ Right column.

Storage markup shown inside a fenced code block stays literal (it isn't activated).

## The documentation root

**Do you need a `markfluence.yaml`?**

- If you export, edit, and publish files **one at a time**, no. Each file's
root defaults to its own directory, and that's already the directory you
want.
- If you're working on a **directory tree** of files — pages that link to
each other, or that share an `assets/` directory — put a
`markfluence.yaml` at the root of that tree. Without one, each file's root
still defaults to its own directory, which means a page can't reach an
image or another page sitting *above* itself; a shared-assets layout like
the one in [Images](#body) above needs a declared root to work at all.

```yaml
# Marks the root of a markfluence project. Image and link paths are recorded
# relative to this directory. https://github.com/mozilla/markfluence
```

The rest of this section is the precise version of the same idea. Every
markdown file has a **documentation root**: the directory holding
`markfluence.yaml`, found by walking up from the file's own directory, or —
with no `markfluence.yaml` anywhere above it — the file's own directory. It
bounds which images and `parent:` references a file may read, and it's what
an image's recorded attachment name and source are relative to. The root
actually used is reported once per distinct value in a run. `--root PATH`
overrides discovery for the whole invocation — and, for `create`, `update`,
and `attachment-upload`, also redirects where `.env` is read from (see
[Configure](#configure)).

For the reasoning behind this model — why a bare marker file, what it fixes,
what it costs — see [docs/root-model.md](docs/root-model.md) and
[_plans/025_file-organization.md](_plans/025_file-organization.md).

### Common tasks

**Moving or renaming a markdown file.** Just move it. Links to it resolve by
where it actually is, via the root-relative link index — nothing elsewhere
needs editing, and nothing needs republishing except the moved file itself
(to pick up its own new links, if any changed).

**Moving a page's own images along with it.** This churns: an attachment's
identity is relative to the *root*, not the page, so moving both together
changes the images' root-relative paths, and the next publish uploads them
under new names, leaving the originals behind unreferenced (markfluence never
deletes; [#99](https://github.com/mozilla/markfluence/issues/99) tracks a
future `attachment-prune`). Moving just the page and leaving its images in a
shared directory is the free move instead.

**Renaming or moving a shared asset**, independent of any page, churns the
same way: every page referencing it records a new attachment name on its next
publish. Identity follows the asset's location, not any particular page's
(this is L3 in [docs/guarantees.md](docs/guarantees.md) — `identity-from-asset-location`).

**Setting up a shared assets directory across many pages** needs a
`markfluence.yaml` at the directory that should be the shared root. Without
one, each page's root defaults to its own directory, and an asset above any
one of them is `IMAGE BROKEN` — the layout in [Images](#body) above needs
this to work at all.

## Development

Requires Go 1.25+.
Expand Down
Loading