Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6073cbc
docs(plans): plan recursive export and the attachment name it depends on
willkg Sep 5, 2026
825517b
feat(convert): name an attachment by its base name
willkg Sep 5, 2026
39bcd43
docs(plans): sequence 029 into commits, checkpoints, and reviews
willkg Sep 5, 2026
2a07e1d
feat(convert): refuse two assets that want one attachment name
willkg Sep 5, 2026
3d155f1
feat(check): report a name collision as a document defect
willkg Sep 5, 2026
2c4dbd1
refactor(attachmentupload): refuse a name collision across a batch
willkg Sep 5, 2026
f3c0646
refactor(convert): stop interpreting a stored attachment name
willkg Sep 5, 2026
217fa0e
test: retire encoded attachment names from the fixtures
willkg Sep 5, 2026
aa91ba0
docs(confluence): attachment names are base names
willkg Sep 5, 2026
36a7377
docs(guarantees): L3 after the naming change, and R2's new first step
willkg Sep 5, 2026
4f28226
docs: README, CLAUDE.md, and attachment-upload's help for base names
willkg Sep 5, 2026
640f634
docs(plans): correct 025 where 029 superseded it
willkg Sep 5, 2026
07b9b2d
fix(attachmentupload): key the batch-collision guard on the file, not…
willkg Sep 5, 2026
7728cbe
refactor: move slugify into internal/pageslug
willkg Sep 5, 2026
16328bd
feat(pagedoc): position an attachment's destination against its page
willkg Sep 5, 2026
18c7163
feat(attachfile): place an unrecorded attachment under its page's dir…
willkg Sep 5, 2026
9f99cd4
feat(attachment-download): scope an unrecorded attachment to its page
willkg Sep 5, 2026
9146bda
fix(export): write an attachment where the exported markdown points a…
willkg Sep 5, 2026
4a20a1f
refactor(pagedoc): carry a page's placement in one value
willkg Sep 5, 2026
c1f10d7
feat(export): the on-disk layout of a walked subtree
willkg Sep 5, 2026
5f591fd
feat(export): --depth, and the tree it writes
willkg Sep 5, 2026
b552d94
feat(export): --space, exporting a whole space
willkg Sep 5, 2026
e1cb81e
feat(export): plant markfluence.yaml at the destination of a tree
willkg Sep 5, 2026
36a5d15
feat(export): report two pages writing one file with different content
willkg Sep 5, 2026
eae4eb5
feat(export): skip the render for a page already on disk
willkg Sep 5, 2026
981920c
feat(export): parent_file, a typed summary, and the schema for both
willkg Sep 5, 2026
d9948ae
fix(export): two ways a page's content could be replaced by another's
willkg Sep 5, 2026
3b159a7
test(convert): the round-trip property, and the drift it found
willkg Sep 5, 2026
7278d6b
feat(export): accept a folder as the export target
willkg Sep 5, 2026
d6d2863
docs: the tree export, and why L5/L6 stay Partial
willkg Sep 5, 2026
ff0180a
docs(export): the help's "same output read prints" is now conditional
willkg Sep 5, 2026
4b4cb21
fix(export): six findings from the whole-branch review
willkg Sep 5, 2026
1292c0b
fix(export): keep a page's collision warning when the page exports
willkg Sep 5, 2026
476cd9c
refactor(export): one copy of the order an export happens in
willkg Sep 5, 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
9 changes: 5 additions & 4 deletions CLAUDE.md

Large diffs are not rendered by default.

106 changes: 76 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -683,10 +683,44 @@ file can be edited and published straight back with `update`.

Attachments are written to the paths their images were published from, so the
exported tree matches the layout of the repo the page came from and previews
locally in GitHub or VSCode. There is deliberately no `--attachments-dir`:
collecting attachments into one directory would mean rewriting the image `src`s,
and a later `update` would then publish them under different attachment names,
orphaning the originals.
locally in GitHub or VSCode. `--depth` exports the page's descendants as well, mirroring the Confluence
hierarchy:

```console
$ markfluence export 1234567890 --depth all --dest out
wrote out/markfluence.yaml
wrote out/handbook.md
wrote out/handbook/onboarding.md
downloaded out/handbook/onboarding/diagram.png
2 pages (2 exported, 0 skipped, 0 failed)
```

A page becomes `<slug>.md` with a `<slug>/` beside it holding its children and
its own Confluence-native attachments; a folder becomes a directory. Each
child's `parent:` points at its parent's file (`parent: ../handbook.md`), so the
tree can be published into fresh pages rather than only back into the ids it
came from. `--depth` takes `0` (the default, the page alone), a positive number,
or `all`.

`--space KEY` exports a whole space instead of a page, its root pages forming
the top level. It needs an explicit `--depth`, since walking a space costs a
pair of requests per page and folder in it. A **folder** can be the target too,
in which case what is inside it becomes the top level.

`markfluence.yaml` is written at `--dest` for a multi-page export, marking it as
a project root. Without it, each exported file's root would be its own
directory, and a shared asset above a page would resolve outside it — so the
tree would not publish back. An existing one is left alone.

A page whose file already exists is skipped, so a re-run resumes rather than
re-fetching; `--force` re-exports everything, which is also how you refresh a
tree whose pages changed upstream.

There is deliberately no `--attachments-dir`. It is no longer *unsafe* — an
attachment is named by its base name, so moving `assets/x.png` to
`attachments/x.png` keeps the name `x.png` and orphans nothing — but collecting
everything into one directory reintroduces exactly the collision the base name
already has to refuse: two pages' `diagram.png` cannot share a directory.

Only attachments the page actually references are exported. That includes images,
attachment links, and references inside macros markfluence passes through
Expand Down Expand Up @@ -714,21 +748,22 @@ List a page's attachments.

```console
$ markfluence attachment-list 1234567890
NAME SIZE VER TYPE SOURCE
assets%2Fdiagram.png 24.1 KB 3 image/png assets/diagram.png
notes.pdf 1.2 MB 1 application/pdf -
NAME SIZE VER TYPE SOURCE
diagram.png 24.1 KB 3 image/png assets/diagram.png
notes.pdf 1.2 MB 1 application/pdf -
```

`NAME` is the name Confluence stores. For an image markfluence published that is
the percent-encoded source path (see [Body](#body)), and `SOURCE` is the Markdown
image path it came from — so the table shows at a glance which attachments a
publish manages and which it will leave alone.
`NAME` is the name Confluence stores — for an image markfluence published, the
file's base name (see [Body](#body))and `SOURCE` is the Markdown image path
it came from, recorded in the attachment's comment. The table shows at a glance
which attachments a publish manages and which it will leave alone.

`SOURCE` is a dash when no source path is recorded: either the attachment was
uploaded by hand, or it was published before markfluence recorded source paths.
Those two look the same here; `--json` has a `managed` field that tells them
apart. Attachments left behind by the encoding change show up this way, which is
how you find them.
apart. Attachments left behind by a naming change show up this way, which is how
you find them — including the percent-encoded names markfluence wrote before it
started naming attachments by their base name.

### `attachment-upload`

Expand All @@ -747,11 +782,15 @@ current. `--force` uploads anyway (bumping the attachment's version), which is
how you repair an attachment whose stored bytes drifted while its checksum still
matches. `--dry-run` previews without writing.

`--name` sets the attachment name for a single file and takes a **path**, which
markfluence encodes for you — so `--name assets/x.png` produces the attachment
that an image written as `![](assets/x.png)` resolves to. The recorded source
path always matches the stored name, so a later publish won't create a duplicate
under a different one.
`--name` takes a **path**, not a name, for a single file — so `--name
assets/x.png` produces the attachment that an image written as
`![](assets/x.png)` resolves to: stored as `x.png`, with `assets/x.png` recorded
as its source. The stored name is always the base name of the recorded path, so
a later publish won't create a duplicate under a different one.

Uploading several files at once refuses a collision the same way publishing
does: `arch/diagram.png` and `deploy/diagram.png` in one command both want the
attachment `diagram.png`, so neither is uploaded.

```sh
markfluence attachment-upload 1234567890 diagram.png
Expand Down Expand Up @@ -779,9 +818,12 @@ downloaded /out/assets/diagram.png
downloaded /out/notes.pdf
```

An attachment with no recorded path — hand-uploaded, or published before
markfluence recorded them — is written under its stored name. `--flat` writes
everything under stored names. `--dest` defaults to the current directory and is
An attachment with no recorded path — one that originated in Confluence, or was
published before markfluence recorded them — is written under a directory named
after the page, since an attachment name is unique per page and not per space:
two pages' `diagram.png` would otherwise be one file. That is where `read` and
`export` point at it too. `--flat` writes everything directly under `--dest`,
under stored names. `--dest` defaults to the current directory and is
created if missing. An existing file is skipped unless `--force`, and
`--dry-run` previews without writing.

Expand Down Expand Up @@ -1186,15 +1228,19 @@ one resolving outside it (`../../secrets/x.png`) is reported as
`line N: 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.
Confluence attachment names cannot contain `/`, so an image is attached under
its **base name**: `assets/logo.png` is attached as `logo.png`. The path —
relative to the root, not to the page — is recorded in the attachment's
comment, which is what `markfluence read` and `markfluence export` use to put
the file back where it came from. The same file referenced as
`../assets/logo.png` from a page one directory down is the same attachment,
since both resolve to the same root-relative path.

Because the name is only the base name, two images in one file whose names
agree — `arch/diagram.png` and `deploy/diagram.png` — cannot both be published:
an attachment name is unique per page, so one would overwrite the other. That is
refused, naming both paths, and `markfluence check` reports it without
publishing. Rename one of the files.

Extra properties ride in the title as JSON:

Expand Down
16 changes: 15 additions & 1 deletion _plans/025_file-organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,21 @@ Two consequences to carry into implementation:
exports as different markdown depending on how many pages were asked for.
Nothing depends on today's flat-in-the-root behaviour.

**Slug collisions are refused, naming both pages.** `slugify` is lossy —
> **Correction (`_plans/029`, #59).** The last sentence is wrong. The
> attachment *name* depended on it. A name was the percent-encoded
> root-relative path, and the name is the attachment's identity, so
> page-scoping an attachment moved its markdown path, moved its name, and made
> republishing create a second attachment while orphaning the first. The rule
> stands and the reasoning for it stands; what changed to make it payable is
> that an attachment is now named by its base name, so a moved path keeps its
> name. See `_plans/029` §"The thing 025 got wrong".

**Slug collisions are refused, naming both pages.** *(Superseded by
`_plans/029`, which disambiguates with a `-<id>` suffix instead. Refusing makes
a space unexportable over a punctuation variant, and `--space` exists for spaces
the caller cannot retitle; the L2 objection below does not reach an exported
filename, which is ergonomic because identity travels in `page_id` under L8.)*
`slugify` is lossy —
`Deploy: Prod`, `Deploy Prod` and `deploy-prod` all become `deploy-prod`, and
long titles truncate — so two pages can want one filename even though Confluence
enforces unique titles per space. Mirroring narrows this to siblings, and it does
Expand Down
Loading