fix(api): settle the fourteen API inconsistencies the route docs pass found #280

Merged
dominik.polakovics merged 1 commit from afk/273 into main 2026-10-14 09:02:14 +02:00

Closes #273

Fixes all fourteen items #273 lists. Each one calls the rule's existing owner (CLAUDE.md "Every rule has exactly one owner") and has a test. docs/api.md now describes the corrected behaviour for every route touched.

Changes

  1. GET /api/link/resolve checks lang through opsRoot before handling any kind. An unknown lang is now 404 editor.unknownLang for external and media too.
  2. POST /api/element/duplicate now calls elemops.SlotElementsMsg, so it returns 422 element.slotRefuses, the same code as insert.
  3. GET /api/page/refs on the content root's own page returns 400 page.rootRefs, a new code. It uses the new exported pageops.IsContentRootPage.
  4. GET /api/media/library lists legacy folders whose names are not slugs, using mediaops.CleanFolder (the existing shape check, now exported). The slug rule only applies when an upload would create a new folder. The English and German text of library.folderInvalid is reworded to say what the check actually tests.
  5. Both folder-delete routes read folder the same way, through mediaops.NormalizeFolder, which lives in the shared planFolderDelete. Running the delete therefore gives the same result as the dry-run plan. The server's trimFolderSlashes and the copy in media.SaveToLibrary are gone.
  6. POST /api/media/upload checks target and folder before it decodes the file.
  7. Create, rename and translation rename all trim the title through one function, pageops.requireTitle.
  8. An item row's enabled is read with element.ReadEnabled. Any value other than true, false or absent is a field.bool field error (elemops.NewItem now returns that error).
  9. handleSavePage uses decodeBody and requirePost.
  10. A create form now checks its parent. The placement checks moved out of pageops.Create into pageops.CheckCreatePlacement, and create and the form both call it, so they return the same refusals with the same codes and statuses.
  11. In a connected translation, elementL10n reports nothing as synced when the source page has no element with the same uid and name. The editor now reads the form description's synced list and no longer rebuilds it from translation.synced[name]. Without this second change, the UI would have kept showing those fields as read-only.
  12. Structural element writes snapshot as create (insert, duplicate), delete, move, and enable/disable. snapshotOps and undo_ops.json are unchanged.
  13. A test shows that the plain l10n.beforeSlot, beforeParent and beforePage codes can never be returned, so UnplacedError now carries a single message and the three codes are deleted from Go and from the client catalogs.
  14. GET /api/publish/status checks the commit. A value that is not a 40-character hex sha returns 400 request.invalidParam (new code), and a well-formed sha the repository does not contain returns 404 publish.unknownCommit (new code). A commit the server never polled still returns static, but only if the repository has it. Without a repository the route returns 409 publish.noRepo, the same answer the other publish routes give. New helpers: gitrepo.IsCommitID and Repo.HasCommit.

Verification

  • go test ./..., go vet ./... and gofmt pass, including the drift tests for routes, snapshot ops, docs and apimsg.
  • cd web && npx vitest run passes (124 files, 3213 tests). tsc --noEmit and npm run build succeed.
  • I ran the server against a freshly scaffolded site and checked the issue's curl cases. link/resolve?lang=zz returns 404. page/refs?path=_index.md returns 400 page.rootRefs. media/library?folder=My Photos returns 200. The plan and the delete agree on a folder written with backslashes. publish/status returns 400 for zzz, 404 for an unknown sha, and static for HEAD.

Follow-ups (not in this PR)

  • l10n.beforePageParentFirst looks unreachable too, since a top-level element is always placed. I kept it because no test proves that yet.
  • A same-page element transfer still snapshots as save. Transfer is not one of the routes the issue lists.
  • pageops.isContentRootPage is now a thin wrapper around IsContentRootPage, so the rule is still evaluated only once. Renaming its callers is a mechanical cleanup.

scaffold/site/AGENTS.md and the scaffolded skills are untouched, because nothing they document has changed.

🤖 Generated with Claude Code

Closes #273 Fixes all fourteen items #273 lists. Each one calls the rule's existing owner (CLAUDE.md "Every rule has exactly one owner") and has a test. `docs/api.md` now describes the corrected behaviour for every route touched. ## Changes 1. `GET /api/link/resolve` checks `lang` through `opsRoot` before handling any kind. An unknown `lang` is now `404 editor.unknownLang` for `external` and `media` too. 2. `POST /api/element/duplicate` now calls `elemops.SlotElementsMsg`, so it returns `422 element.slotRefuses`, the same code as insert. 3. `GET /api/page/refs` on the content root's own page returns `400 page.rootRefs`, a new code. It uses the new exported `pageops.IsContentRootPage`. 4. `GET /api/media/library` lists legacy folders whose names are not slugs, using `mediaops.CleanFolder` (the existing shape check, now exported). The slug rule only applies when an upload would create a new folder. The English and German text of `library.folderInvalid` is reworded to say what the check actually tests. 5. Both folder-delete routes read `folder` the same way, through `mediaops.NormalizeFolder`, which lives in the shared `planFolderDelete`. Running the delete therefore gives the same result as the dry-run plan. The server's `trimFolderSlashes` and the copy in `media.SaveToLibrary` are gone. 6. `POST /api/media/upload` checks `target` and `folder` before it decodes the file. 7. Create, rename and translation rename all trim the title through one function, `pageops.requireTitle`. 8. An item row's `enabled` is read with `element.ReadEnabled`. Any value other than true, false or absent is a `field.bool` field error (`elemops.NewItem` now returns that error). 9. `handleSavePage` uses `decodeBody` and `requirePost`. 10. A create form now checks its `parent`. The placement checks moved out of `pageops.Create` into `pageops.CheckCreatePlacement`, and create and the form both call it, so they return the same refusals with the same codes and statuses. 11. In a connected translation, `elementL10n` reports nothing as synced when the source page has no element with the same uid and name. The editor now reads the form description's `synced` list and no longer rebuilds it from `translation.synced[name]`. Without this second change, the UI would have kept showing those fields as read-only. 12. Structural element writes snapshot as `create` (insert, duplicate), `delete`, `move`, and `enable`/`disable`. `snapshotOps` and `undo_ops.json` are unchanged. 13. A test shows that the plain `l10n.beforeSlot`, `beforeParent` and `beforePage` codes can never be returned, so `UnplacedError` now carries a single message and the three codes are deleted from Go and from the client catalogs. 14. `GET /api/publish/status` checks the commit. A value that is not a 40-character hex sha returns `400 request.invalidParam` (new code), and a well-formed sha the repository does not contain returns `404 publish.unknownCommit` (new code). A commit the server never polled still returns `static`, but only if the repository has it. Without a repository the route returns `409 publish.noRepo`, the same answer the other publish routes give. New helpers: `gitrepo.IsCommitID` and `Repo.HasCommit`. ## Verification - `go test ./...`, `go vet ./...` and gofmt pass, including the drift tests for routes, snapshot ops, docs and apimsg. - `cd web && npx vitest run` passes (124 files, 3213 tests). `tsc --noEmit` and `npm run build` succeed. - I ran the server against a freshly scaffolded site and checked the issue's curl cases. `link/resolve?lang=zz` returns 404. `page/refs?path=_index.md` returns 400 `page.rootRefs`. `media/library?folder=My Photos` returns 200. The plan and the delete agree on a folder written with backslashes. `publish/status` returns 400 for `zzz`, 404 for an unknown sha, and `static` for HEAD. ## Follow-ups (not in this PR) - `l10n.beforePageParentFirst` looks unreachable too, since a top-level element is always placed. I kept it because no test proves that yet. - A same-page element transfer still snapshots as `save`. Transfer is not one of the routes the issue lists. - `pageops.isContentRootPage` is now a thin wrapper around `IsContentRootPage`, so the rule is still evaluated only once. Renaming its callers is a mechanical cleanup. `scaffold/site/AGENTS.md` and the scaffolded skills are untouched, because nothing they document has changed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(api): settle the fourteen API inconsistencies the route docs pass found
All checks were successful
ci / build-and-test (pull_request) Successful in 3m12s
ci / release (pull_request) Has been skipped
05c89f3afd
Each fix calls the rule's existing owner instead of a sibling copy:

- link/resolve validates lang (opsRoot) before answering any kind
- element/duplicate checks the slot allowlist (elemops.SlotElementsMsg)
- page/refs refuses the content root's own page: 400 page.rootRefs
- media/library lists legacy non-slug folders (mediaops.CleanFolder);
  the slug rule stays where a folder comes into being
- both folder-delete routes spell folder through mediaops.NormalizeFolder
- media/upload checks target and folder before decoding the file
- create, rename and translation rename trim the title in one place
  (pageops.requireTitle)
- an item row's non-boolean enabled is a field.bool field error
  (element.ReadEnabled)
- page/save decodes through decodeBody
- a create form ref's parent is checked as create checks it
  (pageops.CheckCreatePlacement)
- no same-uid, same-name source element means nothing is synced; the
  editor takes the form description's synced verdict
- structural element writes snapshot as create/delete/move/enable/disable
- the plain l10n.before{Slot,Parent,Page} codes were unreachable and are
  removed
- publish/status refuses a malformed commit (400 request.invalidParam)
  and an unknown one (404 publish.unknownCommit)

docs/api.md describes the corrected behaviour for every touched route.

Closes #273

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

[autoland] verdict: pass

PASS. Signal: CI aggregate success (ci / build-and-test: npm build + vitest, go vet, go test ./... incl. route/docs/apimsg/snapshot-ops drift tests); gofmt -l on changed Go files clean locally. Conventions: Conventional Commits title, Closes #273 present, base a0c4ce7 = main (no conflict). Scope matches the 14 items of #273; each calls the named owner (opsRoot, SlotElementsMsg, mediaops.CleanFolder/NormalizeFolder in planFolderDelete, pageops.requireTitle, element.ReadEnabled, decodeBody, elementL10n, CheckCreatePlacement shared with Create). Removed l10n.before{Slot,Parent,Page} codes have no remaining references; new codes in apimsg and both catalogs; docs/api.md updated per route. The web change for item 11 (ElementForm reads the description's synced verdict) is necessary for the fix to reach the UI and follows CLAUDE.md's 'client does not re-derive' rule.

[autoland] verdict: pass PASS. Signal: CI aggregate success (ci / build-and-test: npm build + vitest, go vet, go test ./... incl. route/docs/apimsg/snapshot-ops drift tests); gofmt -l on changed Go files clean locally. Conventions: Conventional Commits title, Closes #273 present, base a0c4ce7 = main (no conflict). Scope matches the 14 items of #273; each calls the named owner (opsRoot, SlotElementsMsg, mediaops.CleanFolder/NormalizeFolder in planFolderDelete, pageops.requireTitle, element.ReadEnabled, decodeBody, elementL10n, CheckCreatePlacement shared with Create). Removed l10n.before{Slot,Parent,Page} codes have no remaining references; new codes in apimsg and both catalogs; docs/api.md updated per route. The web change for item 11 (ElementForm reads the description's synced verdict) is necessary for the fix to reach the UI and follows CLAUDE.md's 'client does not re-derive' rule.
Sign in to join this conversation.
No reviewers
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!280
No description provided.