Media operations ignore library links in body prose: rename/move must rewrite them, delete must refuse and link to the referencing places #310

Closed
opened 2026-10-16 18:01:56 +02:00 by dominik.polakovics · 0 comments

This was generated by AI during triage.

Summary

A library reference inside body prose — a markdown link or inline anchor to /media/<folder>/<name> in a text body or a rich-text param — counts as a reference everywhere the media operations care: a library rename or move rewrites it, a delete is refused while it exists, and a publish carries the file. The delete refusal, and the dialog's pre-check list, name each referencing place as a link the editor opens, not a bare file path.

Current behavior

mediaops recognises a library reference only as a whole shortcode-parameter string or front-matter value (media.StoredLibraryRef, bare media/… or /media/… form); "body prose never counts" (internal/mediaops/doc.go, refs.go, delete.go). Yet the link picker's File tab stores /media/… into links, including links in rich text, and the theme renders them (ce/href.html). Consequences, all on the same page:

  • POST /api/media/rename, /move, /folder/rename, /folder/move rewrite the params and front matter of every referencing page and leave a prose link pointing at the old path — a 404 on the live site.
  • POST /api/media/delete and /folder/delete refuse only when a param or front-matter value references the file; a file linked only from prose is deleted and the link dangles.
  • The publish pre-flight (checkExportedMediaRefs, internal/server/publishset.go) builds its carried set from mediaops.PageRefs, so a file linked only from prose is not carried to main with its page, and the pre-flight does not notice.
  • When a delete is refused, the editor (DeleteFileDialog and DeleteMediaConfirm, web/src/components) lists the referencing pages as plain content paths (<li>{path}</li>); the user has to find the page and the element by hand. The pre-check list (GET /api/media/refs) is a substring scan (media.ReferenceScan), not the engine's answer, so it can disagree with the refusal.

The prose link positions are already owned: linkrefs scans them (Destinations: ](, ](< and href=" anchors, in Text nodes and inside string values) and rewrites them for page renames (Rewrites, RewritePage, Pair). linkrefs sits below media in the import graph and already owns the /media/ spelling (linkrefs.MediaPrefix, linkrefs.Media).

Desired behavior

  • One notion of reference. A page references a library file when the file's reference, in either stored form, is a whole shortcode-parameter string or front-matter value (as today) or a prose link destination (linkrefs.Destinations' positions) in the body or in a string value. mediaops.PageRefs, ReferencingPages, the delete refusals, the folder-delete plan and the rename/move rewrites all use that one notion. A bare textual mention of a path that is not at a link position stays a non-reference, as linkrefs already rules for page links.
  • Rename and move rewrite prose. RenameFile, MoveFile, RenameFolder and MoveFolder rewrite a prose link to the file (or to a file under the folder) to its new reference, in the same operation and under the same honest-refusal contract the parameter rewrite has (a page that cannot be parsed or re-serialised aborts the operation before anything moves). The rewrite of a link position is linkrefs' rewrite, called with media pairs — not a second prose rewriter in mediaops. Whether linkrefs.Pair grows an exact-file form or mediaops always passes folder-prefix pairs plus a file pair is the implementer's call; the trailing-slash prefix contract of linkrefs must not be weakened for page links.
  • Delete is refused while prose references exist, for a file and in the folder-delete plan's Keep list, with the same 409 media.referenced body.
  • Referencing places are links. The 409 body of POST /api/media/delete, the keep entries of POST /api/media/folder/delete (and its plan) and the answer of GET /api/media/refs name each referencing place so the client can open it: the page (its content path and language, as the editor's routes address a page) and, when the reference is in a shortcode parameter, the element's uid, so the client can use elementEditRoute / viewElementRoute (web/src/routing.ts) and land on the element. A prose reference and a front-matter reference link to the page. The delete dialogs render each place as a link; the dialog's pre-check list comes from the engine's notion of reference (the same answer the refusal gives), so what it lists is exactly what blocks the delete.
  • Publish carries prose-referenced files. checkExportedMediaRefs sees prose references through the same PageRefs, so a page linking a PDF only from its text carries the PDF and is refused when the PDF is missing on main.
  • docs/api.md records the new shapes of POST /api/media/delete (409 body), POST /api/media/folder/delete, GET /api/media/refs, and the rename/move routes' "rewrites every page that references it" sentences name prose links. docs/architecture.md "Media gate" / publish narrative says prose links count. scaffold/site/AGENTS.md's "Library media publishes with the pages that use it" bullet names prose links among the reference shapes (/media/<folder>/<name> in a link value, in front matter or in a text body). The mediaops and linkrefs package docs say which rule each owns. The edit-content skill's media paragraph, if it lists where a library reference may appear, includes prose links.

Decisions

Settled by the maintainer on 2026-10-16 (issue #300's triage); implement, do not reopen.

  • Prose links count as references for rename, move, delete and publish — the editor writes them, the theme renders them, so an operation that ignores them breaks the live site. The "substring in prose is not a reference" rule stays true for text that is not at a link position.
  • Delete refuses rather than warns while a prose reference exists, as it does for parameters: the invariant is "a library reference never dangles", enforced by refusing.
  • The refusal names places as links: a bare content path is not enough for a user to fix the reference.
  • One owner for "where do link destinations live in a page, and how is one rewritten": linkrefs. mediaops calls it; it does not add a second prose scanner or rewriter (CLAUDE.md "Every rule has exactly one owner"). The whole-value notion (StoredLibraryRef over params and front matter) stays mediaops' as today.
  • Bundle-local references stay out: a bundle asset is page-local, its delete keeps the warn-then-allow flow, and no rename or move exists for it.
  • Not blocked by #300: that issue adds the harness check warning and already reads prose through linkrefs.Destinations; this one makes the operations agree with it. Land in either order.

Key interfaces

  • linkrefs.Destinations, linkrefs.Rewrites, linkrefs.RewritePage, linkrefs.Pair, linkrefs.Classify, linkrefs.MediaPrefix — the prose scan and rewrite.
  • media.StoredLibraryRef, mediaops.PageRefs, mediaops.ReferencingPages, scanReferences, scanRewrites, rewriteParams, frontMatterChanges, *mediaops.ReferencedError, mediaops.DeletePlan / KeepEntry — the engine side to extend.
  • handleMediaDelete, handleMediaFolderDelete, handleMediaRefs, mediaReferencedResponse (internal/server/media.go); checkExportedMediaRefs (internal/server/publishset.go).
  • web/src/api/media.ts (deleteLibraryMedia, fetchMediaRefs), DeleteFileDialog in web/src/components/MediaModule.tsx, web/src/components/DeleteMediaConfirm.tsx, elementEditRoute / viewElementRoute in web/src/routing.ts.

Acceptance criteria

  • A page whose text body holds [brochure](/media/docs/a.pdf) and nothing else referencing the file: renaming a.pdf to b.pdf rewrites the link to /media/docs/b.pdf; moving it to docs/old/ rewrites it to /media/docs/old/a.pdf; renaming or moving the docs folder rewrites it accordingly. The same for an inline <a href="/media/docs/a.pdf"> and for a link inside a rich-text param. A page mentioning media/docs/a.pdf as plain words is untouched.
  • Deleting that file is refused with 409 media.referenced, the page named; the folder-delete plan keeps it with the page named; after the link is removed, both succeed.
  • The refusal body, the folder plan and GET /api/media/refs carry, per referencing place, what the client needs to open the page in its language and, for a parameter reference, the element; the delete dialogs render each place as a link that opens the editor there, and the pre-check list and the refusal agree.
  • A publish of a page whose only reference to a library file is a prose link carries the file; with the file absent from the publish set and from main, the pre-flight refuses with publish.mediaMissing naming it.
  • No second scan of link positions and no second prose rewriter exists in mediaops; no comment says a function mirrors or must stay in sync with another.
  • docs/api.md, docs/architecture.md, scaffold/site/AGENTS.md and the package docs say what the engine now does; the route drift test in internal/server and go test ./docs/ ./scaffold/ ./internal/element/ pass; the web client's tests (vitest) pass.

How to verify

  • go test ./internal/mediaops/ ./internal/linkrefs/ ./internal/media/ ./internal/server/ ./docs/ ./scaffold/ ./internal/element/, go vet ./...; in web/: npm test and npm run build (the server package tests do not need web/dist).
  • The acceptance cases as mediaops tests beside the existing rename/move/delete tests (renamefile_test.go, movefile_test.go, delete_test.go): before the change the prose link is left as is and the delete succeeds.
  • In a running editor: insert a link to a library PDF from the File tab into a text element, then try to delete the PDF in the Media module — the dialog refuses and the listed place opens the element.

Assumptions to check

  • That the rich-text link dialog and the link param picker both store /media/… for a File-tab choice and that the stored form never carries a query or fragment (media.StoredLibraryRef rejects those) — read web/src/components/SetLinkForm.tsx and the rte link code under web/src/rte/.
  • That linkrefs.Destinations yields prose destinations inside string params (rich-text params) as the package doc says, so no new position is needed.
  • That the editor's routes can address a translation file's element (lang plus page path plus uid) for a reference found in a translation.
  • That GET /api/media/refs has no other caller relying on the substring semantics (grep fetchMediaRefs and ReferenceScan); if media.ReferenceScan loses its last caller, remove it.

Out of scope

  • The harness check warning for a missing media file — #300.
  • Bundle-local references and the bundle delete flow.
  • Rewriting a prose link when a page is renamed (that already works, linkrefs).
  • Any change to how uploads are gated or processed.

Blocked by

None - can start immediately

  • #300 — harness check reports media references that resolve to no file; its triage found this gap.
> *This was generated by AI during triage.* ## Summary A library reference inside body prose — a markdown link or inline anchor to `/media/<folder>/<name>` in a text body or a rich-text param — counts as a reference everywhere the media operations care: a library rename or move rewrites it, a delete is refused while it exists, and a publish carries the file. The delete refusal, and the dialog's pre-check list, name each referencing place as a link the editor opens, not a bare file path. ## Current behavior `mediaops` recognises a library reference only as a whole shortcode-parameter string or front-matter value (`media.StoredLibraryRef`, bare `media/…` or `/media/…` form); "body prose never counts" (`internal/mediaops/doc.go`, `refs.go`, `delete.go`). Yet the link picker's File tab stores `/media/…` into links, including links in rich text, and the theme renders them (`ce/href.html`). Consequences, all on the same page: - `POST /api/media/rename`, `/move`, `/folder/rename`, `/folder/move` rewrite the params and front matter of every referencing page and leave a prose link pointing at the old path — a 404 on the live site. - `POST /api/media/delete` and `/folder/delete` refuse only when a param or front-matter value references the file; a file linked only from prose is deleted and the link dangles. - The publish pre-flight (`checkExportedMediaRefs`, `internal/server/publishset.go`) builds its carried set from `mediaops.PageRefs`, so a file linked only from prose is not carried to `main` with its page, and the pre-flight does not notice. - When a delete is refused, the editor (`DeleteFileDialog` and `DeleteMediaConfirm`, `web/src/components`) lists the referencing pages as plain content paths (`<li>{path}</li>`); the user has to find the page and the element by hand. The pre-check list (`GET /api/media/refs`) is a substring scan (`media.ReferenceScan`), not the engine's answer, so it can disagree with the refusal. The prose link positions are already owned: `linkrefs` scans them (`Destinations`: `](`, `](<` and `href="` anchors, in Text nodes and inside string values) and rewrites them for page renames (`Rewrites`, `RewritePage`, `Pair`). `linkrefs` sits below `media` in the import graph and already owns the `/media/` spelling (`linkrefs.MediaPrefix`, `linkrefs.Media`). ## Desired behavior - **One notion of reference.** A page references a library file when the file's reference, in either stored form, is a whole shortcode-parameter string or front-matter value (as today) or a prose link destination (`linkrefs.Destinations`' positions) in the body or in a string value. `mediaops.PageRefs`, `ReferencingPages`, the delete refusals, the folder-delete plan and the rename/move rewrites all use that one notion. A bare textual mention of a path that is not at a link position stays a non-reference, as `linkrefs` already rules for page links. - **Rename and move rewrite prose.** `RenameFile`, `MoveFile`, `RenameFolder` and `MoveFolder` rewrite a prose link to the file (or to a file under the folder) to its new reference, in the same operation and under the same honest-refusal contract the parameter rewrite has (a page that cannot be parsed or re-serialised aborts the operation before anything moves). The rewrite of a link position is `linkrefs`' rewrite, called with media pairs — not a second prose rewriter in `mediaops`. Whether `linkrefs.Pair` grows an exact-file form or `mediaops` always passes folder-prefix pairs plus a file pair is the implementer's call; the trailing-slash prefix contract of `linkrefs` must not be weakened for page links. - **Delete is refused while prose references exist**, for a file and in the folder-delete plan's Keep list, with the same `409 media.referenced` body. - **Referencing places are links.** The `409` body of `POST /api/media/delete`, the `keep` entries of `POST /api/media/folder/delete` (and its plan) and the answer of `GET /api/media/refs` name each referencing place so the client can open it: the page (its content path and language, as the editor's routes address a page) and, when the reference is in a shortcode parameter, the element's uid, so the client can use `elementEditRoute` / `viewElementRoute` (`web/src/routing.ts`) and land on the element. A prose reference and a front-matter reference link to the page. The delete dialogs render each place as a link; the dialog's pre-check list comes from the engine's notion of reference (the same answer the refusal gives), so what it lists is exactly what blocks the delete. - **Publish carries prose-referenced files.** `checkExportedMediaRefs` sees prose references through the same `PageRefs`, so a page linking a PDF only from its text carries the PDF and is refused when the PDF is missing on `main`. - `docs/api.md` records the new shapes of `POST /api/media/delete` (409 body), `POST /api/media/folder/delete`, `GET /api/media/refs`, and the rename/move routes' "rewrites every page that references it" sentences name prose links. `docs/architecture.md` "Media gate" / publish narrative says prose links count. `scaffold/site/AGENTS.md`'s "Library media publishes with the pages that use it" bullet names prose links among the reference shapes (`/media/<folder>/<name>` in a link value, in front matter **or in a text body**). The `mediaops` and `linkrefs` package docs say which rule each owns. The `edit-content` skill's media paragraph, if it lists where a library reference may appear, includes prose links. ## Decisions Settled by the maintainer on 2026-10-16 (issue #300's triage); implement, do not reopen. - Prose links count as references for rename, move, delete and publish — the editor writes them, the theme renders them, so an operation that ignores them breaks the live site. The "substring in prose is not a reference" rule stays true for text that is not at a link position. - Delete refuses rather than warns while a prose reference exists, as it does for parameters: the invariant is "a library reference never dangles", enforced by refusing. - The refusal names places as links: a bare content path is not enough for a user to fix the reference. - One owner for "where do link destinations live in a page, and how is one rewritten": `linkrefs`. `mediaops` calls it; it does not add a second prose scanner or rewriter (CLAUDE.md "Every rule has exactly one owner"). The whole-value notion (`StoredLibraryRef` over params and front matter) stays `mediaops`' as today. - Bundle-local references stay out: a bundle asset is page-local, its delete keeps the warn-then-allow flow, and no rename or move exists for it. - Not blocked by #300: that issue adds the `harness check` warning and already reads prose through `linkrefs.Destinations`; this one makes the operations agree with it. Land in either order. ## Key interfaces - `linkrefs.Destinations`, `linkrefs.Rewrites`, `linkrefs.RewritePage`, `linkrefs.Pair`, `linkrefs.Classify`, `linkrefs.MediaPrefix` — the prose scan and rewrite. - `media.StoredLibraryRef`, `mediaops.PageRefs`, `mediaops.ReferencingPages`, `scanReferences`, `scanRewrites`, `rewriteParams`, `frontMatterChanges`, `*mediaops.ReferencedError`, `mediaops.DeletePlan` / `KeepEntry` — the engine side to extend. - `handleMediaDelete`, `handleMediaFolderDelete`, `handleMediaRefs`, `mediaReferencedResponse` (`internal/server/media.go`); `checkExportedMediaRefs` (`internal/server/publishset.go`). - `web/src/api/media.ts` (`deleteLibraryMedia`, `fetchMediaRefs`), `DeleteFileDialog` in `web/src/components/MediaModule.tsx`, `web/src/components/DeleteMediaConfirm.tsx`, `elementEditRoute` / `viewElementRoute` in `web/src/routing.ts`. ## Acceptance criteria - [ ] A page whose text body holds `[brochure](/media/docs/a.pdf)` and nothing else referencing the file: renaming `a.pdf` to `b.pdf` rewrites the link to `/media/docs/b.pdf`; moving it to `docs/old/` rewrites it to `/media/docs/old/a.pdf`; renaming or moving the `docs` folder rewrites it accordingly. The same for an inline `<a href="/media/docs/a.pdf">` and for a link inside a rich-text param. A page mentioning `media/docs/a.pdf` as plain words is untouched. - [ ] Deleting that file is refused with `409 media.referenced`, the page named; the folder-delete plan keeps it with the page named; after the link is removed, both succeed. - [ ] The refusal body, the folder plan and `GET /api/media/refs` carry, per referencing place, what the client needs to open the page in its language and, for a parameter reference, the element; the delete dialogs render each place as a link that opens the editor there, and the pre-check list and the refusal agree. - [ ] A publish of a page whose only reference to a library file is a prose link carries the file; with the file absent from the publish set and from `main`, the pre-flight refuses with `publish.mediaMissing` naming it. - [ ] No second scan of link positions and no second prose rewriter exists in `mediaops`; no comment says a function mirrors or must stay in sync with another. - [ ] `docs/api.md`, `docs/architecture.md`, `scaffold/site/AGENTS.md` and the package docs say what the engine now does; the route drift test in `internal/server` and `go test ./docs/ ./scaffold/ ./internal/element/` pass; the web client's tests (`vitest`) pass. ## How to verify - `go test ./internal/mediaops/ ./internal/linkrefs/ ./internal/media/ ./internal/server/ ./docs/ ./scaffold/ ./internal/element/`, `go vet ./...`; in `web/`: `npm test` and `npm run build` (the server package tests do not need `web/dist`). - The acceptance cases as `mediaops` tests beside the existing rename/move/delete tests (`renamefile_test.go`, `movefile_test.go`, `delete_test.go`): before the change the prose link is left as is and the delete succeeds. - In a running editor: insert a link to a library PDF from the File tab into a text element, then try to delete the PDF in the Media module — the dialog refuses and the listed place opens the element. ## Assumptions to check - That the rich-text link dialog and the link param picker both store `/media/…` for a File-tab choice and that the stored form never carries a query or fragment (`media.StoredLibraryRef` rejects those) — read `web/src/components/SetLinkForm.tsx` and the rte link code under `web/src/rte/`. - That `linkrefs.Destinations` yields prose destinations inside string params (rich-text params) as the package doc says, so no new position is needed. - That the editor's routes can address a translation file's element (`lang` plus page path plus uid) for a reference found in a translation. - That `GET /api/media/refs` has no other caller relying on the substring semantics (grep `fetchMediaRefs` and `ReferenceScan`); if `media.ReferenceScan` loses its last caller, remove it. ## Out of scope - The `harness check` warning for a missing media file — #300. - Bundle-local references and the bundle delete flow. - Rewriting a prose link when a *page* is renamed (that already works, `linkrefs`). - Any change to how uploads are gated or processed. ## Blocked by None - can start immediately ## Related - #300 — `harness check` reports media references that resolve to no file; its triage found this gap.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#310
No description provided.