Claude Skill

add-images

Wire `@pracht/image`: the CLS-safe zero-runtime `<Image>`, the loader for your target (sharp endpoint, Cloudflare, Vercel, Netlify, passthrough), `?pracht` build-time imports with blur placeholders, prebuilt WebP variants, and the optimization endpoint's security settings. Use fo

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Download jovidecroock-pracht-skills_add-images-8bcdd52.zip · 4 KB

Install

skills CLI npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/add-images
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
Git git clone https://github.com/JoviDeCroock/pracht.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jovidecroock/pracht collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Pracht Add Images

@pracht/image splits the problem the way next/image does: the <Image> component decides which widths to render, a loader decides what URL serves each width (https://pracht.resynapse.dev/docs/images). The component renders a plain <img> — SSR-safe, zero hydration, works with hydration: "none".

Step 1: Choose the backend for the deployment target

MCP: when the pracht MCP server is registered (pracht dev-mcp), prefer its inspect_routes/inspect_build/doctor/verify tools over shelling out.

pracht inspect build --json   # adapterTarget (requires a prior `pracht build`)
Target Loader Notes
Node defaultLoader + the built-in endpoint needs sharp; put a CDN in front
Cloudflare cloudflareLoader Image Resizing must be enabled on the zone; sharp does not run on Workers
Vercel vercelLoader needs an images section in the project config; Vercel only serves widths listed in images.sizes
Netlify netlifyLoader Image CDN; remote sources need images.remote_images in netlify.toml; no sharp endpoint
Static host passthroughLoader no image service — srcset is omitted
Any target, no runtime service ?pracht&pracht-static imports prebuilt WebP variants, no loader involved

Confirm the choice with AskUserQuestion when the app deploys to more than one target — a per-target configureImage() behind an env flag is the usual answer (examples/basic does exactly this).

Step 2: Install

pnpm add @pracht/image
pnpm add sharp              # only for the built-in Node endpoint
pnpm add -D sharp           # only for `?pracht` imports / static variants

sharp stays an optional peer dependency; the vite plugin fails with an install hint when a ?pracht import needs it, and the endpoint answers 500 with the same hint at runtime.

Step 3: Configure the loader once

Put configureImage() where both the server and the browser evaluate it — the top of src/routes.ts is the canonical spot:

import { cloudflareLoader, configureImage } from "@pracht/image";

configureImage({
  loader: cloudflareLoader,
  // deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
  // imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  // quality: 75,
});

createDefaultLoader("/my/endpoint") builds a default-style loader for a custom endpoint path. Root-absolute endpoints pick up Vite's deploy base automatically; provider loaders (cloudflareLoader, vercelLoader, netlifyLoader) deliberately stay at the origin root.

For Netlify, use configureImage({ loader: import.meta.env.DEV ? passthroughLoader : netlifyLoader }) so pracht dev renders original images. Import both loaders from @pracht/image. For remote sources, merge the allowed URL patterns into the existing netlify.toml:

[images]
remote_images = ['https://images\.example\.com/.*']

Local source files need no image-service configuration; Netlify negotiates the output format and caches resized images. Skip the Node endpoint step.

Step 4: Render images

import { Image } from "@pracht/image";

<Image
  src="/banner.jpg"
  alt="Product banner"
  width={1200}
  height={280}
  sizes="(max-width: 1200px) 100vw, 1200px"
  priority
/>;
  • alt is required — use alt="" for decorative images.
  • width/height are required unless fill; they reserve space and prevent layout shift. Missing dimensions log a console.error in dev.
  • sizes (or fill) switches the srcset to w descriptors across deviceSizes; a fixed image gets 1x/2x candidates snapped to the breakpoint list so caches stay small.
  • priority on above-the-fold images swaps loading="lazy" + decoding="async" for loading="eager" + fetchpriority="high". Use it for the LCP image only.
  • fill positions the image absolutely inside the nearest positioned ancestor and defaults sizes to 100vw; pair with style={{ objectFit: "cover" }}.

Step 5: Build-time imports and static variants

Register the plugin — the main pracht() plugin does not include it:

// vite.config.ts
import { prachtImage } from "@pracht/image/vite";

export default { plugins: [prachtImage(), pracht({ /* … */ })] };
import hero from "./hero.jpg?pracht";                 // { src, width, height, blurDataURL }
import banner from "./banner.jpg?pracht&pracht-static"; // + prebuilt WebP variants

<Image src={hero} alt="Hero" placeholder="blur" />;
<Image src={banner} alt="Banner" sizes="(max-width: 960px) 100vw, 960px" />;
  • Dimensions come from sharp metadata with EXIF orientation applied, so a rotated portrait reports its display size.
  • blurDataURL is an 8px WebP data URI generated at build time. SVGs skip it, animated GIFs blur their first frame.
  • pracht-static emits content-hashed WebP files at staticWidths, takes precedence over the global loader, and is cached in Vite's cacheDir keyed by source bytes. It requires an absolute Vite base; SVG and animated sources pass through unchanged.
  • Root-relative publicDir imports keep stable public URLs and bypass the runtime loader.
  • Types: "types": ["@pracht/image/client"] in tsconfig, or a triple-slash reference in any .d.ts.

@pracht/markdown uses ?pracht&pracht-static automatically for relative Markdown images and renders them with getImageProps() — see /add-content.

Step 6: Mount the optimization endpoint (Node-compatible runtimes only)

// src/api/_pracht/image.ts
import { createImageHandler } from "@pracht/image/node";

const imageHandler = createImageHandler({
  // Required in every environment, including dev. Your own env var, not a
  // framework one — set it to the app's public origin and use the same value
  // as nodeAdapter({ canonicalOrigin }).
  localOrigin: process.env.PRACHT_ORIGIN,
  // remotePatterns: [{ protocol: "https", hostname: "*.example.com", pathname: "/uploads" }],
  // allowedWidths: [640, 1280],   // required when configureImage() customizes the breakpoints
});

export const GET = imageHandler;
export const HEAD = imageHandler;

That file maps to /api/_pracht/image, exactly what defaultLoader targets — no further configuration. The endpoint negotiates WebP via Accept (pass formats: ["image/avif", "image/webp"] for AVIF), never enlarges beyond the source, passes SVG/GIF through, and answers Cache-Control: public, max-age=14400, must-revalidate with Vary: Accept. It drops the query string from relative image files (/hero.png?v=2 → /hero.png), so if the app cache-busts or signs local images with a query parameter, keep it with sourceQueryParams: ["v"]. It also caches results in memory for no longer than cacheControl allows and encodes at most four at once (cache, maxConcurrency); a custom fetchImage turns the shared cache off unless cache is set.

Step 7: Verify

pracht dev            # set PRACHT_ORIGIN to the exact origin it prints
pracht build
pracht verify --json

Check in the browser: the <img> carries srcset/sizes, the LCP image is fetchpriority="high", and no layout shift occurs on reload. Then run /audit-bundles and /audit-seo if image work was part of a performance pass.

Rules

  1. Never leave localOrigin unset — relative sources are resolved only against it, so a forged Host cannot turn the endpoint into an open proxy. The handler answers 500 until it is configured, including in dev.
  2. Always allowlist remote hosts with remotePatterns; never proxy arbitrary URLs.
  3. When configureImage() customizes deviceSizes/imageSizes, pass the matching allowedWidths — the endpoint rejects widths outside its allowlist by design, and skipping this silently breaks the srcset.
  4. Do not use priority on more than the one above-the-fold image per route.
  5. placeholder="blur" writes an inline style attribute: a CSP without style-src-attr 'unsafe-inline' silently drops it (and fill positioning), and img-src must include data:. See https://pracht.resynapse.dev/docs/recipes/csp.
  6. Platform loaders generate URLs that only resolve on the deployed platform — fall back to passthroughLoader behind import.meta.env.DEV for local previews.
  7. Use an immutable one-year cacheControl only when every source URL is content-addressed.
  8. Never overwrite an existing vite.config.ts, image API route, or configureImage() call — diff first and confirm with AskUserQuestion.

$ARGUMENTS

Files (pracht)
  • SKILL.md 9 KB
    ---
    name: add-images
    version: 1.1.0
    description: |
      Wire `@pracht/image`: the CLS-safe zero-runtime `<Image>`, the loader for your
      target (sharp endpoint, Cloudflare, Vercel, Netlify, passthrough), `?pracht` build-time
      imports with blur placeholders, prebuilt WebP variants, and the optimization
      endpoint's security settings.
      Use for "add images", "optimize images", "responsive images", "next/image
      equivalent", "blur placeholders", "my images cause layout shift".
    allowed-tools:
      - Bash
      - Read
      - Write
      - Edit
      - Grep
      - Glob
      - AskUserQuestion
    ---
    
    # Pracht Add Images
    
    `@pracht/image` splits the problem the way next/image does: the `<Image>`
    component decides *which widths* to render, a loader decides *what URL* serves
    each width (<https://pracht.resynapse.dev/docs/images>). The component renders a plain `<img>` — SSR-safe,
    zero hydration, works with `hydration: "none"`.
    
    ## Step 1: Choose the backend for the deployment target
    
    MCP: when the pracht MCP server is registered (`pracht dev-mcp`), prefer its
    `inspect_routes`/`inspect_build`/`doctor`/`verify` tools over shelling out.
    
    ```bash
    pracht inspect build --json   # adapterTarget (requires a prior `pracht build`)
    ```
    
    | Target | Loader | Notes |
    | ------ | ------ | ----- |
    | Node | `defaultLoader` + the built-in endpoint | needs `sharp`; put a CDN in front |
    | Cloudflare | `cloudflareLoader` | Image Resizing must be enabled on the zone; sharp does not run on Workers |
    | Vercel | `vercelLoader` | needs an `images` section in the project config; Vercel only serves widths listed in `images.sizes` |
    | Netlify | `netlifyLoader` | Image CDN; remote sources need `images.remote_images` in `netlify.toml`; no sharp endpoint |
    | Static host | `passthroughLoader` | no image service — srcset is omitted |
    | Any target, no runtime service | `?pracht&pracht-static` imports | prebuilt WebP variants, no loader involved |
    
    Confirm the choice with `AskUserQuestion` when the app deploys to more than one
    target — a per-target `configureImage()` behind an env flag is the usual answer
    (`examples/basic` does exactly this).
    
    ## Step 2: Install
    
    ```bash
    pnpm add @pracht/image
    pnpm add sharp              # only for the built-in Node endpoint
    pnpm add -D sharp           # only for `?pracht` imports / static variants
    ```
    
    sharp stays an optional peer dependency; the vite plugin fails with an install
    hint when a `?pracht` import needs it, and the endpoint answers 500 with the
    same hint at runtime.
    
    ## Step 3: Configure the loader once
    
    Put `configureImage()` where both the server and the browser evaluate it — the
    top of `src/routes.ts` is the canonical spot:
    
    ```ts
    import { cloudflareLoader, configureImage } from "@pracht/image";
    
    configureImage({
      loader: cloudflareLoader,
      // deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
      // imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
      // quality: 75,
    });
    ```
    
    `createDefaultLoader("/my/endpoint")` builds a default-style loader for a custom
    endpoint path. Root-absolute endpoints pick up Vite's deploy `base`
    automatically; provider loaders (`cloudflareLoader`, `vercelLoader`, `netlifyLoader`)
    deliberately stay at the origin root.
    
    For Netlify, use `configureImage({ loader: import.meta.env.DEV ?
    passthroughLoader : netlifyLoader })` so `pracht dev` renders original images.
    Import both loaders from `@pracht/image`. For remote sources, merge the
    allowed URL patterns into the existing `netlify.toml`:
    
    ```toml
    [images]
    remote_images = ['https://images\.example\.com/.*']
    ```
    
    Local source files need no image-service configuration; Netlify negotiates
    the output format and caches resized images. Skip the Node endpoint step.
    
    ## Step 4: Render images
    
    ```tsx
    import { Image } from "@pracht/image";
    
    <Image
      src="/banner.jpg"
      alt="Product banner"
      width={1200}
      height={280}
      sizes="(max-width: 1200px) 100vw, 1200px"
      priority
    />;
    ```
    
    - `alt` is required — use `alt=""` for decorative images.
    - `width`/`height` are required unless `fill`; they reserve space and prevent
      layout shift. Missing dimensions log a `console.error` in dev.
    - `sizes` (or `fill`) switches the srcset to `w` descriptors across
      `deviceSizes`; a fixed image gets `1x`/`2x` candidates snapped to the
      breakpoint list so caches stay small.
    - `priority` on above-the-fold images swaps `loading="lazy"` +
      `decoding="async"` for `loading="eager"` + `fetchpriority="high"`. Use it for
      the LCP image only.
    - `fill` positions the image absolutely inside the nearest positioned ancestor
      and defaults `sizes` to `100vw`; pair with `style={{ objectFit: "cover" }}`.
    
    ## Step 5: Build-time imports and static variants
    
    Register the plugin — the main `pracht()` plugin does **not** include it:
    
    ```ts
    // vite.config.ts
    import { prachtImage } from "@pracht/image/vite";
    
    export default { plugins: [prachtImage(), pracht({ /* … */ })] };
    ```
    
    ```tsx
    import hero from "./hero.jpg?pracht";                 // { src, width, height, blurDataURL }
    import banner from "./banner.jpg?pracht&pracht-static"; // + prebuilt WebP variants
    
    <Image src={hero} alt="Hero" placeholder="blur" />;
    <Image src={banner} alt="Banner" sizes="(max-width: 960px) 100vw, 960px" />;
    ```
    
    - Dimensions come from sharp metadata with EXIF orientation applied, so a
      rotated portrait reports its *display* size.
    - `blurDataURL` is an 8px WebP data URI generated at build time. SVGs skip it,
      animated GIFs blur their first frame.
    - `pracht-static` emits content-hashed WebP files at `staticWidths`, takes
      precedence over the global loader, and is cached in Vite's `cacheDir` keyed by
      source bytes. It requires an **absolute** Vite `base`; SVG and animated
      sources pass through unchanged.
    - Root-relative `publicDir` imports keep stable public URLs and bypass the
      runtime loader.
    - Types: `"types": ["@pracht/image/client"]` in tsconfig, or a triple-slash
      reference in any `.d.ts`.
    
    `@pracht/markdown` uses `?pracht&pracht-static` automatically for relative
    Markdown images and renders them with `getImageProps()` — see `/add-content`.
    
    ## Step 6: Mount the optimization endpoint (Node-compatible runtimes only)
    
    ```ts
    // src/api/_pracht/image.ts
    import { createImageHandler } from "@pracht/image/node";
    
    const imageHandler = createImageHandler({
      // Required in every environment, including dev. Your own env var, not a
      // framework one — set it to the app's public origin and use the same value
      // as nodeAdapter({ canonicalOrigin }).
      localOrigin: process.env.PRACHT_ORIGIN,
      // remotePatterns: [{ protocol: "https", hostname: "*.example.com", pathname: "/uploads" }],
      // allowedWidths: [640, 1280],   // required when configureImage() customizes the breakpoints
    });
    
    export const GET = imageHandler;
    export const HEAD = imageHandler;
    ```
    
    That file maps to `/api/_pracht/image`, exactly what `defaultLoader` targets —
    no further configuration. The endpoint negotiates WebP via `Accept` (pass
    `formats: ["image/avif", "image/webp"]` for AVIF), never enlarges beyond the
    source, passes SVG/GIF through, and answers
    `Cache-Control: public, max-age=14400, must-revalidate` with `Vary: Accept`.
    It drops the query string from relative image files (`/hero.png?v=2` →
    `/hero.png`), so if the app cache-busts or signs local images with a query
    parameter, keep it with `sourceQueryParams: ["v"]`. It also caches results in
    memory for no longer than `cacheControl` allows and encodes at most four at
    once (`cache`, `maxConcurrency`); a custom `fetchImage` turns the shared cache
    off unless `cache` is set.
    
    ## Step 7: Verify
    
    ```bash
    pracht dev            # set PRACHT_ORIGIN to the exact origin it prints
    pracht build
    pracht verify --json
    ```
    
    Check in the browser: the `<img>` carries `srcset`/`sizes`, the LCP image is
    `fetchpriority="high"`, and no layout shift occurs on reload. Then run
    `/audit-bundles` and `/audit-seo` if image work was part of a performance pass.
    
    ## Rules
    
    1. Never leave `localOrigin` unset — relative sources are resolved only against
       it, so a forged `Host` cannot turn the endpoint into an open proxy. The
       handler answers 500 until it is configured, including in dev.
    2. Always allowlist remote hosts with `remotePatterns`; never proxy arbitrary
       URLs.
    3. When `configureImage()` customizes `deviceSizes`/`imageSizes`, pass the
       matching `allowedWidths` — the endpoint rejects widths outside its allowlist
       by design, and skipping this silently breaks the srcset.
    4. Do not use `priority` on more than the one above-the-fold image per route.
    5. `placeholder="blur"` writes an inline `style` attribute: a CSP without
       `style-src-attr 'unsafe-inline'` silently drops it (and `fill` positioning),
       and `img-src` must include `data:`. See <https://pracht.resynapse.dev/docs/recipes/csp>.
    6. Platform loaders generate URLs that only resolve on the deployed platform —
       fall back to `passthroughLoader` behind `import.meta.env.DEV` for local
       previews.
    7. Use an immutable one-year `cacheControl` only when every source URL is
       content-addressed.
    8. Never overwrite an existing `vite.config.ts`, image API route, or
       `configureImage()` call — diff first and confirm with `AskUserQuestion`.
    
    $ARGUMENTS
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.