Media: bundle uploads, global media library, image field picker, recompression rules #12

Closed
opened 2026-07-11 13:00:54 +02:00 by dominik.polakovics · 2 comments

What to build

The hybrid media model (docs/DECISIONS.md D18): image fields upload into the page's leaf bundle by default; a global Media Library at assets/media/ holds shared assets; the image picker offers Upload and Choose-from-library. Enforced rules: jpg/png/webp/avif + PDF only; no SVG or video uploads; ~15 MB cap; images auto-recompressed and downscaled server-side before entering git; alt text required by default (per-field overridable). Responsive rendering stays the theme's job.

Acceptance criteria

  • Image field opens a picker with Upload and Library tabs; upload lands in the page bundle, library files under assets/media/
  • Library browsing shows thumbnails and folders; picking a library asset stores a stable reference the shipped shortcodes resolve
  • Disallowed types (svg, video, executables — by content sniffing, not extension) and oversize files are rejected with clear messages
  • An 8000-px phone photo is downscaled/recompressed before being written; original dimensions never enter the repo
  • Alt text is required on save unless the field opts out; missing alt blocks save with an inline error
  • Deleting a media item warns when it is referenced by other pages (best-effort reference scan)

Blocked by

## What to build The hybrid media model (docs/DECISIONS.md D18): image fields upload into the page's leaf bundle by default; a global Media Library at `assets/media/` holds shared assets; the image picker offers Upload and Choose-from-library. Enforced rules: jpg/png/webp/avif + PDF only; no SVG or video uploads; ~15 MB cap; images auto-recompressed and downscaled server-side before entering git; alt text required by default (per-field overridable). Responsive rendering stays the theme's job. ## Acceptance criteria - [ ] Image field opens a picker with Upload and Library tabs; upload lands in the page bundle, library files under `assets/media/` - [ ] Library browsing shows thumbnails and folders; picking a library asset stores a stable reference the shipped shortcodes resolve - [ ] Disallowed types (svg, video, executables — by content sniffing, not extension) and oversize files are rejected with clear messages - [ ] An 8000-px phone photo is downscaled/recompressed before being written; original dimensions never enter the repo - [ ] Alt text is required on save unless the field opts out; missing alt blocks save with an inline error - [ ] Deleting a media item warns when it is referenced by other pages (best-effort reference scan) ## Blocked by - #3
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Hybrid media model: image fields open an Upload/Library picker; uploads land in the page's leaf bundle by default, shared assets live in a global media library under assets/media/; the server enforces allowed types by content sniffing, a ~15 MB cap, server-side downscale/recompression before anything enters git, required alt text (per-field opt-out), and a best-effort reference-scan warning on delete.

Dependency note: this issue is hard-blocked by #3 (sidecar schemas, generated forms, canonical save path) — the scheduler handles ordering. In #3 the image field type is deliberately a plain path string typed by hand; this issue replaces that input with the real picker and adds the media backend. The issue body's docs/DECISIONS.md D18 reference points at a file that is not committed — this brief is self-contained.

Current behavior:
The harness serves the SolidJS shell and a supervised hugo server preview, and the content engine round-trips .md files through a typed element tree with canonical serialization. Once #3 lands, element params are editable through schema-generated forms and saved canonically, but an image param is just a text input holding a path: nothing can upload a file, no media endpoints exist, no binary asset is ever validated or transformed, and there is no assets/media/ library. Sample-site pages are standalone .md files, not leaf bundles.

Desired behavior:

Picker. An image field in a generated form opens a picker dialog with two tabs: Upload and Library. Upload from the page-editing context stores the file in that page's leaf bundle (the directory of its index.md) and stores a page-relative reference in the param. If the page is currently a standalone .md file rather than a bundle, the first bundle upload must still work — e.g. by transparently converting the page to a leaf bundle (slug.mdslug/index.md) with its content preserved byte-for-byte; how is the implementer's choice, but the user never sees a "this page can't hold images" dead end. The Library tab browses the global library, shows folders and image thumbnails, allows uploading into the library, and picking an asset stores a stable reference (distinguishable from a bundle-relative path) that the site's shortcode templates can resolve and that survives the canonical round-trip unchanged.

Global library. Shared assets live under assets/media/ in the site directory, organized in subfolders. The harness API exposes: list (folders + items with thumbnail URLs), upload (multipart), and delete. Thumbnails for browsing are generated/served by the harness and must not add derivative files to the repo.

Enforced types. Only jpg, png, webp, avif, and PDF are accepted — decided by content sniffing (magic bytes / decoding), never by file extension or client-declared MIME type. SVG, video, executables, and everything else are rejected with a clear, human-readable message shown in the picker. A file whose sniffed type is allowed but whose extension disagrees must not be stored under the misleading extension.

Size and recompression. Uploads over the cap (~15 MB, defined as a single named constant) are rejected with a clear message before processing. Accepted images are downscaled and recompressed server-side before being written anywhere inside the repo: dimensions are clamped to a documented maximum edge length (e.g. ~2560 px; exact value is the implementer's choice, but it must be a named, documented constant), EXIF orientation is applied so the stored pixels are upright, and the result is re-encoded at a sensible quality. The original upload bytes and original dimensions never enter the repo. PDFs are sniffed and size-capped but stored unmodified. If the server cannot encode a given input format after processing, re-encoding to another allowed format is acceptable; silently storing the unprocessed original is not.

Alt text. When an image is chosen or uploaded for an image field, alt text is required by default: saving the page with a missing/empty alt on such a field blocks the save with an inline error on that field (same validation surface as #3's required params). A sidecar-level per-field flag opts a specific image field out of the requirement (for decorative images). The alt value persists through the canonical serializer alongside the image reference and is available to the shortcode template; the exact persistence shape (e.g. a paired param) is the implementer's choice.

Delete. Deleting a media item (library or bundle asset) triggers a best-effort reference scan across the site's content files; if references are found, the UI warns and names the referencing pages before the user confirms. Unreferenced items delete without a warning. "Best-effort" means a textual scan is acceptable — it must never block deletion, only inform.

Rendering. Responsive rendering (srcset, sizes, art direction) remains the theme's job — the harness stores one processed asset per upload.

Key interfaces:

  • The image field type from #3's sidecar schema: same param value on the shortcode (a string reference), now populated by the picker; the sidecar grows an opt-out flag for the alt requirement on a per-field basis, and alt persistence must round-trip through the content engine.
  • Media API contract: library listing (folders, items, thumbnails), upload (multipart; target = page bundle or library folder), delete with reference-scan result. Uploads are validated server-side; the client-side picker only surfaces the server's verdict.
  • Reference format: bundle-relative path vs. library reference must be distinguishable, stable under canonical serialization, and resolvable by Hugo shortcode templates (library assets via Hugo's asset pipeline).
  • Processing pipeline invariant: no upload byte is written inside the repo before sniffing, cap check, and (for images) downscale/recompress have all passed.

Acceptance criteria:

  • An image field opens a picker with Upload and Library tabs; a page-context upload lands in that page's leaf bundle (converting a standalone .md page to a bundle, or equivalent, without losing content), a library upload lands under assets/media/
  • Library browsing shows folders and thumbnails; picking a library asset stores a stable reference that a shortcode template resolves to the rendered image and that survives save/reload unchanged
  • SVG, video, and executable uploads are rejected by content sniffing regardless of extension or declared MIME type, with a clear message; renaming evil.svg to evil.png does not get it through
  • Files over the ~15 MB cap are rejected with a clear message
  • An 8000-px photo is downscaled and recompressed before being written; the stored file has the documented maximum dimensions and the original bytes/dimensions never appear anywhere in the repo; EXIF orientation is honored
  • PDFs within the cap are accepted and stored unmodified
  • Saving a page with a missing alt on an image field blocks the save with an inline error on that field; a sidecar per-field opt-out flag disables the requirement for that field only
  • Deleting a referenced media item warns and names the referencing pages before confirmation; deleting an unreferenced item does not warn
  • Tests cover: sniffing rejections, the size cap, the downscale/recompress path, alt validation (required and opted-out), and the reference scan

Out of scope:

  • Responsive image rendering, srcset/sizes, and any styling — the theme's job (#15)
  • The full element library's image-consuming elements (#14) — one demonstrable image-bearing shortcode on the sample site is enough here
  • Git commits of uploaded media (#10) — writing files into the working tree is enough
  • Page create/rename/move/delete and general bundle management (#9) — only the minimal page-to-bundle conversion needed for first upload
  • Rich-text image insertion (#7/#8)
  • Locks, identity, CSRF (#13)
  • Image editing, cropping, focal points, video uploads (never in v1), SVG support
> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Hybrid media model: image fields open an Upload/Library picker; uploads land in the page's leaf bundle by default, shared assets live in a global media library under `assets/media/`; the server enforces allowed types by content sniffing, a ~15 MB cap, server-side downscale/recompression before anything enters git, required alt text (per-field opt-out), and a best-effort reference-scan warning on delete. **Dependency note:** this issue is **hard-blocked by #3** (sidecar schemas, generated forms, canonical save path) — the scheduler handles ordering. In #3 the `image` field type is deliberately a plain path string typed by hand; this issue replaces that input with the real picker and adds the media backend. The issue body's `docs/DECISIONS.md D18` reference points at a file that is not committed — this brief is self-contained. **Current behavior:** The harness serves the SolidJS shell and a supervised `hugo server` preview, and the content engine round-trips `.md` files through a typed element tree with canonical serialization. Once #3 lands, element params are editable through schema-generated forms and saved canonically, but an `image` param is just a text input holding a path: nothing can upload a file, no media endpoints exist, no binary asset is ever validated or transformed, and there is no `assets/media/` library. Sample-site pages are standalone `.md` files, not leaf bundles. **Desired behavior:** *Picker.* An `image` field in a generated form opens a picker dialog with two tabs: **Upload** and **Library**. Upload from the page-editing context stores the file in that page's leaf bundle (the directory of its `index.md`) and stores a page-relative reference in the param. If the page is currently a standalone `.md` file rather than a bundle, the first bundle upload must still work — e.g. by transparently converting the page to a leaf bundle (`slug.md` → `slug/index.md`) with its content preserved byte-for-byte; how is the implementer's choice, but the user never sees a "this page can't hold images" dead end. The Library tab browses the global library, shows folders and image thumbnails, allows uploading into the library, and picking an asset stores a stable reference (distinguishable from a bundle-relative path) that the site's shortcode templates can resolve and that survives the canonical round-trip unchanged. *Global library.* Shared assets live under `assets/media/` in the site directory, organized in subfolders. The harness API exposes: list (folders + items with thumbnail URLs), upload (multipart), and delete. Thumbnails for browsing are generated/served by the harness and must not add derivative files to the repo. *Enforced types.* Only jpg, png, webp, avif, and PDF are accepted — decided by **content sniffing** (magic bytes / decoding), never by file extension or client-declared MIME type. SVG, video, executables, and everything else are rejected with a clear, human-readable message shown in the picker. A file whose sniffed type is allowed but whose extension disagrees must not be stored under the misleading extension. *Size and recompression.* Uploads over the cap (~15 MB, defined as a single named constant) are rejected with a clear message before processing. Accepted images are downscaled and recompressed server-side **before** being written anywhere inside the repo: dimensions are clamped to a documented maximum edge length (e.g. ~2560 px; exact value is the implementer's choice, but it must be a named, documented constant), EXIF orientation is applied so the stored pixels are upright, and the result is re-encoded at a sensible quality. The original upload bytes and original dimensions never enter the repo. PDFs are sniffed and size-capped but stored unmodified. If the server cannot encode a given input format after processing, re-encoding to another allowed format is acceptable; silently storing the unprocessed original is not. *Alt text.* When an image is chosen or uploaded for an image field, alt text is required by default: saving the page with a missing/empty alt on such a field blocks the save with an inline error on that field (same validation surface as #3's required params). A sidecar-level per-field flag opts a specific image field out of the requirement (for decorative images). The alt value persists through the canonical serializer alongside the image reference and is available to the shortcode template; the exact persistence shape (e.g. a paired param) is the implementer's choice. *Delete.* Deleting a media item (library or bundle asset) triggers a best-effort reference scan across the site's content files; if references are found, the UI warns and names the referencing pages before the user confirms. Unreferenced items delete without a warning. "Best-effort" means a textual scan is acceptable — it must never block deletion, only inform. *Rendering.* Responsive rendering (srcset, sizes, art direction) remains the theme's job — the harness stores one processed asset per upload. **Key interfaces:** - The `image` field type from #3's sidecar schema: same param value on the shortcode (a string reference), now populated by the picker; the sidecar grows an opt-out flag for the alt requirement on a per-field basis, and alt persistence must round-trip through the content engine. - Media API contract: library listing (folders, items, thumbnails), upload (multipart; target = page bundle or library folder), delete with reference-scan result. Uploads are validated server-side; the client-side picker only surfaces the server's verdict. - Reference format: bundle-relative path vs. library reference must be distinguishable, stable under canonical serialization, and resolvable by Hugo shortcode templates (library assets via Hugo's asset pipeline). - Processing pipeline invariant: no upload byte is written inside the repo before sniffing, cap check, and (for images) downscale/recompress have all passed. **Acceptance criteria:** - [ ] An image field opens a picker with Upload and Library tabs; a page-context upload lands in that page's leaf bundle (converting a standalone `.md` page to a bundle, or equivalent, without losing content), a library upload lands under `assets/media/` - [ ] Library browsing shows folders and thumbnails; picking a library asset stores a stable reference that a shortcode template resolves to the rendered image and that survives save/reload unchanged - [ ] SVG, video, and executable uploads are rejected by content sniffing regardless of extension or declared MIME type, with a clear message; renaming `evil.svg` to `evil.png` does not get it through - [ ] Files over the ~15 MB cap are rejected with a clear message - [ ] An 8000-px photo is downscaled and recompressed before being written; the stored file has the documented maximum dimensions and the original bytes/dimensions never appear anywhere in the repo; EXIF orientation is honored - [ ] PDFs within the cap are accepted and stored unmodified - [ ] Saving a page with a missing alt on an image field blocks the save with an inline error on that field; a sidecar per-field opt-out flag disables the requirement for that field only - [ ] Deleting a referenced media item warns and names the referencing pages before confirmation; deleting an unreferenced item does not warn - [ ] Tests cover: sniffing rejections, the size cap, the downscale/recompress path, alt validation (required and opted-out), and the reference scan **Out of scope:** - Responsive image rendering, srcset/sizes, and any styling — the theme's job (#15) - The full element library's image-consuming elements (#14) — one demonstrable image-bearing shortcode on the sample site is enough here - Git commits of uploaded media (#10) — writing files into the working tree is enough - Page create/rename/move/delete and general bundle management (#9) — only the minimal page-to-bundle conversion needed for first upload - Rich-text image insertion (#7/#8) - Locks, identity, CSRF (#13) - Image editing, cropping, focal points, video uploads (never in v1), SVG support
Author
Owner

Closing as superseded: the maintainer has decided on a clean-slate reset of the project (issue #29, PR #30). The v1 implementation this issue builds on is being removed and the plan will be re-drawn from scratch; this spec is retired rather than actioned.

Closing as superseded: the maintainer has decided on a clean-slate reset of the project (issue #29, PR #30). The v1 implementation this issue builds on is being removed and the plan will be re-drawn from scratch; this spec is retired rather than actioned.
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#12
No description provided.