feat: @decocms/blocks/image (platform-neutral) with VTEX and Shopify image providers in their packages

TL
2h ago
+5618-4

Stacked on #693 (P2 of the v8 framework-packages plan). Reworked after the product owner's review: @decocms/blocks keeps framework code only, and nothing that knows VTEX, Shopify or another commerce platform.

What

@decocms/blocks/image (platform-neutral): pure functions, no React, no module state.

  • decoCdn(options): the Deco image CDN (https://<domain>/image?fit=&width=…&src=). Options: domain, quality (tier), fit (default cover, as v7's getSrcSet/Image), legacyHosts (default ["decoims.com"] on the Cloudflare CDNs), sources ("deco" default, "all" for v7's "everything else goes through the CDN", or a predicate), stripAssetHosts ("leading", the default since the PO's 2026-10-15 decision: v7 from 7.71.4; or "anywhere": v7 up to 7.71.3 and two storefronts' vendored helpers), layout ("src-last" or "quality-last").
  • createImageUrl({ providers }): one ImageUrl per site, with isOptimizable and hosts(). Providers are tried in order; a provider's format may return null to leave a source to the next one.
  • srcSet, parseSrcSet, preloadLink, and the generic types (ImageProvider, ImageUrl, ResizeOptions, Quality, Fit, …). srcSet without a width writes v7's two width=NaN … NaNw candidates, as v7's getSrcSet did for an Image or Source without a width (a storefront's pages have 12 such <source> tags; before this, they lost their srcset).

@decocms/apps-vtex/image: VTEX's URL formats as providers. They are pure helpers over the blocks types: no fetch, no React, no env reads, no runtime imports.

  • vtexCatalog(): v7's Image rule. VTEX hosts, {id}-{w}-{h ?? w}, URL re-serialised.
  • vtexCatalogFrame({ frame? }): any host, a fixed frame or the call's box, rounded.
  • vtexCatalogWebp({ quality? }): the next/image loaders' rule. {id}-{w}-auto/{file}.webp?…&quality= on VTEX's 1-10 scale.
  • vtexFileManager({ via: "query" | "thumbor", quality? }).

@decocms/apps-shopify/image: shopifyCdn(), v7's rule (width, height when given, crop=center).

A site lists the platform providers before decoCdn. The bindings never depend on an apps-* package; sites wire the helper in. v7's registerImageCdnDomain/registerImageQuality do not come back: they were module state. Their values are decoCdn's domain and quality.

Golden URLs: only the approved changes

The fixtures were regenerated by running each v8 storefront's own helpers, unchanged:

  • the three vendored v7 getOptimizedMediaUrl/getSrcSet variants
  • two next/image loaders
  • a fixed-frame catalog loader

The corpus grew to cover edge cases: unencoded characters, an asset host inside another URL, already-resized CDN URLs, uppercase hosts, odd id suffixes, store-domain catalog URLs, and fractional widths. The fixtures are split per package:

PackageCasesSources
@decocms/blocks1,508Deco hosts, other hosts, paths, data:, ""
@decocms/apps-vtex2,625catalog + file manager, composed with decoCdn
@decocms/apps-shopify144Shopify CDN, composed with decoCdn

Every case reproduces today's URL byte for byte, except 192 approved @decocms/blocks cases:

  • 160 for approved change 2 below (the two already-resized CDN sources, in every config);
  • 32 for approved change 3 (the source https://images.example.com/https://assets.decocache.com/…, on the two configs that keep decoCdn's default with sources: "all").

Each of those carries before (today's URL) and reason. The test asserts that no other case changed, and that each change-3 before is exactly what stripAssetHosts: "anywhere" still writes. The apps-vtex and apps-shopify fixtures have no changed case and still assert that none carries a before. Where a helper throws today, the case is recorded as <throws>, and the provider still throws (v7's VTEX rule on a URL that only mentions a VTEX host in its query). The fixtures use no site names. The generator stays out of the repo because it reads the sites' paths.

Approved changes (PO, 2026-10-14 and 2026-10-15)

The product owner approved 1 and 2 on 2026-10-14 ("the image fixes are good") and 3 on 2026-10-15:

  1. File-manager images on the next/image loader that still uses VTEX's ?width=&aspect=true resizer. That resizer has answered 500 for raster files since 2026-10-07. That store switches to vtexFileManager({ via: "thumbor" }), which the other loader already uses. This is a site change only; no package changes.

  2. An already-resized Deco CDN URL pasted into content (https://assets.decocms.com/image?…, or the decoims.com, deco-assets.edgedeco.com and deco-assets.decoazn.com endpoints). Today it becomes src=image, or the whole /image URL without its query, and the CDN cannot serve either one. decoCdn now re-resizes it from its src by default (c80dd337, narrowed in 6b7e7001). That covers an encoded src, a nested CDN URL, http and any host case. Resizing a result again gives the same URL. It applies only to the URLs decoCdn already resized (its domain and legacy hosts with the default sources: "deco"; every endpoint with sources: "all" or a predicate that takes it): a Deco CDN URL it passed through before, such as a deco-assets.edgedeco.com one under sources: "deco", still passes through unchanged, because it was served fine. Other URLs are unaffected, including a non-Deco /image path and an /image URL with no src. A differential fuzz against the previous commit (9 configs, 10 hosts, 6 path shapes) finds 108 changed URLs, every one of them src=image or src=<cdn>/image before.

  3. stripAssetHosts defaults to "leading" (afe430c8). An asset host (assets.decocache.com/, the S3 bucket) is removed only where the source starts with it, as v7's Image does since 7.71.4. Two storefronts' vendored helpers (and v7 up to 7.71.3) removed it anywhere, so a source that only contains an asset URL (https://other.host/https://assets.decocache.com/x.png) used to lose that part and now keeps it. Sources that start with an asset host, which is every one the storefronts' content has in the corpus, give the same URL either way. "anywhere" stays an option. Follow-up in the migrator (#700): stop emitting "anywhere" and count and report the URLs this changes per site.

Proposals for the PO (not decided, not in the code)

  1. A mobile app's VTEX helper copies disagree with each other. One drops the source's query; the other leaves a pre-sized -W-auto id at its baked size. The app does not use these packages, so it is out of the golden suite. Proposal: point it at one helper.

Nuvemshop's image helper stays in the store's own code (PO, 2026-10-14). There is no apps-nuvemshop package.

Checks

Decision 3 (afe430c8, on top of #693's decisions 1–3 merged in d654d9a4): bun run check, bun run build, skills:check and skills:readme:check pass, and plain Node imports all 24 built exports (deco --help runs). bun run test (2 GB): 2150 passed, 3 failed: DS-2 (fails on main too; fixed separately) and two schema.test.ts timeouts under full-suite load (58/58 alone). Image tests 48/48 across the three packages; reverting the default fails three of them.

Adversarial pass (6b7e7001): c80dd337 had also widened decoCdn's default match to every Deco CDN endpoint, which changed 168 URLs that used to pass through and were served fine; the match is back to the pre-fix sources, and a new unit test pins those pass-throughs. bun run check and bun run build pass; image tests 27/27; the golden fixtures are unchanged (160 approved cases). Full gates at the top of the stack (#700).

Fix 2 (c80dd337): bun run check, bun run build, skills:check and skills:readme:check pass. bun run test: 2133 passed, 2 failed: DS-2 (the known flake) and a schema.test.ts timeout under full-suite load, which passes 58/58 alone. The image tests pass 26/26, with 6 new fix-2 unit tests.

Review pass (2026-10-14): the srcSet fix above (ea0a6bf7). Its golden markup cases (v7's own components, no width) are in #700's tests/tanstack-image-markup.golden.test.ts. Full gates at the top of the stack (#700).

First round (before the PO review):

  • bun run check passes. bun run build passes and emits dist/v8/image.*, apps-vtex/dist/image.* and apps-shopify/dist/image.*. Plain Node imports the built @decocms/apps-vtex/image.
  • bun run test: 2124 passed, 4 failed:
    • DS-2 in delivery.cli.test.ts, the known flake.
    • Three CLI conformance timeouts under full-suite load (cli-02, qs-01/in-03, saved-03/saved-11). Run alone, those files pass 179/179.
  • bun run skills:check and bun run skills:readme:check pass.
  • Bundle tests:
    • @decocms/blocks/image bundles for a browser and for workerd with no Node built-ins and no React (esbuild).
    • New tests/upstream-helpers.guardrail.test.ts: every apps-* helper subpath gets the same bundle check, plus a purity check (no network, React, env reads or runtime imports). Its comment records that the PO asked for helper subpaths in apps-*.
  • CLAUDE.md now says where platform helpers live.

For the PRs above (#696, #697, #700): the API changed:

  • vtexCatalog/vtexFileManager/shopify are no longer in @decocms/blocks/image.
  • parse/ParsedImage and proxyOtherHosts are gone (use sources: "all").
  • @decocms/tanstack's default ImageUrl cannot include VTEX/Shopify, because it must not depend on apps-*.

Main refresh (2026-10-17, 0103f85f)

  • Merged P1 63b9e6d2 into this branch (normal merge, no conflicts), then merged up the stack to M2 #707.
  • Gates at the top of the stack (M2 5131a3c1): bun run check, bun run build, skills:check, skills:readme:check pass. bun run test (2 GB memory cap): 122 files pass, 1 skipped; 2,745 tests pass, 95 skipped, 0 fail. DS-2 now passes (fixed on main by #709); no timeouts this run.
  • CI on this PR's new head: all checks pass.

🤖 Generated with Claude Code
https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu