Skip to content

ADR-0010: Inline images in editorial Article bodies

Status

Accepted

Date

2026-08-17

Context

Editorial Article bodies are imported from the C.S. Lewis Official Substack (CSL-102). Some posts embed images inside the body — a handwritten-letter scan, a section header graphic. The shared rich-text allowlist (data.richtext.MARKDOWN_FEATURES: bold/italic/link/ol/ul) had no image support, so the importer dropped every in-body image. The mobile app consumes the body as Markdown (ADR-0006 #3); the readers layer projects Wagtail HTML to Markdown.

Markdown expresses images (![alt](url)), so images can travel end-to-end without breaking the lossless HTML→Markdown contract. The question is how to represent them so they also render and stay editable in the Wagtail CMS.

Decision Drivers

  • Must: in-body images reach the mobile app as Markdown images.
  • Must: images render and stay editable in the Wagtail editor (drafts are reviewed).
  • Must: keep Lewis's canon bodies (Passage/Writing/Book) image-free.
  • Should: not hotlink third-party CDNs that can rot or revoke access.

Considered Options

Option A: Keep a raw <img src="../substackcdn…"> in the stored HTML

  • Pros: no download; simplest importer change.
  • Cons: not a Wagtail image, so the Draftail editor strips it on the first edit/save — the review workflow silently deletes images. Hotlinks a third-party CDN.

Option B: Download each image into a Wagtail Image + insert an image embed

  • Pros: renders and stays editable in the editor; Wagtail hosts the file (S3 in prod), no hotlink; expand_db_html + markdownify project it to ![alt](rendition-url) for the app. Mirrors the existing cover-image handling.
  • Cons: adds the image feature to the Article body allowlist; downloads and stores third-party images in our media.

Decision

We will use Option B. The importer downloads each in-body image into a Wagtail Image (reused by title on re-runs, like the cover) and inserts a Wagtail image embed (<embed embedtype="image" …/>). The image feature is added to the Article body allowlist only — data.richtext.ARTICLE_BODY_FEATURES = [*MARKDOWN_FEATURES, "image"] — so canon bodies stay image-free. Adding the feature needs no migration. --skip-images drops in-body images (offline runs).

Rendition. An in-body image is not served through the width ladder of ADR-0009: it sits in a Markdown body, and Markdown's ![alt](url) holds a single URL, not a size-keyed object. So the embed resolves to one WebP rendition — a custom rich-text image format, body-webp (readers.images.BODY_IMAGE_FORMAT), bound to the ladder's WebP "large" spec (width-1080|format-webp). This picks a generous reading width and keeps the WebP format, rather than Wagtail's default fullwidth (800px, original format). The rendition is warmed on Article save (data/signals.py) so a reader's first body projection does not pay for the resize; Wagtail's lazy generation is the fallback.

Only editorially valuable in-body images are kept. Of the 8 in-body images across 6 licensed posts, review (2026-08-17) kept 3 posts — the handwritten "1921" letter (Posting Into the Void), an illustration (A Devil's Triumph), and the manuscript/portrait (C.S. Lewis, The Poet). The other 3 posts' images are decorative banners / section headers. The importer keeps in-body images only for the slugs in INLINE_IMAGE_SLUGS; every other post's in-body images are dropped even without --skip-images.

Consequences

Positive

  • The three valuable in-body images render in the editor and project to Markdown images (one WebP rendition each) for the app; decorative banners are excluded, so bodies stay clean.
  • Images are hosted by us (S3 in prod), not hotlinked from Substack's CDN.
  • The HTML→Markdown contract stays lossless (image is a first-class Markdown mark).

Negative

  • We store third-party images in our media; rely on the licensing that covers each post's content (CSL-102, 28 licensed posts).

Risks & Mitigations

  • Risk: on local/dev the rendition URL is relative (/media/…) → the app cannot resolve it. → Mitigation: prod serves images from S3 as absolute URLs; confirm the app resolves media paths against the API host if relative URLs ever ship.
  • Risk: Substack buttons carry styling classes we cannot keep. → Mitigation: Markdown has no classes; content-button links are preserved as [label](url), which is the faithful Markdown representation (styling is out of contract).
  • Risk: an in-body image gets a single fixed width, not a responsive ladder → a small quality/size trade-off vs the structured image fields. → Mitigation: inherent to the Markdown body contract; width-1080 WebP is a reasonable reading width and re-tuning is a one-line spec change.
  • ADR-0006: Content Model & Serialization — Markdown body contract.
  • ADR-0007: Book Ingestion Pipeline — importer/provenance conventions this mirrors.
  • ADR-0009: Image rendition contract — the ladder for structured image fields, which excludes these in-body images.
  • CSL-102: Substack ingestion pipeline (editorial Articles); 28 licensed posts.