feat: media uploads — bundle & library, image picker, sniffing + recompression, alt text #26

Closed
dominik.polakovics wants to merge 5 commits from afk/12 into main

Closes #12

Implements the hybrid media model: image fields open an Upload/Library picker; page uploads land in the page's leaf bundle (converting standalone `slug.md` to `slug/index.md` byte-for-byte on first upload), shared assets live under `assets/media/` addressed by stable `media:` refs that the shipped hero shortcode resolves via Hugo's asset pipeline.

What's in here

  • `internal/media` — content sniffing by magic bytes only (jpg/png/webp/avif/pdf allowed; SVG, video, executables, GIF, HEIC rejected with user-facing messages), a 15 MB cap (`MaxUploadBytes`), and a processing pipeline: decode → EXIF-orient upright → downscale to `MaxEdgePx` (2560) → re-encode. Original bytes and dimensions never enter the repo; metadata (incl. GPS) is stripped as a side effect. AVIF re-encodes to WebP (pure-Go wasm AVIF encoding is prohibitively slow — the brief allows re-encoding); PDFs pass through unmodified. Pure Go (wazero codecs), no cgo.
  • `internal/element` — every image param pairs with `_alt`; alt is required when the image is set unless the sidecar opts out with `alt_required: false`. Missing alt blocks save with an inline 422 on the alt field.
  • `internal/server` — `/api/media/library|upload|delete|thumb`. Upload validates fully in memory before any byte is written; stored filenames are slugified with the extension taken from the sniffed kind (`evil.svg`→`evil.png` doesn't survive; a JPEG named `photo.png` stores as `photo.jpg`). Delete runs a best-effort reference scan and answers 409 with the referencing pages until confirmed. Thumbnails are generated on demand and cached in memory only — no derivatives in the repo.
  • `web` — MediaPicker dialog (Upload / Library tabs, folder browsing with thumbnails, upload-to-library, delete with reference warning), image fields with paired alt inputs, and page-rename handling so the open form survives a bundle conversion.
  • Sample site — hero renders a real `` resolving both ref kinds; home page references a library asset.

Verified

  • `go vet ./...`, `go test ./...` (content, element, media, server, supervisor), `npm test` (28), `npm run build`, `tsc --noEmit` — all green.
  • Live smoke test against the running harness: 4000×3000 JPEG stored as 2560×1920; SVG-renamed-.png → 415; 16 MB → 413; PDF accepted but refused for image fields; `posts/first.md` converted byte-identically to `posts/first/index.md` with the image beside it; referenced delete → 409 naming `_index.md`, unreferenced → immediate; missing alt → 422 on `image_alt`; Hugo preview renders the uploaded library asset with its alt text.

Out of scope per the brief: responsive rendering (#15), element library (#14), git commits of media (#10), general page management (#9), rich-text insertion (#7/#8), locks/CSRF (#13).

🤖 Generated with Claude Code

https://claude.ai/code/session_01K3ADUhWHjwPvHsehpDpqBD

Closes #12 Implements the hybrid media model: image fields open an Upload/Library picker; page uploads land in the page's leaf bundle (converting standalone \`slug.md\` to \`slug/index.md\` byte-for-byte on first upload), shared assets live under \`assets/media/\` addressed by stable \`media:\` refs that the shipped hero shortcode resolves via Hugo's asset pipeline. ## What's in here - **\`internal/media\`** — content sniffing by magic bytes only (jpg/png/webp/avif/pdf allowed; SVG, video, executables, GIF, HEIC rejected with user-facing messages), a 15 MB cap (\`MaxUploadBytes\`), and a processing pipeline: decode → EXIF-orient upright → downscale to \`MaxEdgePx\` (2560) → re-encode. Original bytes and dimensions never enter the repo; metadata (incl. GPS) is stripped as a side effect. AVIF re-encodes to WebP (pure-Go wasm AVIF encoding is prohibitively slow — the brief allows re-encoding); PDFs pass through unmodified. Pure Go (wazero codecs), no cgo. - **\`internal/element\`** — every image param pairs with \`<name>_alt\`; alt is required when the image is set unless the sidecar opts out with \`alt_required: false\`. Missing alt blocks save with an inline 422 on the alt field. - **\`internal/server\`** — \`/api/media/library|upload|delete|thumb\`. Upload validates fully in memory before any byte is written; stored filenames are slugified with the extension taken from the *sniffed* kind (\`evil.svg\`→\`evil.png\` doesn't survive; a JPEG named \`photo.png\` stores as \`photo.jpg\`). Delete runs a best-effort reference scan and answers 409 with the referencing pages until confirmed. Thumbnails are generated on demand and cached in memory only — no derivatives in the repo. - **\`web\`** — MediaPicker dialog (Upload / Library tabs, folder browsing with thumbnails, upload-to-library, delete with reference warning), image fields with paired alt inputs, and page-rename handling so the open form survives a bundle conversion. - **Sample site** — hero renders a real \`<img>\` resolving both ref kinds; home page references a library asset. ## Verified - \`go vet ./...\`, \`go test ./...\` (content, element, media, server, supervisor), \`npm test\` (28), \`npm run build\`, \`tsc --noEmit\` — all green. - Live smoke test against the running harness: 4000×3000 JPEG stored as 2560×1920; SVG-renamed-.png → 415; 16 MB → 413; PDF accepted but refused for image fields; \`posts/first.md\` converted byte-identically to \`posts/first/index.md\` with the image beside it; referenced delete → 409 naming \`_index.md\`, unreferenced → immediate; missing alt → 422 on \`image_alt\`; Hugo preview renders the uploaded library asset with its alt text. Out of scope per the brief: responsive rendering (#15), element library (#14), git commits of media (#10), general page management (#9), rich-text insertion (#7/#8), locks/CSRF (#13). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K3ADUhWHjwPvHsehpDpqBD
New internal/media package: magic-byte sniffing (jpg/png/webp/avif/pdf
allowed; svg, video, executables, gif rejected with user-facing
messages), a 15 MB cap, and a processing pipeline that decodes, applies
EXIF orientation, downscales to a 2560 px max edge, and re-encodes every
image before it can enter the repo — stripping metadata (including GPS)
as a side effect. AVIF re-encodes to WebP (pure-Go wasm AVIF encoding is
prohibitively slow); PDFs pass through unmodified. In-memory JPEG
thumbnails for the picker UI. Pure Go via wazero-based codecs — no cgo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3ADUhWHjwPvHsehpDpqBD
Every image param implicitly pairs with a '<name>_alt' shortcode param.
Alt text is required whenever the image has a value, unless the sidecar
opts the field out with 'alt_required: false'. Validation recognizes and
type-checks alt keys (never "unknown parameter"), coercion drops orphan
alt values, and Schema.OwnsParam lets the save path replace rather than
duplicate existing alt params. Sidecars declaring a param that collides
with a generated alt name fail to load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3ADUhWHjwPvHsehpDpqBD
New /api/media/* endpoints: library listing (folders, items, thumbnail
URLs), multipart upload targeting the shared library or a page's leaf
bundle, delete guarded by a best-effort reference scan (409 + referencing
pages until confirmed), and on-demand JPEG thumbnails cached only in
memory. Uploading to a standalone page converts it to a leaf bundle
(slug.md -> slug/index.md) byte-for-byte first. No upload byte is written
into the site until sniffing, the size cap, and processing have all
passed; stored filenames are slugified and always take their extension
from the sniffed kind. The save path now merges params via OwnsParam so
edited alt text replaces instead of duplicating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3ADUhWHjwPvHsehpDpqBD
Image params in the generated form now open a picker dialog: Upload
stores into the page's bundle (adopting the new page path when the
upload converts a standalone page to a bundle, without losing the open
form), Library browses assets/media with folders and thumbnails, uploads
into the current folder, and deletes with a warning naming referencing
pages. Each image field carries a paired alt-text input, required unless
the sidecar opts out, validated client-side and mapped from server 422s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3ADUhWHjwPvHsehpDpqBD
feat(testdata): sample library asset and hero image rendering
All checks were successful
ci / build-and-test (pull_request) Successful in 3m31s
77fc4ff674
The hero shortcode renders a real <img>, resolving media: refs through
Hugo's asset pipeline and bare filenames as page-bundle resources, with
alt text from image_alt. The sample site gains a library image under
assets/media/sample/ that the home page hero now references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3ADUhWHjwPvHsehpDpqBD
All checks were successful
ci / build-and-test (pull_request) Successful in 3m31s

Pull request closed

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!26
No description provided.