Markdown Image Url Resolution

DL#161 — Markdown Image URL Resolution

Written for AI agents. See Log Methodology Note below for details.

Background

The markdown plugin (@jay-framework/markdown) renders .md files via the markdown-pages component. When markdown content contains relative image references like ![Diagram](22 - flow.svg), the rendered HTML produces <img src="22%20-%20flow.svg"> relative to the page URL — but no static file exists at that route. The dev server interprets it as a markdown page request and fails:

GET /design-log/22%20-%20serialized%20mutable%20flow%202.svg
Error: ENOENT: no such file or directory, open 'content/22 - serialized mutable flow 2.svg.md'

The images sit alongside the .md files in the content directory, but that directory isn't served as static files.

Problem

  1. Relative image paths in markdown resolve against the page URL, not the content directory
  2. The content directory isn't exposed as a public-accessible path
  3. No mechanism to rewrite image URLs to externally hosted versions (e.g., CDN)

Design

Three layers. Layers 1+2 work together as the zero-config default. Layer 3 replaces both when provided.

Layer 1: Rewrite relative URLs to content directory path

In the markdown parser, intercept <img src="..."> tags. If src is relative (not http:// or https://), rewrite it to be served from the public folder where layer 2 copies the files.

Use marked's renderer.image override with an imageBaseUrl computed from the content directory's public-relative path.

Layer 2: Copy media files to public directory (default)

During loadParams (build time), scan the content directory for non-.md files (images, SVGs, diagrams) and copy them to the project's public/ folder under a matching path. The public folder path is read from the .jay config (devServer.publicFolder, defaults to ./public).

This is the default behavior — no props needed. Images alongside markdown files are automatically made available as static assets.

contentDir: design-log/content/
public folder: ./public/

Copy: design-log/content/22 - flow.svg
  →   public/design-log/content/22 - flow.svg

URL rewrite: <img src="/design-log/content/22%20-%20flow.svg">

Layer 3: Media mapping file (replaces layers 1+2)

Optional mediaMap prop pointing to a YAML file that maps local filenames to CDN URLs with responsive variants:

# media-map.yaml — generated by an upload script
'22 - flow.svg':
  width: 800
  height: 600
  src: 'https://static.wixstatic.com/media/abc123/v1/fill/w_800,h_600/flow.webp'
  srcset:
    - url: 'https://static.wixstatic.com/media/abc123/v1/fill/w_400,h_300/flow.webp'
      width: 400
    - url: 'https://static.wixstatic.com/media/abc123/v1/fill/w_800,h_600/flow.webp'
      width: 800
'diagram.png':
  width: 1200
  height: 900
  src: 'https://static.wixstatic.com/media/def456/v1/fill/w_1200,h_900/diagram.webp'
  srcset:
    - url: 'https://static.wixstatic.com/media/def456/v1/fill/w_400,h_300/diagram.webp'
      width: 400
    - url: 'https://static.wixstatic.com/media/def456/v1/fill/w_800,h_600/diagram.webp'
      width: 800
    - url: 'https://static.wixstatic.com/media/def456/v1/fill/w_1200,h_900/diagram.webp'
      width: 1200
<script type="application/jay-headless" ...>
  contentDir: ./content
  mediaMap: ./media-map.yaml
</script>

When mediaMap is provided:

  • Mapped images use the external URL — no file copying, no local serving
  • Unmapped images fall back to layers 1+2
  • width and height from the mapping are added to the <img> tag (prevents CLS)
  • srcset variants produce responsive <img> with srcset and sizes attributes

Rendered output for a mapped image with srcset:

<img
  src="https://cdn.../w_800/flow.webp"
  srcset="
    https://cdn.../w_400/flow.webp   400w,
    https://cdn.../w_800/flow.webp   800w,
    https://cdn.../w_1200/flow.webp 1200w
  "
  sizes="(max-width: 600px) 400px, (max-width: 1024px) 800px, 1200px"
  width="800"
  height="600"
  loading="lazy"
  alt="Diagram"
/>

Without srcset (just src + dimensions):

<img src="https://cdn.../w_800/flow.webp" width="800" height="600" loading="lazy" alt="Diagram" />

Upload script responsibilities

The upload script is external tooling (not part of the markdown plugin). It:

  1. Scans the content directory for image files
  2. Reads original dimensions (e.g., using sharp or image-size)
  3. Uploads to CDN (e.g., wix-media API) requesting responsive variants at configured breakpoints
  4. Writes/updates the media-map.yaml file

The script decides what responsive widths to generate. Typical breakpoints: [400, 800, 1200]. For each image, it generates variants up to the original width (no upscaling).

Input configuration (script-specific, not part of the plugin):

# upload-config.yaml (example)
breakpoints: [400, 800, 1200]
format: webp
quality: 85

The markdown plugin only consumes the mapping file — it doesn't know or care how images were uploaded or what CDN is used.

Implementation Plan

Phase 1: Layers 1+2 (zero-config)

  1. parse-markdown.ts — add imageBaseUrl?: string option to createMarkedParser(). Override renderer.image to prepend the base URL to relative src values.
  2. markdown-pages.ts — in loadParams, scan contentDir for non-.md files and copy to public folder (read path from .jay config or default ./public). Compute imageBaseUrl from the content directory's public-relative path. Pass to parser in withSlowlyRender.

No new props needed. Works out of the box.

Phase 2: Layer 3 (media mapping with srcset)

  1. parse-markdown.ts — accept mediaMap option with type:
    Record<
      string,
      {
        src: string;
        width?: number;
        height?: number;
        srcset?: Array<{ url: string; width: number }>;
      }
    >;
    In renderer.image, check map first. If srcset entries exist, render responsive <img> with srcset and sizes. Fall back to base URL for unmapped images.
  2. markdown-pages.ts — if mediaMap prop is set, read and parse the YAML file, pass to parser. Skip file copying for mapped images.
  3. Contract — add optional mediaMap prop.

Phase 3: Agent-kit documentation

  1. agent-kit/designer/markdown-usage.md — document:
    • Default behavior (images auto-copied to public/, no config needed)
    • mediaMap prop for CDN hosting
    • Mapping file format (src, width, height, srcset)
    • Example with wix-media URLs
    • Note that upload scripts are external tooling

Trade-offs

Choice Pro Con
Zero-config default (copy to public/) Works immediately, no setup needed Duplicates files on disk
Mapping file replaces local copy CDN-optimized, responsive images Requires external upload script
srcset in mapping file Responsive images from markdown Script must generate multiple variants
sizes attribute generation Browser picks optimal image Default sizes may not match all layouts
Read .jay config for public folder Consistent with project setup Component depends on project config

Log Methodology Note

Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.