diff --git a/.agents/skills/README.md b/.agents/skills/README.md index 0d6d64508..a76ce35b5 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -48,7 +48,7 @@ skill, not the row. | Skill | What it covers | |---|---| -| [`deco-v7-to-v8-migration/`](deco-v7-to-v8-migration/SKILL.md) | @decocms/blocks 8 (the next major): moves a v7 Deco site (@decocms/blocks 7.x with @decocms/tanstack or @decocms/nextjs, @decocms/blocks-admin, @decocms/blocks-cli, v7 @decocms/apps-*) onto @decocms/blocks 8 and the thin @decocms/apps-* clients. Runs the one-time migration script (scripts/main.ts: block map with legacy aliases, vendored app loaders, content in .deco/blocks, secret re-encryption, import report), then guides the manual work it reports. Use when a site's package.json depends on @decocms/blocks ^7, @decocms/tanstack, @decocms/nextjs or @decocms/blocks-admin and should move to v8; when the report lists /deco/invoke calls, createDecoWorkerEntry, RequestContext, useCart or other v7 imports to replace; when deco check rejects migrated content; or when a migrated page, draft preview, editor form or parity run differs from v7. Replaces the unpublished @decocms/blocks-migrate package. | +| [`deco-v7-to-v8-migration/`](deco-v7-to-v8-migration/SKILL.md) | @decocms/blocks 8 (the next major): moves a v7 Deco site (@decocms/blocks 7.x with @decocms/tanstack or @decocms/nextjs, @decocms/blocks-admin, @decocms/blocks-cli, v7 @decocms/apps-*) onto @decocms/blocks 8, the v8 bindings (@decocms/tanstack 8, @decocms/nextjs 8) and the thin @decocms/apps-* clients. Runs the one-time migration script (scripts/main.ts: block map with legacy aliases, vendored app loaders, content in .deco/blocks, secret re-encryption, import report), then guides the manual work it reports. Use when a site's package.json depends on @decocms/blocks ^7, @decocms/tanstack, @decocms/nextjs or @decocms/blocks-admin and should move to v8; when the report lists /deco/invoke calls, createDecoWorkerEntry, RequestContext, useCart or other v7 imports to replace; when deco check rejects migrated content; or when a migrated page, draft preview, editor form or parity run differs from v7. Replaces the unpublished @decocms/blocks-migrate package. | _1 skill(s). This table is generated by `scripts/skills-readme.ts` — edit the skill, not the row._ diff --git a/.agents/skills/deco-v7-to-v8-migration/SKILL.md b/.agents/skills/deco-v7-to-v8-migration/SKILL.md index 4d578a225..22fc9af9d 100644 --- a/.agents/skills/deco-v7-to-v8-migration/SKILL.md +++ b/.agents/skills/deco-v7-to-v8-migration/SKILL.md @@ -1,11 +1,11 @@ --- name: deco-v7-to-v8-migration -description: "@decocms/blocks 8 (the next major): moves a v7 Deco site (@decocms/blocks 7.x with @decocms/tanstack or @decocms/nextjs, @decocms/blocks-admin, @decocms/blocks-cli, v7 @decocms/apps-*) onto @decocms/blocks 8 and the thin @decocms/apps-* clients. Runs the one-time migration script (scripts/main.ts: block map with legacy aliases, vendored app loaders, content in .deco/blocks, secret re-encryption, import report), then guides the manual work it reports. Use when a site's package.json depends on @decocms/blocks ^7, @decocms/tanstack, @decocms/nextjs or @decocms/blocks-admin and should move to v8; when the report lists /deco/invoke calls, createDecoWorkerEntry, RequestContext, useCart or other v7 imports to replace; when deco check rejects migrated content; or when a migrated page, draft preview, editor form or parity run differs from v7. Replaces the unpublished @decocms/blocks-migrate package." +description: "@decocms/blocks 8 (the next major): moves a v7 Deco site (@decocms/blocks 7.x with @decocms/tanstack or @decocms/nextjs, @decocms/blocks-admin, @decocms/blocks-cli, v7 @decocms/apps-*) onto @decocms/blocks 8, the v8 bindings (@decocms/tanstack 8, @decocms/nextjs 8) and the thin @decocms/apps-* clients. Runs the one-time migration script (scripts/main.ts: block map with legacy aliases, vendored app loaders, content in .deco/blocks, secret re-encryption, import report), then guides the manual work it reports. Use when a site's package.json depends on @decocms/blocks ^7, @decocms/tanstack, @decocms/nextjs or @decocms/blocks-admin and should move to v8; when the report lists /deco/invoke calls, createDecoWorkerEntry, RequestContext, useCart or other v7 imports to replace; when deco check rejects migrated content; or when a migrated page, draft preview, editor form or parity run differs from v7. Replaces the unpublished @decocms/blocks-migrate package." --- # Deco v7 → v8 Migration -Moves a v7 site onto the next major in two parts: a script that does the mechanical, content-safe part in one pass, and a list of manual steps it prints. Proven on `deco-sites/storefront-tanstack` (Shopify, TanStack Start), `deco-sites/demo-storefront` (Shopify, TanStack Start, a redesign of storefront-tanstack's v7 tree), `deco-sites/blog-tanstack` (TanStack Start), two Next.js App Router storefronts on VTEX (ejected FastStore), a TanStack Start storefront on VTEX and a non-ejected FastStore storefront on VTEX (hundreds of saved blocks, private). +Moves a v7 site onto the next major in two parts: a script that does the mechanical, content-safe part in one pass, and a list of manual steps it prints. Proven on `deco-sites/storefront-tanstack`, `deco-sites/demo-storefront` (a redesign of storefront-tanstack's v7 tree) and `deco-sites/blog-tanstack` (TanStack Start), plus four private storefronts: two Next.js App Router (ejected FastStore), one TanStack Start and one non-ejected FastStore, all on VTEX. The spec is the docs page **Migrating from v7** (`/next/renames-and-migrations`). When this skill and the docs disagree, the docs win. @@ -22,14 +22,14 @@ Not for sites still on `@decocms/start@6.x` (upgrade to 7.x first with `decocms- 1. A clean working tree: the script edits files in place. 2. `DECO_CRYPTO_KEY` (the v7 secret key) in the environment, and `.deco/secrets.pub` committed (see `/next/secrets`). Without them, v7 secrets are reported, not re-encrypted. -3. **Install `@decocms/blocks@^8.1` before running the script.** Not `^8`: the `8.0.0` on npm is an accidental v7 build with no `/cli`. A v7 site already has `@decocms/blocks@7`, which lacks the `/cli` and `/protocol/keys` subpaths the script imports. Keep the v7 `@decocms/apps-*` packages installed until the script has run: it vendors the app loaders your content calls from `node_modules`. -4. `typescript` resolvable from the site (the import codemod parses `src/` with it). Every TanStack and Next site already has it. +3. **Install `@decocms/blocks@^8.3` before running the script** (not `^8`: `8.0.0` is an accidental v7 build). v7's blocks lacks the `/cli` and `/protocol/keys` the script imports. Keep the v7 `@decocms/apps-*` installed until the script has run: it vendors the app loaders your content calls from `node_modules`. +4. `typescript` resolvable from the site (the import codemod parses `src/` with it). ## Run The script is `scripts/main.ts` in this skill. It resolves `@decocms/blocks` relative to its own files, so run it where that resolves to v8. -**From a `decocms/blocks` checkout on the v8 line** (clone it and run `bun install` there first; the script resolves `@decocms/blocks` from that checkout's `node_modules`): +**From a `decocms/blocks` checkout on the v8 line** (run `bun install` there first): ```bash DECO_CRYPTO_KEY=… bun /.agents/skills/deco-v7-to-v8-migration/scripts/main.ts --root @@ -39,7 +39,7 @@ DECO_CRYPTO_KEY=… bun /.agents/skills/deco-v7-to-v8-migration/scripts/ **From an installed copy of the skill** (outside the repo): copy `scripts/` into the site so it resolves the site's `@decocms/blocks@8`, run it, then delete the copy: ```bash -cd && bun add @decocms/blocks@^8.1 +cd && bun add @decocms/blocks@^8.3 cp -R /scripts .deco-migrate DECO_CRYPTO_KEY=… bun .deco-migrate/main.ts --root . rm -rf .deco-migrate @@ -54,7 +54,7 @@ In order: **content** (and site **settings** folded into `.deco/blocks/CMS.json` Commit the script's output unedited as its own commit (`chore: run the v7-to-v8 migration`), so reviewers can tell generated changes from hand-written ones. -**A sibling site already migrated?** When the site shares its v7 tree with one that is already on v8 (a fork, a redesign of a template), port the sibling's v8 changes with a three-way merge per file instead of by hand: `git merge-file `. Most files merge cleanly; conflicts are mostly import lines. Then check what the merge brought that isn't the migration ([`reference/sibling-port.md`](reference/sibling-port.md)). +**A sibling site already migrated?** When the site shares its v7 tree with one that is already on v8 (a fork, a redesign of a template), port the sibling's v8 changes with a three-way merge per file instead of by hand: `git merge-file `. Conflicts are mostly import lines. Then check what the merge brought that isn't the migration ([`reference/sibling-port.md`](reference/sibling-port.md)). ## Review the report @@ -64,9 +64,9 @@ The script prints **Done** and **Left to do**, grouped by step. Every "Left to d The full text of each step: [`reference/manual-steps.md`](reference/manual-steps.md). -1. **Dependencies**: `@decocms/blocks@^8.1` plus the v8 `@decocms/apps-` client; remove the v7 packages and codegen once nothing imports them. -2. **Render pages with `createCMS`** (`/next/tanstack-start-descriptors`, `/next/nextjs`); delete the v7 setup files, admin routes, `/deco/*` handlers and v7 CI jobs. -3. **Move framework code into the site** ([`reference/framework-code.md`](reference/framework-code.md)). +1. **Dependencies**: `@decocms/blocks@^8.3`, the v8 binding at the same version (`@decocms/tanstack` or `@decocms/nextjs`) and the v8 `@decocms/apps-` client; remove the other v7 packages and codegen once nothing imports them. +2. **Render pages with `createCMS` on the binding** (`/next/tanstack-start-descriptors`, `/next/nextjs`); delete the v7 setup files, admin routes, `/deco/*` handlers and v7 CI jobs. +3. **Worker, drafts, Studio and images from the binding (platform image URLs: `@decocms/apps-*/image`); the rest is site code** ([`reference/framework-code.md`](reference/framework-code.md)). 4. **Replace `/deco/invoke`** with one server function per loader or action the browser called ([`reference/server-functions.md`](reference/server-functions.md)). 5. **Fix the content `deco check` rejects** ([`reference/content.md`](reference/content.md)). 6. **Telemetry, analytics and previews** ([`reference/telemetry.md`](reference/telemetry.md)). @@ -82,8 +82,9 @@ Then the rest of [`reference/verify.md`](reference/verify.md): `"blocksMajor": 8 ## DO NOT -- Add compat shims that re-create v7 APIs (`useCart` from a package, `invoke`, `RequestContext`, a fake `@decocms/tanstack`). What v7's packages did is site code now; copy it in and own it. A site-local `invoke` object with v7's paths, or a `runInvoke("site/loaders/…")` dispatcher, is the same shim. -- Strip `"use client"` from components that need hooks, events or browser APIs to make them render. +- Add compat shims that re-create v7 APIs (`useCart` from a package, `invoke`, `RequestContext`). What v7's packages did and the bindings don't is site code now; copy it in and own it. +- Copy v7's worker entry, draft middleware, Studio bridge, preview badge or `Image` into the site: `@decocms/tanstack` and `@decocms/nextjs` ship them. A site-local `invoke` object with v7's paths, or a `runInvoke("site/loaders/…")` dispatcher, is the same shim. +- Strip `"use client"` from hook, event or browser-API components to render them. - Rename saved type names. Content stores them; keep the v7 name as an alias (`/next/renames-and-migrations#rename-a-type-with-an-alias`). - Hand-edit the script's output in the same commit as the run. @@ -108,10 +109,10 @@ What the site migrations taught, one subject per file. Commit hashes refer to `d | [`running-the-script.md`](reference/running-the-script.md) | Before the run (install order, vendoring, stale lockfiles), dependencies after | | [`sibling-port.md`](reference/sibling-port.md) | What a three-way merge from a sibling site brings along | | [`rendering.md`](reference/rendering.md) | No async rendering, streaming and hydration, awaited blocks, section wrappers, Tailwind | -| [`framework-code.md`](reference/framework-code.md) | Edge cache, image, SEO, devices, cookies, commerce code, error reporting | +| [`framework-code.md`](reference/framework-code.md) | The bindings (worker, drafts, Studio, images); SEO, devices, cookies, commerce code, error reporting | | [`server-functions.md`](reference/server-functions.md) | `/deco/invoke` → server functions | | [`caching-and-privacy.md`](reference/caching-and-privacy.md) | Per-visitor data, QueryClient scope, draft bypass, hydration waits | -| [`nextjs.md`](reference/nextjs.md) | Next.js App Router: transpilePackages, drafts on static pages, the proxy, `list('page')`, head order, Jest | +| [`nextjs.md`](reference/nextjs.md) | Next.js App Router: `@decocms/nextjs` drafts and proxy, `list('page')`, head order, Jest | | [`hosted-releases.md`](reference/hosted-releases.md) | Hosted CMS instead of fast deploy, bundled content, v7 leftovers to delete | | [`content.md`](reference/content.md) | `deco check` as a deploy gate, content it rejects, editing locally with `deco serve` | | [`telemetry.md`](reference/telemetry.md) | Telemetry params, resource attributes, dropped v7 bindings, analytics | diff --git a/.agents/skills/deco-v7-to-v8-migration/reference/caching-and-privacy.md b/.agents/skills/deco-v7-to-v8-migration/reference/caching-and-privacy.md index ba0b8900c..1cd97443e 100644 --- a/.agents/skills/deco-v7-to-v8-migration/reference/caching-and-privacy.md +++ b/.agents/skills/deco-v7-to-v8-migration/reference/caching-and-privacy.md @@ -4,7 +4,7 @@ From the site migrations; the sites and what the commit hashes refer to are list - **Keep per-visitor data out of the edge cache** (`844ce9f`): cache a GET server function only when its payload is page data; cart, user, wishlist, addresses answer `Cache-Control: private, no-store`, and the cache wrapper never stores such a response. Detect signed-in shoppers by the cookie the sign-in flow actually sets. - **One QueryClient per router**, never per isolate: a shared one leaks the previous request's cart and user. -- v7's admin paths (`/deco/*`, `/live/*`, `/.decofile`) should answer 404, not a cacheable page. -- Read drafts with `await cms.draftPointer(request)`; it ignores drafts on hosts outside `preview.hosts` (the `CMS` block, capped by `createCMS({ preview })`), so list your staging and dev hosts there. -- **Gate the copied worker entry's draft bypass on the pointer too.** v7's edge cache skipped a request with `?__draft=` or the draft cookie only on an allowed preview host. A copy that checks the raw param or cookie lets anyone skip the edge cache on production (`?__draft=x`), so every such request reaches the worker and the commerce API. Compute `const previewing = (await cms.draftPointer(request)) !== null` once per request and use it wherever the copy checked for a draft. +- v7's admin paths (`/deco/*`, `/live/*`, `/.decofile`) should answer 404, not a cacheable page. `createDecoWorker` does; on Next.js, check them in the parity run (`hosted-releases.md`). +- **The draft decision is the binding's.** v7's edge cache skipped a request with `?__draft=` or the draft cookie only on an allowed preview host; a hand-copied worker that checks the raw param lets anyone skip the cache on production (`?__draft=x`). `createDecoWorker` decides once per request with v7's allowlist, skips the cache only for a draft it accepted, and hands the page that draft (`contentClient(cms)`); on Next.js, `decideDraft` in the proxy does the same. Don't add a second draft check of your own. +- **A signed-in shopper never shares a cached page**: return `"private"` from `createDecoWorker`'s `segment` for the cookie the sign-in flow actually sets; a server function answering per-visitor data calls `markPrivate()` (`@decocms/tanstack/server`). - **Don't let hydration wait on a cross-origin call.** Prefetching the shopper's profile in the router's `hydrate` so widgets hydrate with it keeps v7's pixels, but `hydrate` is awaited: a slow or hung gateway keeps the page from ever becoming interactive. Cap the wait (a few hundred milliseconds) or don't await it. A parity harness replays upstream calls, so it never shows this cost. diff --git a/.agents/skills/deco-v7-to-v8-migration/reference/framework-code.md b/.agents/skills/deco-v7-to-v8-migration/reference/framework-code.md index 26633684c..b6ec5640a 100644 --- a/.agents/skills/deco-v7-to-v8-migration/reference/framework-code.md +++ b/.agents/skills/deco-v7-to-v8-migration/reference/framework-code.md @@ -1,19 +1,66 @@ -# Framework code moves into the site +# Framework code: the v8 bindings, and what stays site code From the site migrations; the sites and what the commit hashes refer to are listed in `SKILL.md` ("Gotchas by subject"). -v8 has no framework binding and its apps are thin clients. What `@decocms/tanstack`, `@decocms/blocks` 7 and `@decocms/apps-*` did for the site becomes site code, copied with its v7 output (`21addb9`, `0512e60`): +v8 keeps the framework layer in two bindings, released with `@decocms/blocks` at one version (`^8.3.0`): `@decocms/tanstack` (TanStack Start on Cloudflare Workers, deployed the Deco way) and `@decocms/nextjs` (any Next.js App Router app). Use them; don't copy v7's worker, draft, preview or image code into the site. What neither binding covers (commerce flows, SEO, device-specific UI) is site code. Each package's README has the full wiring; `examples/tanstack-worker-smoke` and `examples/nextjs-binding-smoke` in this repo are complete apps. + +## TanStack Start (`@decocms/tanstack`) + +- **Worker entry**: `createDecoWorker({ serverEntry, cms, site, buildHash, dev })` from `/worker` replaces v7's `createDecoWorkerEntry` and everything a copied entry carried. Built in: `/deco/_liveness`, 404 for v7's admin paths, drafts (the host allowlist, the cookie, no-store and noindex, only a real draft skips the cache), CMS redirects before any route, the edge cache keyed by build, content revision, device and `segment` (stale-while-revalidate, stale-if-error), `__cseg` CDN caching of navigation data, Studio's `frame-ancestors`, one `Set-Cookie` per cookie, `x-page-status`. A site states only what differs: `segment` (region, sales channel, `"private"` for a signed-in shopper), `csp`, `cacheProfiles`, `safeCookies`, `preload`, `purgeToken`, `previewHosts`, `onCache`, and `routes` for requests it answers itself (a commerce proxy, a sitemap). Code the site keeps (geo cookies, a v7 request context) wraps the worker's `fetch`; it needs no new option. +- **Build hash**: `decoTanstack({ buildHash: })` from `/vite` (CI sha, then git, then the time) defines `__BUILD_HASH__` and includes `decoContent()` from `@decocms/blocks/vite`. The package reads no env: the site passes the CI value. +- **Root document**: `DecoDocument` from `/react` (`site`, `lang`, `nonce`, `editShortcut`) mounts `StudioBridge` and `DraftBadge` from `@decocms/blocks/preview`, the events bus, `NavigationProgress` and `StableOutlet`. A layout it can't express (a skip link before `
`) keeps its own document and renders `` from `/react` in its ``: the bridge, the draft badge and the draft they read (`StudioBridge` alone leaves draft pages without the badge). +- **Studio**: the page loader returns `pathTemplate: match.entry.path` (the page `matchRoute` matched) in its loader data, so "." on the store page opens that page in Studio's editor, as v7's `cmsRoute` did with `path`. Each page section renders in v7's wrapper, `
`: Studio's editor outlines, selects and scrolls to those. +- **Router and server functions**: `createDecoRouter` (`/router`) keeps `?q=`; a router that needs TanStack's `hydrate` builds its own router with `decoParseSearch`/`decoStringifySearch`. `createStart(() => ({ serverFns: { fetch: decoServerFnFetch } }))` (`/start`). The worker decides the draft, so delete the draft middleware in `start.ts`; pages read it with `contentClient(cms)` (`/server`). +- **Image**: `Image`, `Picture` and `Source` from `/react` render v7's markup with v7's props (the script moves those imports). The binding knows no commerce platform, so give them the site's own URLs with `createDecoRouter({ routeTree, Wrap: ({ children }) => {children} })`, where `imageUrl = createImageUrl({ providers: [vtexCatalog(), shopifyCdn(), decoCdn({ domain, quality, sources: "all", ...byRelease })] })`: `createImageUrl` and `decoCdn` from `@decocms/blocks/image`, `vtexCatalog` from `@decocms/apps-vtex/image`, `shopifyCdn` from `@decocms/apps-shopify/image`. Keep both helpers whatever the site's platform: v7 resized VTEX and Shopify images on every site, so dropping one changes those URLs. A store with Nuvemshop images (`*.mitiendanube.com/stores/…/products/…-{w}-{h}.{ext}`) also lists its own provider before `decoCdn`, since no `@decocms/apps-*` package covers Nuvemshop (see below). That is v7's `Image` byte for byte, an `Image` or `Source` without a width included (`width=NaN … NaNw`, as v7 wrote it); `domain` and `quality` are what the site passed to `registerImageCdnDomain`/`registerImageQuality`, else left out. v7's `Image` changed across 7.x releases; `byRelease` is, for the `@decocms/blocks` version the site ran (the script's report spells the whole line for it): + + | Site's 7.x | `decoCdn` options besides `domain`, `quality`, `sources: "all"` | + |---|---| + | before 7.71.0 | `legacyHosts: []`; `domain` defaults to `"decoims.com"` there | + | 7.71.0 to 7.71.3 | `legacyHosts: []` | + | 7.71.4 | `legacyHosts: ["decoims.com"]` | + | 7.71.5 on | none (and, from 7.77.1, the Nuvemshop provider below for a store with Nuvemshop images) | + + `stripAssetHosts` stays at its default, `"leading"`, on every row. Before 7.71.4, v7 stripped the asset hosts anywhere in a source; the switch is approved (PO, 2026-10-15) and changes only a source that merely contains an asset URL. The report's "image URLs" line lists those URLs for the PR. + + A site that kept its own copy of v7's image helper (a file under `src/` that defines `getOptimizedMediaUrl`) keeps that copy's rules, not its release's: the script reads the options from the copy and from the site's `registerImageCdnDomain`/`registerImageQuality` calls, and reports them on the copy's own line (fuzzed against each release's `Image.tsx` and two storefronts' edited copies). A copy whose rules it can't read is reported for you to compare by hand. One approved change from v7 (PO, 2026-10-14): an already-resized Deco CDN URL in the content (`https://assets.decocms.com/image?…&src=…`, or the `decoims.com`, `deco-assets.edgedeco.com` and `deco-assets.decoazn.com` endpoints) is re-resized from its `src`. v7 wrote `src=image`, which the CDN can't serve. Without a provider every source goes through the Deco CDN, VTEX and Shopify images too. Check a page's image URLs against v7's. v7's Nuvemshop rule, for the site's `src/imageUrl.ts` (`tests/tanstack-image-markup.golden.test.ts` holds it to v7's markup): + + ```ts + const NUVEMSHOP_IMAGE = /^(https:\/\/[a-z0-9-]+\.mitiendanube\.com\/stores\/.+\/products\/.+)-\d+-\d+\.(\w+)$/; + const NUVEMSHOP_SIZES = [50, 100, 240, 320, 480, 640]; + const nuvemshop: ImageProvider = { + name: "nuvemshop", + match: (src) => NUVEMSHOP_IMAGE.test(src), + format(src, { width }) { + const m = src.match(NUVEMSHOP_IMAGE); + if (!m) return null; + const size = NUVEMSHOP_SIZES.find((s) => s >= width); + return `${m[1]}-${size ? `${size}-0` : "1024-1024"}.${m[2]}`; + }, + }; + // providers: [vtexCatalog(), shopifyCdn(), nuvemshop, decoCdn({ domain, quality, sources: "all" })] + ``` +- **Draft names and Studio's origins**: `DRAFT_PARAM`, `DRAFT_COOKIE`, `DRAFT_HEADER`, `draftResponseHeaders`, `STUDIO_ORIGINS`, `STUDIO_FRAME_ANCESTORS` and `isStudioOrigin` come from the root, `@decocms/tanstack` (not from `@decocms/blocks`); the script moves them. +- **Cache metrics**: v7's `DECO_METRICS` writes have no writer; `onCache` (hit/miss/bypass, page type, duration) is the hook to send them anywhere (`telemetry.md`). + +## Next.js (`@decocms/nextjs`) + +- **Drafts**: `decideDraft`, `draftRequestHeaders`, `rewriteToDraftRoute`, `applyDraft` and `isDraftRoutePath` from `/proxy` in `proxy.ts` (or `draftProxy` for a site with no proxy of its own); `bindDraft(cms, { searchParams, site })` and `contentClient(cms)` from `/server` on the `/_draft` route and in a layout that reads content. Pass the same `{ site, previewHosts }` to both. Details and gotchas: `nextjs.md`. +- **Studio and the badge**: `` in the root layout and `` on the draft route, from `@decocms/nextjs`. Each CMS page renders `` (a `` React puts in the head), so "." opens that page in Studio; the root layout stays across navigations and can't know it. Studio's editor outlines, selects and scrolls to top-level `
` elements, v7's `SectionRenderer` wrapper: a site whose section list renders its own wrapper (v7 storefronts on Next often did) makes that wrapper a `
` with those two attributes. +- **next.config**: `withDeco(config)` from `/config` puts v7's draft header rules first and adds Studio's dev origins; it adds no rewrites (v7's `/deco/*` handlers are gone) and no `transpilePackages`. +- **Image**: `createDecoImageLoader(imageUrl, { fallback: nextImageOptimizer })` from `/image-loader` as the `loaderFile`, over the site's `createImageUrl`. The loader knows no platform: a VTEX store lists `vtexCatalogWebp()` and `vtexFileManager({ via })` from `@decocms/apps-vtex/image` (`via` is the resizer its loader uses today: `"query"` or `"thumbor"`), then `decoCdn({ quality: "high", fit: "contain", layout: "quality-last" })` from `@decocms/blocks/image`. URLs match the site's current loader byte for byte, except the approved re-resizing of an already-resized Deco CDN URL (above). A loader still on `via: "query"` should move to `"thumbor"`: VTEX's `?width=&aspect=true` resizer answers 500 for raster files since 2026-10-07 (PO-approved, a site change). +- **Draft names and Studio's origins**: from the root, `@decocms/nextjs` (its own copy; never `@decocms/tanstack` or `@decocms/blocks`). +- **Dev**: nothing to pass. `createCMS` reads `NODE_ENV`, so `next dev` keeps local content and `next start` serves the hosted release; drop a v7 `isNextDev`/`dev` line (the `dev` option still overrides it when a site needs to). + +## Site code either way -- **Edge cache**: the worker entry owns cache profiles, `Cache-Control`, the Cache API with SWR/SIE, split by device/login/region, keyed by deployment. Cache GET server functions (client-side navigation data) like pages; POST server functions keep their own headers (blog `59cb74f`). Restore `/deco/_liveness` if monitoring uses it. -- **Image**: copy v7's `Image` so CDN URLs stay identical. - **SEO/head**: a head builder; SEO blocks return the page's SEO. -- **Device detection**: v7's user-agent patterns in `src/sdk/device.ts`. -- **Cookies and request state**: pass the request and response headers explicitly, or use a site-owned `AsyncLocalStorage`, instead of v7's `RequestContext`. +- **Device detection** for components: v7's user-agent patterns in `src/sdk/device.ts` (the TanStack worker keys its cache by device on its own). +- **Cookies and request state**: pass the request and response headers explicitly, or a site-owned `AsyncLocalStorage`, instead of v7's `RequestContext`. - **Cart, user, wishlist, sign-in flows and commerce loaders**: vendored next to the loaders the script copied, sending through the v8 client. - **Section loaders** run inside their block functions. -- **Module-level config from v7 apps is cross-request state.** A v7 app's `configureX({...})` singleton (the website app's SEO defaults, for one) called from the page loader on every request is shared by every request in the isolate. A preview host and production share the worker, so a draft's values can land in a concurrent release render that the edge then caches. Put per-page values in the page's own state (the `AsyncLocalStorage` the page loader runs blocks in) and pass them to whatever read the singleton. -- **Identification headers in the copied worker entry** (`x-powered-by`, the outbound `User-Agent`) are the site's own values now, not the framework's. If they change (v7 sent its package version), list it as the site's difference. -- **Keep v7's platform client, or adopt the v8 one?** Keeping the vendored v7 client (and its cached loaders and fetch cache) keeps every upstream request identical, so recorded upstream fixtures replay. If you keep it, drop the v8 `@decocms/apps-` dependency nothing imports, and list the move to its client plus the `/next/caching` recipe (`cachedLoader` is gone in v8) as a follow-up. Prune what the copied worker entry still carries for v7's admin (preview shells, `/live/previews`, `/deco/render`). -- **Error reporting.** v7's `onResolveError` hook sent every failed section or loader to the site's exception reporting. v8 has no hook: `client.resolve` returns `[value, error]`, and a section loader that throws is caught in its block function. Report both explicitly there (same attributes as before, so dashboards keep working); `console.error` alone only reaches the SDK's sampled log export. - -- **A worker-level A/B split (`withABTesting`, `SITES_KV`).** v7 could wrap the worker to send a share of traffic to a fallback origin, configured per host in KV. v8 has no equivalent. If the KV holds no config for the site's hosts, nothing was split: delete the wrapper and the binding. Either way, list it for the product owner, since the switch to run such a test is gone. +- **Module-level config from v7 apps is cross-request state.** A v7 app's `configureX({...})` singleton (the website app's SEO defaults, for one) called from the page loader on every request is shared by every request in the isolate. A preview host and production share the worker, so a draft's values can land in a concurrent release render that the edge then caches. Put per-page values in the page's own state and pass them to whatever read the singleton. +- **Identification headers** (`x-powered-by`, the outbound `User-Agent`) are the site's own values; if they change (v7 sent its package version), list it as the site's difference. +- **Keep v7's platform client, or adopt the v8 one?** Keeping the vendored v7 client (and its cached loaders and fetch cache) keeps every upstream request identical, so recorded upstream fixtures replay. If you keep it, drop the v8 `@decocms/apps-` dependency nothing imports, and list the move to its client plus the `/next/caching` recipe (`cachedLoader` is gone in v8) as a follow-up. +- **Error reporting.** v7's `onResolveError` sent every failed section or loader to the site's exception reporting. v8 has no hook: `client.resolve` returns `[value, error]`, and a section loader that throws is caught in its block function. Report both explicitly there (same attributes as before, so dashboards keep working); `console.error` alone only reaches the SDK's sampled log export. +- **A worker-level A/B split (`withABTesting`, `SITES_KV`).** v8 has no equivalent. If the KV holds no config for the site's hosts, nothing was split: delete the wrapper and the binding. If it does, keep the split as site code around the worker's `fetch`. Either way, list it for the product owner. +- **Not ported to the TanStack worker**: v7's caching of POST server functions by body. A site that relied on it keeps that wrapper or lists it. diff --git a/.agents/skills/deco-v7-to-v8-migration/reference/import-map.md b/.agents/skills/deco-v7-to-v8-migration/reference/import-map.md index 827040406..a0afeb846 100644 --- a/.agents/skills/deco-v7-to-v8-migration/reference/import-map.md +++ b/.agents/skills/deco-v7-to-v8-migration/reference/import-map.md @@ -1,6 +1,6 @@ # Import map: v7 → v8 -What `scripts/imports.ts` does with each v7 `@decocms/*` import in the site's `src/`. Source of truth: `V8_API` and `HINTS` in that file; keep this table in step with them. +What `scripts/imports.ts` does with each v7 `@decocms/*` import in the site's `src/`. Source of truth: `V8_API`, `MOVES`, `NAME_HINTS` and `HINTS` in that file; keep these tables in step with them. ## Rewritten by the script @@ -8,10 +8,17 @@ What `scripts/imports.ts` does with each v7 `@decocms/*` import in the site's `s |---|---| | `@decocms/apps-/loaders/…`, `…/actions/…` that the content calls | the vendored copy in `src/vendor//…` (relative import) | | `createInstrumentedFetch` from `@decocms/blocks/sdk/instrumentedFetch` | `createInstrumentedFetch` from `@decocms/blocks/fetch`; `createInstrumentedFetch("x")` becomes `createInstrumentedFetch({ provider: "x" })` | +| `createDecoRouter`, `decoParseSearch`, `decoStringifySearch` (`@decocms/tanstack`) | the same names from `@decocms/tanstack/router` | +| `NavigationProgress`, `StableOutlet` (`@decocms/tanstack`) | the same names from `@decocms/tanstack/react` | +| `decoServerFnFetch` (`@decocms/tanstack/sdk/serverFnFetch`) | `@decocms/tanstack/start` | +| `Image`, `Picture`, `Source` and their props types (`@decocms/blocks/hooks`), on a TanStack site | `@decocms/tanstack/react`, same props and markup; the report asks for an `ImageUrlProvider` with the platform's helper (`@decocms/apps-vtex/image`, `@decocms/apps-shopify/image`), since the binding knows no platform | +| `DRAFT_PARAM`, `DRAFT_COOKIE`, `DRAFT_HEADER`, `draftResponseHeaders`, `STUDIO_ORIGINS`, `STUDIO_FRAME_ANCESTORS`, `isStudioOrigin` (`@decocms/blocks`, a prerelease) | the site's binding root: `@decocms/tanstack` or `@decocms/nextjs` (reported when the site has neither) | + +An import that mixes moved and other names is split; the rest is reported. ## Left alone (already the v8 API) -`@decocms/blocks` (the root's v8 names: `createCMS`, `matchRoute`, `remoteLoader`, `parseDraftPointer`, `Blocks`, `Lazy`, `Seo`, …; drafts are read with `cms.draftPointer`), `@decocms/blocks/fetch`, `/analytics`, `/secrets`, `/cli`. +`@decocms/blocks` (the root's v8 names: `createCMS`, `matchRoute`, `remoteLoader`, `parseDraftPointer`, `Blocks`, `Lazy`, `Seo`, …), `@decocms/blocks/fetch`, `/analytics`, `/secrets`, `/cli`, `/image` (platform-neutral: `createImageUrl`, `decoCdn`, `srcSet`, `parseSrcSet`, `preloadLink`), `/vite`, `/preview` (`DraftBadge`, `StudioBridge`, `StudioPage`); the platforms' image helpers, `@decocms/apps-vtex/image` and `@decocms/apps-shopify/image`; the root of `@decocms/tanstack` 8 (the draft names and Studio's origins) and every subpath (`/worker`, `/server`, `/start`, `/router`, `/react`, `/scope`, and `/vite`'s `decoTanstack`), and of `@decocms/nextjs` 8 (`/proxy`, `/server`, `/image-loader`, `/config`'s `withDeco`, the root's `StudioBridge`, `StudioPage`, `DraftBadge`, draft names and Studio's origins). ## Reported, with where the replacement lives (first match wins) @@ -31,28 +38,39 @@ What `scripts/imports.ts` does with each v7 `@decocms/*` import in the site's `s | `@decocms/apps-commerce`, `@decocms/apps/commerce` | converters, hooks and shared commerce types live in your platform template (copy them into the site) | | `@decocms/apps-website`, `@decocms/apps/website` | SEO, sitemaps, redirects live in your platform template | | any other `@decocms/apps-`, `@decocms/apps/` | apps are thin upstream clients: call `createClient` from your own code (`/next/upstream-clients`) | -| `@decocms/tanstack`, `@decocms/nextjs` | the v7 framework binding: v8 has none; drop the dependency and follow your framework's guide (`/next/tanstack-start-descriptors`, `/next/nextjs`) | +| `@decocms/blocks/sdk/cacheHeaders`, `/responseCache`, `/cacheStorage`, `/mergeCacheControl` | on TanStack, `createDecoWorker`'s edge cache (`cacheProfiles` over `DEFAULT_CACHE_PROFILES`); a Next.js site keeps its own | +| `@decocms/blocks/sdk/csp`, `/sdk/redirects` | `createDecoWorker`'s `csp` option; the worker applies CMS redirects | +| `createDecoWorkerEntry` (`@decocms/tanstack`) | `createDecoWorker({ serverEntry, cms, site, buildHash })` from `@decocms/tanstack/worker` (`reference/framework-code.md`) | +| `DecoRootLayout`, `PreviewProviders`, `DraftPreviewIndicator` (`@decocms/tanstack`) | `DecoDocument` from `@decocms/tanstack/react` | +| `cmsRouteConfig`, `loadCmsPage`, `DecoPageRenderer`, … (`@decocms/tanstack`) | `createCMS` and `matchRoute` (`/next/tanstack-start-descriptors`), the draft from `contentClient(cms)` (`@decocms/tanstack/server`) | +| `decoInvokeRouteConfig`, `decoMetaRouteConfig`, `decoRenderRouteConfig` | delete: the worker answers 404 on v7's admin paths | +| `setupTanstackFastDeploy`, `kvLoader` | `createCMS({ site, token })` (`reference/hosted-releases.md`) | +| `@decocms/tanstack/vite` (`decoVitePlugin`) | `decoTanstack({ buildHash })` from `@decocms/tanstack/vite` | +| `@decocms/tanstack/sdk/startEntry`, `/sdk/cdnSegment`, `/sdk/cookiePassthrough` | `decoServerFnFetch` in `createStart`; the worker's `segment`; the worker's Set-Cookie handling | +| `createDecoPage`, `DecoPageRenderer`, `DecoRootLayout`, … (`@decocms/nextjs`) | `createCMS` (`/next/nextjs`); `bindDraft` and `contentClient` from `@decocms/nextjs/server`; `StudioBridge` in the root layout | +| `DraftPreviewIndicator` (`@decocms/nextjs`) | the same name in v8, showing what `bindDraft` bound: call that first | +| `@decocms/nextjs/middleware` | `decideDraft` … `applyDraft` (or `draftProxy`) from `@decocms/nextjs/proxy` | +| `@decocms/nextjs/routeHandlers`, `DECO_REWRITES` | delete: no `/deco/*` handlers in v8 | +| `LiveControls` (`@decocms/blocks/hooks`) | `StudioBridge`: `DecoDocument` mounts it (`DecoPreview` in a site's own document); a Next.js layout renders it from `@decocms/nextjs`, and each page renders `StudioPage` | +| `DraftPreviewBadge` (`@decocms/blocks/preview`, `@decocms/nextjs`) | `DraftBadge` from `@decocms/blocks/preview` | +| `registerImageCdnDomain`, `registerImageQuality`, `getOptimizedMediaUrl`, `getSrcSet` | `createImageUrl({ providers: [vtexCatalog(), shopifyCdn(), decoCdn({ domain, quality, sources: "all", ...byRelease })] })` (`byRelease` by the site's 7.x release, or read from the site's own copy of the helper: `framework-code.md`): `createImageUrl`, `decoCdn` from `@decocms/blocks/image`, `vtexCatalog` from `@decocms/apps-vtex/image`, `shopifyCdn` from `@decocms/apps-shopify/image`; `url(src, opts)` and `srcSet(url, src, opts)`; on TanStack, `ImageUrlProvider` | +| `Image`, `Picture` on a Next.js site | next/image with `createDecoImageLoader` from `@decocms/nextjs/image-loader` | +| other v7 names under `@decocms/tanstack`, `@decocms/nextjs` | the v8 binding has no such name; see the rows above and `reference/framework-code.md` | | anything else under `@decocms/*` | no v8 equivalent (several have a site-code replacement: the next table) | ## What sites ended up writing themselves -From the storefront and blog migrations, the reported imports above were replaced by site code like this: +From the storefront and blog migrations, the reported imports with no binding equivalent were replaced by site code like this: | v7 import | Site code | |---|---| -| `Image`, `Picture` (`@decocms/blocks/hooks`) | `src/vendor/blocks/Image.tsx`, a copy that keeps v7's exact CDN URLs; a narrower copy inlined where the site already builds image URLs (an image loader) is fine when it yields v7's URL for every input the site passes. Copy it from the `@decocms/blocks` version **the site has installed**, never from another site's vendored copy: the CDN host and the query parameters changed across 7.x (`decoims.com` → `assets.decocms.com`, `quality=`) | -| `registerImageQuality` (`@decocms/blocks/hooks`, 7.52+) | the vendored `Image.tsx` starts with the quality the site registered (`let imageQuality = "high"`), so every URL keeps v7's `quality=` | -| `LiveControls` (`@decocms/blocks/hooks`) | v7's editor bridge (`__DECO_STATE`, admin `postMessage`, the `.` shortcut to Studio). No v8 equivalent: keep a vendored copy in the root document if editors use the shortcut, and list it for the product owner either way | | `BreadcrumbJsonLd`, `PLPJsonLd`, `ProductJsonLd` (`@decocms/blocks/hooks`) | `src/vendor/blocks/JsonLd.tsx` | -| `ANALYTICS_SCRIPT` (`@decocms/blocks/sdk/analytics`) on a site with no analytics SDK | the same `data-event` observer string in the site's root document, copied from the installed version (7.6x defers it until a Speculation Rules prerender activates) | -| `NavigationProgress`, `StableOutlet` (`@decocms/tanstack`) | the site's root document; copy them from the installed version (the bar's markup changed in 7.6x) | -| `withABTesting` (`@decocms/blocks/sdk/abTesting`) | nothing: a worker-level split between this worker and a fallback origin, driven by a `SITES_KV` config. Delete it and the binding, and list it for the product owner (`reference/framework-code.md`) | +| `ANALYTICS_SCRIPT` (`@decocms/blocks/sdk/analytics`) on a site with no analytics SDK | on TanStack, nothing: `DecoDocument` renders the `data-event` observer; elsewhere the same observer string in the site's root document, copied from the installed version (7.6x defers it until a Speculation Rules prerender activates) | +| `withABTesting` (`@decocms/blocks/sdk/abTesting`) | nothing: a worker-level split between this worker and a fallback origin, driven by a `SITES_KV` config. Delete it and the KV binding, and list it for the product owner (`reference/framework-code.md`) | | `signal` (`@decocms/blocks/sdk/signal`) | delete it when nothing imports the site's re-export; otherwise a local signal | -| `cacheHeaders`, `detectCacheProfile`, a `cache-config.ts` (`@decocms/blocks/sdk/cacheHeaders`) | `src/server/cache-profiles.ts` | | `withFetchTimeout`, `FetchFn` (`@decocms/blocks/sdk/fetchTimeout`, 7.6x) in vendored loaders | a local `fetch(url, { ...init, signal: AbortSignal.timeout(10_000) })` (v7's default), or the v8 client's own fetch | | `useDevice`, `detectDevice` (`@decocms/blocks/sdk/*`) | `src/sdk/device.ts` with v7's user-agent patterns | | `getCookies`/`setCookie`, `RequestContext` | read the request and write response headers explicitly, or a site-owned `AsyncLocalStorage` (`src/request-state.server.ts`) | | `useCart`/`useUser`/`useWishlist`, cart loaders (`@decocms/apps-`) | `src/vendor//…` copies, sending through the v8 client (`createShopifyClient`, …) | | commerce types, offer/price helpers (`@decocms/apps-commerce`) | `src/vendor/commerce` | | `Seo`, JSON-LD components | the site's head builder (`src/head.ts`) and `src/vendor/blocks` | -| edge cache, cache headers (`@decocms/tanstack` worker entry) | the site's worker entry (`src/server`, `src/worker-entry.ts`) | diff --git a/.agents/skills/deco-v7-to-v8-migration/reference/manual-steps.md b/.agents/skills/deco-v7-to-v8-migration/reference/manual-steps.md index a22fac602..6b9366a14 100644 --- a/.agents/skills/deco-v7-to-v8-migration/reference/manual-steps.md +++ b/.agents/skills/deco-v7-to-v8-migration/reference/manual-steps.md @@ -2,9 +2,9 @@ The full text behind `SKILL.md` ("Manual steps"). Work through the report top to bottom; these are the steps it groups its "Left to do" lines under. -1. **Dependencies.** Depend on `@decocms/blocks@^8.1` and the v8 `@decocms/apps-` client. Remove `@decocms/tanstack`/`@decocms/nextjs`, `@decocms/blocks-admin`, `@decocms/blocks-cli`, `@decocms/apps-commerce`, `@decocms/apps-website` and `@decocms/apps-blog` once nothing imports them, and drop the v7 codegen from `build`. On Next.js, check what the installed `@decocms/blocks` publishes: when its `exports` default to `dist/*.js` (`8.1.0` does), it needs no `transpilePackages` entry; a prerelease whose `exports` point at `src/*.ts` needs `@decocms/blocks` in `transpilePackages` (v7's `withDeco` wrapper used to add it), or a clean install fails `next build` with `Module parse failed: Unexpected token`. -2. **Render pages with `createCMS`.** Follow the framework guide (`/next/tanstack-start-descriptors`, `/next/nextjs`): `createCMS` over the content, `matchRoute`, one promise per block, a view registry. Delete the v7 setup files, admin routes and `/deco/*` handlers. Delete the v7 CI jobs too: a `.github/workflows/regen-blocks.yml` (or any job that runs `generate:blocks` or calls the shared `regen-blocks` workflow) regenerated `blocks.gen.json` on every content push to `main`. v8 has no such file and the script it calls is gone, so once merged every publish starts a job that fails or does nothing useful. -3. **Move framework code into the site** (`reference/framework-code.md`): edge cache, image, SEO/head, device detection, cookies, cart/user/wishlist flows, commerce loaders and converters. +1. **Dependencies.** Depend on `@decocms/blocks@^8.3`, the v8 binding at the same version (`@decocms/tanstack@^8.3` or `@decocms/nextjs@^8.3`; they take blocks as a peer, so one copy installs) and the v8 `@decocms/apps-` client. Remove `@decocms/blocks-admin`, `@decocms/blocks-cli`, `@decocms/apps-commerce`, `@decocms/apps-website` and `@decocms/apps-blog` once nothing imports them, and drop the v7 codegen from `build`. On Next.js, check what the installed `@decocms/blocks` publishes: when its `exports` default to `dist/*.js` (`8.1.0` does), it needs no `transpilePackages` entry; a prerelease whose `exports` point at `src/*.ts` needs `@decocms/blocks` in `transpilePackages` (v7's `withDeco` wrapper used to add it), or a clean install fails `next build` with `Module parse failed: Unexpected token`. +2. **Render pages with `createCMS`, on the binding.** Follow the framework guide (`/next/tanstack-start-descriptors`, `/next/nextjs`): `createCMS` over the content, `matchRoute`, one promise per block, a view registry. Wire the binding as its README shows: on TanStack, `createDecoWorker` as the worker entry, `DecoDocument` as the root, `createDecoRouter`, `decoServerFnFetch` and `decoTanstack()`; on Next.js, the draft proxy and route, `StudioBridge`, `withDeco` and the image loader (`reference/framework-code.md`). Delete the v7 setup files, admin routes and `/deco/*` handlers. Delete the v7 CI jobs too: a `.github/workflows/regen-blocks.yml` (or any job that runs `generate:blocks` or calls the shared `regen-blocks` workflow) regenerated `blocks.gen.json` on every content push to `main`. v8 has no such file and the script it calls is gone, so once merged every publish starts a job that fails or does nothing useful. +3. **Replace v7's framework code with the binding, and move the rest into the site** (`reference/framework-code.md`): the worker, drafts, the Studio bridge, the badge and the image components come from the binding, a platform's image URLs from its client's `/image` (`@decocms/apps-vtex/image`, `@decocms/apps-shopify/image`); SEO/head, device detection, cookies, cart/user/wishlist flows, commerce loaders and converters are site code. 4. **Replace `/deco/invoke`** with server functions (TanStack `createServerFn`) or Next server actions/route handlers. One exported server function per loader or action the browser called, and each call site imports the one it calls: `invoke.site.loaders.spin(props)` becomes `$siteLoadersSpin({ data: props })`. Don't rebuild the `invoke.x.y` tree or a string-keyed dispatcher over the commerce-loader map (see DO NOT in `SKILL.md`); the server function calls its handler directly. Details in `reference/server-functions.md`. 5. **Fix the content `deco check` rejects**: fields no type declares, `.tsx`-named preview blocks, v7 app blocks (apps are code now), `requestToParam` blocks in string fields. -6. **Telemetry, analytics and previews**: the `telemetry` option of `createCMS` (`/next/telemetry`), with switches and rates in the `telemetry` section of `CMS.json`; `AnalyticsScript` with `const { analytics } = await cms.settings()` and `track` from `@decocms/blocks/analytics`; drafts through `await cms.draftPointer(request)` / `cms.draftCookie(request)`, which serve the release on hosts outside `preview.hosts` (list the dev server's host with its port too, e.g. `localhost:3000` for `next dev`, in both the code cap and `CMS.json`, and name each host you add for the product owner: v8 ignores `DECO_ALLOWED_PREVIEW_HOSTS`) (with no `previewHosts`, v7 allowed previews only on a TanStack site's `.deco.site` and `.deco-cx.workers.dev`, which the settings step writes, or nowhere; v8 lets every host preview unless you list some, `/next/releases-and-drafts#allow-previews-per-host`). The report carries GTM/GA4 IDs over). If v7 sent no CMS telemetry, pointing `telemetry` at the site's existing collector is new traffic and cost there: list it as a behaviour change. +6. **Telemetry, analytics and previews**: the `telemetry` option of `createCMS` (`/next/telemetry`), with switches and rates in the `telemetry` section of `CMS.json`; `AnalyticsScript` with `const { analytics } = await cms.settings()` and `track` from `@decocms/blocks/analytics`; drafts through the binding (the TanStack worker, or `decideDraft`/`bindDraft` on Next.js), which applies v7's host allowlist: the hosts the site declares in the `CMS` block's `preview.hosts` (none when it lists none, as in v7: blocks' every-host default doesn't apply), plus `.deco.site` and `.deco-cx.workers.dev` from the `site` option; `previewHosts: [...]` replaces the block's list and `previewHosts: "none"` turns previews off, where v7 read `DECO_ALLOWED_PREVIEW_HOSTS`. The report's "preview hosts" line says what to pass to keep v7's hosts; list the dev server's host with its port (`localhost:5173`, `localhost:3000`) when editors preview there, and name each host you add for the product owner. The report carries GTM/GA4 IDs over. If v7 sent no CMS telemetry, pointing `telemetry` at the site's existing collector is new traffic and cost there: list it as a behaviour change. diff --git a/.agents/skills/deco-v7-to-v8-migration/reference/nextjs.md b/.agents/skills/deco-v7-to-v8-migration/reference/nextjs.md index 4e8c4f0c5..2021487eb 100644 --- a/.agents/skills/deco-v7-to-v8-migration/reference/nextjs.md +++ b/.agents/skills/deco-v7-to-v8-migration/reference/nextjs.md @@ -3,9 +3,14 @@ From the site migrations; the sites and what the commit hashes refer to are listed in `SKILL.md` ("Gotchas by subject"). - **`transpilePackages: ['@decocms/blocks']`** only while the installed package's `exports` point at `.ts` source (see `manual-steps.md`, step 1); `8.1.0` ships `dist/` and needs none. A linked local checkout ships `dist/` and hides the difference; so does every parity run made against it. -- **Drafts on Pages Router SSG pages** (no proxy available, as on FastStore): Next 16's compiled pages runtimes inline `tryGetPreviewData`, so a `require.cache` patch never runs. See `faststore.md`. -- **Drafts on static pages.** `force-static`/ISR pages get stubbed `cookies()`, so they can't read the draft pointer. Divert drafted requests (`?__draft=` or the draft cookie) in `proxy.ts` onto a dynamic route group that binds the pointer, and 404 direct hits on that internal route. Send `Cache-Control: no-store, private` and `X-Robots-Tag: noindex` on both signals. -- **The proxy runs on every request: keep the CMS out of it for ordinary traffic.** Ask `cms.draftPointer`/`cms.draftCookie` only when the request has `?__draft=` or the `__deco_draft` cookie (the SDK doesn't export the names yet; pin them in a test). Give the proxy its own `createCMS` handle with `blocks: {}` and the same options as the app's (one shared options module, since a second call with other options keeps the first): the draft helpers read only the `CMS` settings block, so the block map, its section loaders and what they import (a GraphQL engine) leave the proxy bundle. Measured on one store: 2.2 MB to 1.5 MB, traced files 3,245 to 364. The content module still lands there. +- **Drafts on Pages Router SSG pages** (no proxy available, as on FastStore; `@decocms/nextjs` is for the App Router): Next 16's compiled pages runtimes inline `tryGetPreviewData`, so a `require.cache` patch never runs. See `faststore.md`. +- **Drafts come from `@decocms/nextjs`.** Don't hand-write the draft proxy, the draft route or the Studio bridge. In `proxy.ts`: 404 direct hits on the internal route (`isDraftRoutePath`), `decideDraft(proxyCms, request, { site, previewHosts })`, then `rewriteToDraftRoute(request, draft)` or `NextResponse.next({ request: { headers: draftRequestHeaders(request, draft) } })`, and `applyDraft(response, draft)` for the cookie and the no-store/noindex headers (`@decocms/nextjs/proxy`). On the dynamic `src/app/%5Fdraft/[[...path]]` route and in a layout that reads content: `bindDraft(cms, { searchParams, site, previewHosts })` and `contentClient(cms)` (`@decocms/nextjs/server`), with `` on the page. `force-static`/ISR pages get stubbed `cookies()`, which is why drafted requests go to that dynamic route; published pages stay static. The package's README has the files in full. +- **Same options in the proxy and the pages.** Keep `{ site, previewHosts }` in one module both import: the proxy decides with them, `bindDraft` checks again with them. With `site`, `.deco.site` and `.deco-cx.workers.dev` preview too, which v7's Next.js binding didn't allow: name it for the product owner, or leave `site` out to keep v7's hosts. +- **Keep the block map out of the proxy.** The proxy only touches the CMS for a request with `?__draft=` or the draft cookie, but it is bundled whole. Give it its own `createCMS` handle with `blocks: {}` and the same options as the app's (one shared options module, since a second call with other options keeps the first): the draft check reads only the `CMS` settings block, so the block map, its section loaders and what they import (a GraphQL engine) leave the proxy bundle. Measured on one store: 2.2 MB to 1.5 MB, traced files 3,245 to 364. The content module still lands there. +- **`next start -H 127.0.0.1`** makes Next treat the draft rewrite as an external URL, and the request comes back through the proxy. Bind the default host in local checks; production hostnames aren't affected. +- **`Vary: Cookie`** doesn't arrive on a draft page: Next sets `Vary` itself. `Cache-Control: no-store, private` and noindex do. +- **next.config**: `withDeco(config)` from `@decocms/nextjs/config` (plain CommonJS) puts v7's draft header rules first and adds Studio's origins to `allowedDevOrigins`. Delete hand-written copies of those rules. +- **Images**: `createDecoImageLoader(imageUrl, { fallback: nextImageOptimizer })` from `@decocms/nextjs/image-loader` as `images.loaderFile`, over `createImageUrl` from `@decocms/blocks/image` with the platform's helpers first (`@decocms/apps-vtex/image`; see `framework-code.md`). A Server Component can't pass the URL function to a client component, so use the loader file, not a wrapper component. - **A pointer that doesn't parse.** `cms.draftPointer` ignores a `?__draft=`/cookie value that doesn't parse (it returns `null`, so `?__draft=junk` gets the release). On a `@decocms/blocks` release without that fix it returned the raw value and `cms.forDraft` failed with `LOADER_FAILED`, so `?__draft=junk` on a preview host answered 500: there, treat `parseDraftPointer(pointer) === null` as no draft, in the proxy and the page. - **Views wrapped in `next/dynamic`.** The script types each section from its view (`PropsOf`). A lazy view that re-exports a `next/dynamic` wrapper loses its props type, and its form collapses. Type those sections from the component module (`section(…)`) and diff the forms against v7's. - **`list('page')` order.** It sorts by block name; v7 listed saved pages in file order. Output that lists pages in order (a sitemap) has to keep v7's order explicitly. diff --git a/.agents/skills/deco-v7-to-v8-migration/reference/running-the-script.md b/.agents/skills/deco-v7-to-v8-migration/reference/running-the-script.md index dd8d3a72b..d5ecdb06e 100644 --- a/.agents/skills/deco-v7-to-v8-migration/reference/running-the-script.md +++ b/.agents/skills/deco-v7-to-v8-migration/reference/running-the-script.md @@ -4,7 +4,7 @@ From the site migrations; the sites and what the commit hashes refer to are list ## Before you run the script -- **Install `@decocms/blocks@^8.1` first** (`^8` resolves to the accidental v7 build published as `8.0.0`). A v7 site already has `@decocms/blocks@7`, which has no `/cli` or `/protocol/keys`; the script fails to import. Keep v7 `@decocms/apps-*` installed until after the run: vendoring copies from them. +- **Install `@decocms/blocks@^8.3` first** (`^8` resolves to the accidental v7 build published as `8.0.0`). A v7 site already has `@decocms/blocks@7`, which has no `/cli` or `/protocol/keys`; the script fails to import. Keep v7 `@decocms/apps-*` installed until after the run: vendoring copies from them. - **Commit the script's output unedited** (`046de4d`, `de03793`), then do the manual work in follow-up commits. - **The script vendors every app loader the content calls**, including ones the v7 site never ran (a v7 Next.js site resolved many VTEX loader blocks to `null`). Check which ones actually rendered before keeping a vendored copy; delete the rest along with the saved blocks nothing references. diff --git a/.agents/skills/deco-v7-to-v8-migration/reference/script-steps.md b/.agents/skills/deco-v7-to-v8-migration/reference/script-steps.md index 75add475f..0c38109cc 100644 --- a/.agents/skills/deco-v7-to-v8-migration/reference/script-steps.md +++ b/.agents/skills/deco-v7-to-v8-migration/reference/script-steps.md @@ -3,8 +3,8 @@ The full list behind `SKILL.md` ("Run"). What it does, in order (`scripts/migrate.ts`): 1. **content**: saved blocks into `.deco/blocks`, v7 generated files removed, v7 async-rendering wrappers (`website/sections/Rendering/Lazy.tsx`, `SingleDeferred.tsx`, `Deferred.tsx`) unwrapped to the sections they held (v8 has no async rendering), the seven legacy names outside the alias table rewritten (`reference/legacy-names.md`), A/B tests keyed on a random matcher get its saved-block name as `experiment`; - **settings**: site settings folded into the `CMS` block (`.deco/blocks/CMS.json`, type `cms-settings`): the v7 Site block's (`Site`/`site`) `previewHosts` → `preview.hosts` (the field moves, ports kept, each entry trimmed and lowercased as v7 compared it; an entry that isn't a host is reported; on TanStack Start the `.deco.site` and `.deco-cx.workers.dev` hosts v7 always added for `DECO_SITE_NAME` are added too, or reported when the name isn't found; with no `previewHosts` at all, a named TanStack site gets those two hosts alone, the only ones v7 allowed), a literal `collectorAddress` on `OneDollarStats` → `analytics.collector`, and an earlier prerelease's `Telemetry.json`/`Analytics.json` → their sections, field for field and variants included (the old files are deleted). It keeps what `CMS.json` already has, writes nothing when there's nothing to fold, and a second run changes nothing. The report lists the environment-only settings to move by hand (`DECO_ALLOWED_PREVIEW_HOSTS`, `DECO_OTEL_*` sampling, `DECO_ANALYTICS_ENABLED`/`ONEDOLLAR_ENABLED`/`ONEDOLLAR_COLLECTOR`); + **settings**: site settings folded into the `CMS` block (`.deco/blocks/CMS.json`, type `cms-settings`): the v7 Site block's (`Site`/`site`) `previewHosts` → `preview.hosts` (the field moves, ports kept, each entry trimmed and lowercased as v7 compared it; an entry that isn't a host is reported). The hosts Deco runs the site on (`.deco.site`, `.deco-cx.workers.dev`) are not written: the binding allows them from its `site` option, and the report's "preview hosts" line names the `site` to pass (from `DECO_SITE_NAME`) (with no `previewHosts` in v7 nothing more is needed: the binding then allows only those two, as v7 did), a literal `collectorAddress` on `OneDollarStats` → `analytics.collector`, and an earlier prerelease's `Telemetry.json`/`Analytics.json` → their sections, field for field and variants included (the old files are deleted). It keeps what `CMS.json` already has, writes nothing when there's nothing to fold, and a second run changes nothing. The report lists the environment-only settings to move by hand (`DECO_ALLOWED_PREVIEW_HOSTS`, `DECO_OTEL_*` sampling, `DECO_ANALYTICS_ENABLED`/`ONEDOLLAR_ENABLED`/`ONEDOLLAR_COLLECTOR`); 2. **secrets**: v7 secrets re-encrypted with `.deco/secrets.pub`; 3. **block map**: `.deco/index.ts` with each block under a short name plus an alias under its v7 name, after vendoring the app loaders and actions the content calls into `src/vendor`; -4. **imports**: the codemod over `src/` (`reference/import-map.md`); -5. **scripts**: `predev`/`prebuild` run `deco schema && deco content` (`&& deco check`). +4. **imports**: the codemod over `src/` (`reference/import-map.md`): v7 names the bindings keep move to `@decocms/tanstack` subpaths, the draft names and Studio's origins to the site's binding root (`@decocms/tanstack` or `@decocms/nextjs`, never `@decocms/blocks`), the rest are reported name by name. The image URLs it reports (`createImageUrl` with `decoCdn`'s options) follow the site's 7.x release, or, when the site kept its own copy of v7's image helper, that copy's rules and the site's register calls. `stripAssetHosts` is left at decoCdn's default, `"leading"`, even for a site whose v7 code stripped the asset hosts anywhere (7.x before 7.71.4, or such a copy): the PO approved the switch (2026-10-15), and the report's "image URLs" line counts and lists the content and code URLs whose image URL changes (a URL that merely contains an asset URL, `https://other.host/https://assets.decocache.com/…`); +5. **scripts**: `predev`/`prebuild` run `deco schema && deco content` (`&& deco check`), and `deco schema --check` goes into the site's CI right after the install step (the pull-request workflows when there are any; never v7's `regen-blocks`), run through the lockfile's runner (`bunx`, `pnpm exec`, `yarn`, `npx`) and the install's working directory. It fails a pull request whose `.deco/schema.gen.json` differs from what `deco schema` writes. A workflow it can't edit (none, or a multi-line install) is reported. diff --git a/.agents/skills/deco-v7-to-v8-migration/reference/sibling-port.md b/.agents/skills/deco-v7-to-v8-migration/reference/sibling-port.md index 59d6e2fe8..aa60ad400 100644 --- a/.agents/skills/deco-v7-to-v8-migration/reference/sibling-port.md +++ b/.agents/skills/deco-v7-to-v8-migration/reference/sibling-port.md @@ -5,7 +5,7 @@ From the site migrations; the sites and what the commit hashes refer to are list A three-way merge from a site that is already on v8 (`SKILL.md`, "A sibling site already migrated?") brings everything that site changed, not only the migration: - **Its own later product changes.** Read the sibling's history after its script run (`git log .. -- src`) and back out what came from merging its main (a cart read switched from GET to POST, an SSR prefetch skipped) unless the site being migrated has it on its own main. -- **Vendored copies of another version.** The sibling vendored its installed `@decocms/*`. Diff that version's sources with the site's (`npm pack @decocms/@`) and re-vendor what changed (commerce types, `useOffer`, `Image`, `ANALYTICS_SCRIPT`, `NavigationProgress`); keep the sibling's v8 edits on top. +- **Vendored copies of another version.** The sibling vendored its installed `@decocms/*`. Diff that version's sources with the site's (`npm pack @decocms/@`) and re-vendor what changed (commerce types, `useOffer`); keep the sibling's v8 edits on top. A sibling migrated before the bindings vendored `Image`, `NavigationProgress`, the worker entry or the draft code: take those from `@decocms/tanstack`/`@decocms/nextjs` instead of merging the copies (`framework-code.md`). - **Content.** Saved blocks byte-identical on both sides on v7 can take the sibling's migrated version, but check each field it dropped against this site's own types: `deco check` on the site's unmodified content must list every dropped field as unknown. - **Editor forms.** The sibling's SEO and loader types match its v7 forms, not necessarily this site's (7.6x added fields to the SEO sections, such as Structured Data and Omit variants, and offered loaders such as `RelatedProducts` in product pickers). Diff the forms against this site's own v7 schema. - **Security headers.** Compare the site's v7 `createDecoWorkerEntry` options with the sibling's: a site that passed `securityHeaders: { "Content-Security-Policy": … }` sent an enforced CSP that replaces the default `frame-ancestors` one, and no `X-Frame-Options`. The copied edge cache must send that header, not the sibling's report-only list. diff --git a/.agents/skills/deco-v7-to-v8-migration/reference/telemetry.md b/.agents/skills/deco-v7-to-v8-migration/reference/telemetry.md index cad567272..8aabd76a7 100644 --- a/.agents/skills/deco-v7-to-v8-migration/reference/telemetry.md +++ b/.agents/skills/deco-v7-to-v8-migration/reference/telemetry.md @@ -5,6 +5,6 @@ From the site migrations; the sites and what the commit hashes refer to are list - Telemetry is explicit params only: the SDK reads no environment variable (`OTEL_*` and v7's `DECO_OTEL_*` are ignored). A site that kept a collector reads its own variables and passes `telemetry: { endpoint, headers }`; with `createCMS({ site, token })` and no `endpoint`, telemetry goes to the hosted collector (a `token` without `site` is a configuration error). Pass `telemetry: false` in dev or parity runs (`89eae7d`). v7's per-signal `DECO_OTEL_{METRICS,LOGS,TRACES}_ENDPOINT` have no v8 equivalent: every signal goes to `/v1/`. - **New telemetry is a behaviour change.** A site with its own OpenTelemetry export that v7 never fed CMS telemetry starts sending it once `telemetry` points at that collector. List the new traffic for the product owner. - `service.name`, `service.version` and `deployment.environment.name` come from `telemetry.resource`, on the hosted collector too (`telemetry: { resource }` without `endpoint`). v7 took them from `DECO_SITE_NAME`, the `CF_VERSION_METADATA` binding and `DECO_ENV_NAME`: keep the `version_metadata` binding in `wrangler.jsonc`, read the three in the site and pass them (or a Vite `define` of the commit). Vars left in `wrangler.jsonc` that nothing reads are dead config a reviewer will flag. v7's `DECO_OTEL_ERROR_PROMOTION_RATE` becomes `errorSampleRate` in `CMS.json`'s telemetry section (default 0.05, capped by `telemetry.limits`, default 0.1); `DECO_OTEL_LOGS_MIN_LEVEL` has no equivalent (v8 sends error logs only): list it. -- **Local development and hosted releases.** Once `site` is set, `createCMS` keeps the dev server on local files when `NODE_ENV` is `development` (read guarded: where there is no `process`, as in a Worker without Node compatibility, it counts as production). The `dev` option overrides it both ways (`dev: import.meta.env.DEV`, `dev: false`); 8.0 to 8.2 read no `NODE_ENV` and needed `dev`. -- **v7 Workers bindings with no v8 writer.** v7's `instrumentWorker` wrote per-route request counts, edge cache hit/miss and request duration to an Analytics Engine dataset (`DECO_METRICS`) and read `CF_VERSION_METADATA`; v8 has no metric API for them. Don't keep the bindings as dead config, and don't drop them silently: list each by name (and the edge layer's `deco.cache.requests` counter) as a product decision in the PR, since the dashboards built on them go dark. +- **Local development and hosted releases.** Once `site` is set, `createCMS` keeps the dev server on local files when `NODE_ENV` is `development` (read guarded: where there is no `process`, as in a Worker without Node compatibility, it counts as production). Leave `dev` out: the migrator doesn't write it. The option still overrides `NODE_ENV` both ways when a site needs it; 8.0 to 8.2 read no `NODE_ENV` and needed `dev`. +- **v7 Workers bindings with no v8 writer.** v7's `instrumentWorker` wrote per-route request counts, edge cache hit/miss and request duration to an Analytics Engine dataset (`DECO_METRICS`) and read `CF_VERSION_METADATA`. v8 has no exporter for them: `createDecoWorker`'s `onCache` hook hands the site each page or server-function request (`profile`, `decision`, `ms`, `status`), and the site writes it where its dashboards read, `DECO_METRICS` included. Don't keep a binding nothing writes, and don't drop one silently: list each by name (and the edge layer's `deco.cache.requests` counter) as a product decision in the PR. - Analytics: `AnalyticsScript` with `(await cms.settings()).analytics` plus `track` replace OneDollarStats (the collector lives in the `analytics` section of `CMS.json`); it sends collector beacons directly instead of loading the SDK (`4a50b13`, blog `9e97455`). diff --git a/.agents/skills/deco-v7-to-v8-migration/reference/verify.md b/.agents/skills/deco-v7-to-v8-migration/reference/verify.md index 98b87f92c..d3cddf085 100644 --- a/.agents/skills/deco-v7-to-v8-migration/reference/verify.md +++ b/.agents/skills/deco-v7-to-v8-migration/reference/verify.md @@ -13,7 +13,7 @@ A v7 site often carries type errors of its own. Count them on the v7 commit firs Run it once more **without any local link** (a `decocms/blocks` checkout linked into `node_modules` ships compiled `dist/`, the npm package may not): a clean `install --frozen-lockfile && build` from the committed lockfile, the way CI and the deploy run it. Then `git diff .deco/schema.gen.json`: the build regenerates it, so commit what the committed dependency's CLI writes, or every clean build leaves the tree dirty. If it differs from the linked checkout's, check both against v7's forms; when the published one is the worse, list a follow-up to bump once the fix is released. -**Probe drafts by hand** on the dev server and on a production build: `?__draft=` on an allowed host renders the draft and sets the cookie, the cookie alone keeps the next page in the draft, a host outside `preview.hosts` and a malformed pointer get the release, `?__draft=off` clears the cookie. A parity harness can't catch a dead draft path when v7's was dead too: both sides render the release. +**Probe drafts by hand** on the dev server and on a production build: `?__draft=` on an allowed host renders the draft and sets the cookie, the cookie alone keeps the next page in the draft, a host the binding refuses (outside `preview.hosts`, or `previewHosts`, and not `.deco.site`) and a malformed pointer get the release, `?__draft=off` clears the cookie. Check the headers too: a draft answers `no-store` and `noindex`, and on TanStack the release's `X-Cache` still goes MISS then HIT. A parity harness can't catch a dead draft path when v7's was dead too: both sides render the release. Grep the tree for v7 tooling in comments, docs and `.gitattributes` (`blocks-cli`, `meta.gen.json`, `sections.gen.ts`, `generate-schema`, `ts-morph`) and reword each to `deco schema` or the block map. Check that the claim still holds under `deco schema` (`/next/schema`) instead of renaming the tool: some v7 quirks are gone, and the comment should then say it was v7. diff --git a/.agents/skills/deco-v7-to-v8-migration/scripts/bindings.test.ts b/.agents/skills/deco-v7-to-v8-migration/scripts/bindings.test.ts new file mode 100644 index 000000000..48cc5aebb --- /dev/null +++ b/.agents/skills/deco-v7-to-v8-migration/scripts/bindings.test.ts @@ -0,0 +1,222 @@ +// @vitest-environment node +/** + * End to end on a v7 TanStack Start fixture: `migrate` points the imports the + * v8 binding keeps (the router, the progress bar, the image components, the + * server-function fetch) and the draft names at `@decocms/tanstack`, and the + * result type-checks against this repository's `@decocms/tanstack` and + * `@decocms/blocks`, with the image URLs the report tells the site to write + * (the platform helpers from `@decocms/apps-vtex/image` and + * `@decocms/apps-shopify/image`, the Deco CDN from `@decocms/blocks/image`). + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { migrate } from "./migrate"; +import type { Report } from "./report"; + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); +const PACKAGES = path.join(REPO, "packages"); +const PKGS = ["blocks", "tanstack", "apps-vtex", "apps-shopify"]; + +/** `paths` onto each package's `source` files, so the check needs no build. */ +function sourcePaths(): Record { + const paths: Record = {}; + for (const pkg of PKGS) { + const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGES, pkg, "package.json"), "utf8")); + for (const [sub, target] of Object.entries(manifest.exports)) { + const source = target.source ?? target.workerd?.source; + if (!source) continue; + paths[`@decocms/${pkg}${sub === "." ? "" : sub.slice(1)}`] = [ + path.join(PACKAGES, pkg, source), + ]; + } + } + return paths; +} + +/** + * v7's image URLs for the fixture site's 7.64.3, as the report spells them: + * no domain registered, so 7.x's default before 7.71.0, decoims.com; no quality. + */ +const IMAGE_URL = + 'createImageUrl({ providers: [vtexCatalog(), shopifyCdn(), decoCdn({ domain: "decoims.com", sources: "all", legacyHosts: [] })] })'; + +let root: string; +let report: Report; + +function write(file: string, data: string | object) { + const full = path.join(root, file); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, typeof data === "string" ? data : `${JSON.stringify(data, null, 2)}\n`); +} + +const read = (file: string) => fs.readFileSync(path.join(root, file), "utf8"); + +beforeAll(async () => { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "deco-v7-to-v8-bindings-"))); + fs.mkdirSync(path.join(root, "node_modules", "@decocms"), { recursive: true }); + for (const pkg of PKGS) { + fs.symlinkSync( + path.join(PACKAGES, pkg), + path.join(root, "node_modules", "@decocms", pkg), + "dir", + ); + } + // What a TanStack site installs next to them. + for (const dep of ["react", "@types/react", "@tanstack/react-router"]) { + fs.mkdirSync(path.dirname(path.join(root, "node_modules", dep)), { recursive: true }); + fs.symlinkSync( + path.join(REPO, "node_modules", dep), + path.join(root, "node_modules", dep), + "dir", + ); + } + write("package.json", { + name: "v7-tanstack-site", + private: true, + type: "module", + dependencies: { "@decocms/blocks": "7.64.3", "@decocms/tanstack": "7.64.3" }, + }); + write(".deco/blocks/pages-home.json", { + __resolveType: "website/pages/Page.tsx", + name: "Home", + path: "/", + sections: [], + }); + write( + "src/router.tsx", + [ + 'import { createDecoRouter } from "@decocms/tanstack";', + 'import type { AnyRoute } from "@tanstack/react-router";', + "export const getRouter = (routeTree: AnyRoute) => createDecoRouter({ routeTree });", + "", + ].join("\n"), + ); + write( + "src/Shell.tsx", + [ + 'import { NavigationProgress, StableOutlet } from "@decocms/tanstack";', + "export function Shell() {", + " return (<>);", + "}", + "", + ].join("\n"), + ); + write( + "src/start.ts", + [ + 'import { decoServerFnFetch } from "@decocms/tanstack/sdk/serverFnFetch";', + "export const serverFns = { fetch: decoServerFnFetch };", + "", + ].join("\n"), + ); + write( + "src/Banner.tsx", + [ + 'import { Image, Picture, Source, type ImageProps } from "@decocms/blocks/hooks";', + 'export function Banner({ src, alt }: { src: string; alt: ImageProps["alt"] }) {', + " return (", + " ", + ' ', + ' {alt}', + " ", + " );", + "}", + "", + ].join("\n"), + ); + write( + "src/draft.ts", + [ + 'import { DRAFT_COOKIE, STUDIO_ORIGINS } from "@decocms/blocks";', + "export const names = { cookie: DRAFT_COOKIE, origins: STUDIO_ORIGINS };", + "", + ].join("\n"), + ); + report = await migrate({ root }); + // The manual step the report names for the image components, as a site writes it. + write( + "src/imageUrl.tsx", + [ + 'import { shopifyCdn } from "@decocms/apps-shopify/image";', + 'import { vtexCatalog } from "@decocms/apps-vtex/image";', + 'import { createImageUrl, decoCdn } from "@decocms/blocks/image";', + 'import { ImageUrlProvider } from "@decocms/tanstack/react";', + 'import type { ReactNode } from "react";', + `export const imageUrl = ${IMAGE_URL};`, + "export const Wrap = ({ children }: { children: ReactNode }) => (", + " {children}", + ");", + "", + ].join("\n"), + ); +}); + +afterAll(() => { + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("a v7 TanStack Start site on the v8 binding", () => { + it("points the kept imports at @decocms/tanstack", () => { + expect(read("src/router.tsx")).toContain( + 'import { createDecoRouter } from "@decocms/tanstack/router";', + ); + expect(read("src/Shell.tsx")).toContain( + 'import { NavigationProgress, StableOutlet } from "@decocms/tanstack/react";', + ); + expect(read("src/start.ts")).toContain( + 'import { decoServerFnFetch } from "@decocms/tanstack/start";', + ); + expect(read("src/Banner.tsx")).toContain( + 'import { Image, Picture, Source, type ImageProps } from "@decocms/tanstack/react";', + ); + const image = report.manual.find((n) => n.subject === "Image, Picture, Source")?.message; + expect(image).toContain("ImageUrlProvider"); + expect(image).toContain( + 'createImageUrl({ providers: [vtexCatalog(), shopifyCdn(), decoCdn({ domain, quality, sources: "all", legacyHosts: [] })] })', + ); + expect(image).toContain('else "decoims.com", the default before 7.71.0'); + expect(image).toContain("vtexCatalog from @decocms/apps-vtex/image"); + expect(image).toContain("shopifyCdn from @decocms/apps-shopify/image"); + expect(read("src/draft.ts")).toContain( + 'import { DRAFT_COOKIE, STUDIO_ORIGINS } from "@decocms/tanstack";', + ); + const deps = report.manual.find((n) => n.step === "scripts" && n.subject === "package.json"); + expect(deps?.message).toContain("@decocms/tanstack ^8.3.0"); + }); + + it("type-checks against the v8 packages", () => { + const files = [ + "src/router.tsx", + "src/Shell.tsx", + "src/start.ts", + "src/Banner.tsx", + "src/draft.ts", + "src/imageUrl.tsx", + ].map((f) => path.join(root, f)); + const program = ts.createProgram(files, { + strict: true, + noEmit: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowImportingTsExtensions: true, + paths: sourcePaths(), + jsx: ts.JsxEmit.ReactJSX, + lib: ["lib.es2022.d.ts", "lib.dom.d.ts"], + types: [], + }); + const errors = ts + .getPreEmitDiagnostics(program) + .filter((d) => d.file?.fileName.startsWith(path.join(root, "src"))) + .map((d) => { + const where = d.file ? path.relative(root, d.file.fileName) : ""; + return `${where}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`; + }); + expect(errors).toEqual([]); + }, 60_000); +}); diff --git a/.agents/skills/deco-v7-to-v8-migration/scripts/ciSchemaCheck.test.ts b/.agents/skills/deco-v7-to-v8-migration/scripts/ciSchemaCheck.test.ts new file mode 100644 index 000000000..64d369b0b --- /dev/null +++ b/.agents/skills/deco-v7-to-v8-migration/scripts/ciSchemaCheck.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment node +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { addSchemaCheck } from "./ciSchemaCheck"; +import { createReport } from "./report"; + +let root: string | undefined; +afterEach(() => root && fs.rmSync(root, { recursive: true, force: true })); + +function repo(files: Record): string { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "deco-v7-ci-check-"))); + fs.mkdirSync(path.join(root, ".git")); + for (const [file, data] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), data); + } + return root; +} + +const read = (file: string) => fs.readFileSync(path.join(root as string, file), "utf8"); + +const CI = `name: CI +on: + pull_request: + push: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - name: Install + run: bun install --frozen-lockfile + # a comment kept with the step + + - run: bun run build +`; + +describe("deco schema --check in the site's CI (PO 2026-10-15)", () => { + it("goes right after the install step, before the build regenerates the schema", () => { + repo({ "package.json": "{}", "bun.lock": "", ".github/workflows/ci.yml": CI }); + const report = createReport(); + addSchemaCheck(root as string, report); + expect(read(".github/workflows/ci.yml")).toBe( + CI.replace( + " # a comment kept with the step\n", + " # a comment kept with the step\n - name: Check that .deco/schema.gen.json is up to date\n run: bunx deco schema --check\n", + ), + ); + expect(report.done.find((n) => n.subject === "CI")?.message).toContain( + ".github/workflows/ci.yml", + ); + // A second run changes nothing. + const again = createReport(); + addSchemaCheck(root as string, again); + expect(read(".github/workflows/ci.yml").match(/schema --check/g)).toHaveLength(1); + expect(again.done.find((n) => n.subject === "CI")?.message).toContain("already runs"); + }); + + it("prefers the pull-request workflows, skips v7's content regeneration, and keeps the install's working directory", () => { + const deploy = "on:\n push:\njobs:\n d:\n steps:\n - run: npm ci\n"; + const regen = + "on:\n pull_request:\njobs:\n r:\n steps:\n - run: npm ci\n - run: npm run generate:blocks\n"; + const pr = + "on: [pull_request]\njobs:\n t:\n steps:\n - run: npm ci\n working-directory: apps/site\n - run: npm test\n"; + repo({ + "apps/site/package.json": "{}", + "package-lock.json": "{}", + ".github/workflows/deploy.yml": deploy, + ".github/workflows/regen-blocks.yml": regen, + ".github/workflows/pr.yml": pr, + }); + addSchemaCheck(path.join(root as string, "apps/site"), createReport()); + expect(read(".github/workflows/deploy.yml")).toBe(deploy); + expect(read(".github/workflows/regen-blocks.yml")).toBe(regen); + expect(read(".github/workflows/pr.yml")).toBe( + pr.replace( + " - run: npm test\n", + " - name: Check that .deco/schema.gen.json is up to date\n working-directory: apps/site\n run: npx deco schema --check\n - run: npm test\n", + ), + ); + }); + + it("points at the site's folder in a monorepo with no working directory set", () => { + const ci = "on: pull_request\njobs:\n t:\n steps:\n - run: pnpm install\n"; + repo({ "site/package.json": "{}", "pnpm-lock.yaml": "", ".github/workflows/ci.yaml": ci }); + addSchemaCheck(path.join(root as string, "site"), createReport()); + expect(read(".github/workflows/ci.yaml")).toBe( + `${ci} - name: Check that .deco/schema.gen.json is up to date\n working-directory: site\n run: pnpm exec deco schema --check\n`, + ); + }); + + it("reports what it can't place", () => { + repo({ "package.json": "{}" }); + let report = createReport(); + addSchemaCheck(root as string, report); + expect(report.manual.find((n) => n.subject === "CI")?.message).toContain( + "no CI workflow found", + ); + fs.mkdirSync(path.join(root as string, ".github/workflows"), { recursive: true }); + const multiline = + "on: pull_request\njobs:\n t:\n steps:\n - run: |\n npm ci\n"; + fs.writeFileSync(path.join(root as string, ".github/workflows/ci.yml"), multiline); + report = createReport(); + addSchemaCheck(root as string, report); + expect(read(".github/workflows/ci.yml")).toBe(multiline); + expect(report.manual.find((n) => n.subject === "CI")?.message).toContain( + "no one-line install step in .github/workflows/ci.yml", + ); + }); + + it("doesn't take a build or a global install for the install step", () => { + const ci = + "on: pull_request\njobs:\n t:\n steps:\n - run: npm install -g pnpm\n - run: bun run build\n - run: yarn\n - run: yarn build\n"; + repo({ "package.json": "{}", "yarn.lock": "", ".github/workflows/ci.yml": ci }); + addSchemaCheck(root as string, createReport()); + expect(read(".github/workflows/ci.yml")).toBe( + ci.replace( + " - run: yarn build\n", + " - name: Check that .deco/schema.gen.json is up to date\n run: yarn deco schema --check\n - run: yarn build\n", + ), + ); + }); +}); diff --git a/.agents/skills/deco-v7-to-v8-migration/scripts/ciSchemaCheck.ts b/.agents/skills/deco-v7-to-v8-migration/scripts/ciSchemaCheck.ts new file mode 100644 index 000000000..170d02e44 --- /dev/null +++ b/.agents/skills/deco-v7-to-v8-migration/scripts/ciSchemaCheck.ts @@ -0,0 +1,145 @@ +/** + * `deco schema --check` in the site's CI: the step fails a pull request whose + * `.deco/schema.gen.json` differs from what `deco schema` writes (Studio reads + * that file for the editor forms, so a stale one shows stale forms). It runs + * right after the install step, before a build's `prebuild` regenerates the + * file. Workflows are line-edited (no YAML rewrite), so comments and layout + * stay as they were. + */ +import fs from "node:fs"; +import path from "node:path"; +import type { Report } from "./report"; + +const CHECK = "deco schema --check"; +const INSTALL = + /^(?:(?:bun|pnpm|yarn)\s+install|npm\s+(?:ci|install|i)|yarn(?:\s+--\S+)*\s*$)(?:\s|$)/; +const STEP_NAME = "Check that .deco/schema.gen.json is up to date"; + +/** The folder with the site's git repository: the root itself, or the nearest parent with `.git`. */ +function repoRoot(root: string): string { + for (let dir = root; ; dir = path.dirname(dir)) { + if (fs.existsSync(path.join(dir, ".git"))) return dir; + if (path.dirname(dir) === dir) return root; + } +} + +/** How the site runs a dependency's bin, from its lockfile. */ +function runner(root: string, repo: string): string { + const has = (name: string) => + fs.existsSync(path.join(root, name)) || fs.existsSync(path.join(repo, name)); + if (has("bun.lock") || has("bun.lockb")) return "bunx"; + if (has("pnpm-lock.yaml")) return "pnpm exec"; + if (has("yarn.lock")) return "yarn"; + return "npx"; +} + +const indentOf = (line: string) => line.length - line.trimStart().length; + +/** The `run:` command of the step on or under `line`, when it's a one-line command. */ +function runCommand(line: string): string | undefined { + const m = /^\s*(?:-\s+)?run:\s*(.+?)\s*$/.exec(line); + return m && !/^[|>]/.test(m[1]) ? m[1].replace(/^["']|["']$/g, "") : undefined; +} + +interface Insert { + /** Line index the new step goes before. */ + at: number; + text: string[]; +} + +/** + * Where the check goes in one workflow: after the first install step, with + * that step's indentation and working directory. Undefined when the workflow + * has no one-line install step. + */ +function planInsert( + lines: string[], + command: string, + workingDirectory: string, +): Insert | undefined { + for (let i = 0; i < lines.length; i++) { + const run = runCommand(lines[i]); + if (!run || !INSTALL.test(run) || /\s(?:-g|--global)\b/.test(run)) continue; + // The step's own `- ` line: this one, or the closest above with less indentation. + let start = i; + while (start >= 0 && !/^\s*-\s/.test(lines[start])) start--; + if (start < 0) continue; + const dash = indentOf(lines[start]); + let end = start + 1; + while (end < lines.length) { + const line = lines[end]; + if (line.trim() !== "" && !line.trimStart().startsWith("#") && indentOf(line) <= dash) break; + end++; + } + while (end > start + 1 && lines[end - 1].trim() === "") end--; + const body = " ".repeat(dash + 2); + const dir = lines + .slice(start, end) + .map((l) => /^\s*(?:-\s+)?working-directory:\s*(.+?)\s*$/.exec(l)?.[1]) + .find(Boolean); + const text = [`${" ".repeat(dash)}- name: ${STEP_NAME}`]; + if (dir) text.push(`${body}working-directory: ${dir}`); + // A job's `defaults.run.working-directory` already applies to the new step. + else if (workingDirectory && !lines.some((l) => /^\s*working-directory:/.test(l))) + text.push(`${body}working-directory: ${workingDirectory}`); + text.push(`${body}run: ${command}`); + return { at: end, text }; + } + return undefined; +} + +/** Add `deco schema --check` to the site's CI workflows (the pull-request ones when there are any). */ +export function addSchemaCheck(root: string, report: Report): void { + const repo = repoRoot(root); + const dir = path.join(repo, ".github", "workflows"); + const files = fs.existsSync(dir) + ? fs + .readdirSync(dir) + .filter((f) => /\.ya?ml$/.test(f)) + .sort() + .map((f) => path.join(dir, f)) + : []; + const workflows = files + .map((file) => ({ file, text: fs.readFileSync(file, "utf8") })) + // v7's content regeneration jobs are deleted by hand (reference/manual-steps.md). + .filter(({ text }) => !/generate:blocks|regen-blocks/.test(text)); + const rel = (file: string) => path.relative(repo, file).split(path.sep).join("/"); + const command = `${runner(root, repo)} ${CHECK}`; + const manual = (message: string) => + report.manual.push({ step: "scripts", subject: "CI", message }); + + const already = workflows.filter(({ text }) => text.includes(CHECK)); + if (already.length > 0) { + report.done.push({ + step: "scripts", + subject: "CI", + message: `${already.map((w) => rel(w.file)).join(", ")} already run${already.length === 1 ? "s" : ""} ${CHECK}`, + }); + return; + } + const onPullRequests = workflows.filter(({ text }) => /\bpull_request(?:_target)?\b/.test(text)); + const targets = onPullRequests.length > 0 ? onPullRequests : workflows; + const siteDir = path.relative(repo, root).split(path.sep).join("/"); + const added: string[] = []; + for (const { file, text } of targets) { + const lines = text.split("\n"); + const insert = planInsert(lines, command, siteDir); + if (!insert) continue; + lines.splice(insert.at, 0, ...insert.text); + fs.writeFileSync(file, lines.join("\n")); + added.push(rel(file)); + } + if (added.length > 0) { + report.done.push({ + step: "scripts", + subject: "CI", + message: `${added.join(", ")}: \`${command}\` after the install step, so a pull request whose .deco/schema.gen.json differs from what deco schema writes fails (commit the regenerated file to fix it)`, + }); + return; + } + manual( + targets.length === 0 + ? `no CI workflow found in ${rel(dir)}: run \`${command}\` in the site's CI after installing dependencies, so a stale .deco/schema.gen.json fails the pull request` + : `no one-line install step in ${targets.map((w) => rel(w.file)).join(", ")}: add \`${command}\` after the dependencies are installed, so a stale .deco/schema.gen.json fails the pull request`, + ); +} diff --git a/.agents/skills/deco-v7-to-v8-migration/scripts/framework.ts b/.agents/skills/deco-v7-to-v8-migration/scripts/framework.ts new file mode 100644 index 000000000..af2eba0ee --- /dev/null +++ b/.agents/skills/deco-v7-to-v8-migration/scripts/framework.ts @@ -0,0 +1,61 @@ +/** Which v7 framework binding the site ran on, from its `package.json`. */ +import fs from "node:fs"; +import path from "node:path"; +import { isPlainObject } from "./walk"; + +export type Framework = "tanstack" | "nextjs"; + +/** + * `"tanstack"` when the site depends on `@decocms/tanstack`, `"nextjs"` on + * `@decocms/nextjs`, else `undefined` (a native app, a FastStore store that + * isn't ejected). Each moves to the binding of the same name in v8. + */ +export function siteFramework(root: string): Framework | undefined { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + const has = (name: string) => + ["dependencies", "devDependencies"].some( + (field) => isPlainObject(pkg[field]) && Object.hasOwn(pkg[field], name), + ); + if (has("@decocms/tanstack")) return "tanstack"; + if (has("@decocms/nextjs")) return "nextjs"; + } catch { + // No readable package.json: no binding. + } + return undefined; +} + +/** + * The `@decocms/blocks` 7.x version the site ran: the installed copy's, else + * the lowest version its `package.json` range names; `undefined` when neither + * is a 7.x version. v7's image URLs changed across 7.x releases. + */ +export function siteBlocksVersion(root: string): string | undefined { + const v7 = (v: unknown) => + typeof v === "string" ? /(?:^|[^\d.])(7\.\d+\.\d+)/.exec(v)?.[1] : undefined; + try { + const installed = JSON.parse( + fs.readFileSync( + path.join(root, "node_modules", "@decocms", "blocks", "package.json"), + "utf8", + ), + ); + const version = v7(installed.version); + if (version) return version; + } catch { + // Not installed: the range in package.json. + } + try { + const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + for (const field of ["dependencies", "devDependencies"]) { + const version = isPlainObject(pkg[field]) ? v7(pkg[field]["@decocms/blocks"]) : undefined; + if (version) return version; + } + } catch { + // No readable package.json. + } + return undefined; +} + +/** The v8 version every package of the release shares: blocks and both bindings. */ +export const V8_RANGE = "^8.3.0"; diff --git a/.agents/skills/deco-v7-to-v8-migration/scripts/imageCopy.test.ts b/.agents/skills/deco-v7-to-v8-migration/scripts/imageCopy.test.ts new file mode 100644 index 000000000..3f81a2366 --- /dev/null +++ b/.agents/skills/deco-v7-to-v8-migration/scripts/imageCopy.test.ts @@ -0,0 +1,315 @@ +// @vitest-environment node +/** + * A site's own copy of v7's image helper: the decoCdn options come from the + * copy. The excerpts below are the lines that decide the URLs, verbatim from + * `@decocms/blocks` 7.x `src/hooks/Image.tsx` (7.2.0 to 7.70.x, 7.71.0, 7.71.4, + * 7.71.5/7.77.1), plus a storefront's edited copy (7.20.7 with 7.77.1's CDN + * domain and decoims.com rule, but stripping asset hosts anywhere). Each + * release's whole file, and both storefronts' copies, were fuzzed against + * these options (VTEX, Shopify, Nuvemshop, Deco and other sources, four + * registered domains): no URL differs but fix 2's. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createImageUrl, decoCdn } from "@decocms/blocks/image"; +import { afterEach, describe, expect, it } from "vitest"; +import { + changedByLeadingStrip, + copyDecoCdnOptions, + findImageCopies, + isImageCopy, + leadingStripChanges, + readImageCopy, +} from "./imageCopy"; +import { copyImageUrl, rewriteImports, v7StripsAnywhere } from "./imports"; +import { createReport } from "./report"; + +const PLATFORMS = ` + if (/(vteximg\\.com\\.br|vtexassets\\.com|myvtex\\.com)\\/arquivos\\/ids\\/\\d+/.test(originalSrc)) { + return optimizeVTEX(originalSrc, width, height); + } + if (originalSrc.startsWith("https://cdn.shopify.com")) { + return optimizeShopify(originalSrc, width, height); + }`; +const CDN_PREFIX = ` + const cdnPrefix = \`https://\${imageCdnDomain}/\`; + if (imageSource.startsWith(cdnPrefix)) { + imageSource = imageSource.slice(cdnPrefix.length); + }`; +const ANYWHERE = `const DECO_CACHE_URL = "https://assets.decocache.com/"; +const S3_URL = "https://deco-sites-assets.s3.sa-east-1.amazonaws.com/";`; +const STRIP_ANYWHERE = `let imageSource = originalSrc.replace(DECO_CACHE_URL, "").replace(S3_URL, "").split("?")[0];`; +const DECOIMS_CLOUDFLARE = `const DECOIMS_PREFIX_RE = /^https:\\/\\/decoims\\.com\\//; +const CLOUDFLARE_CDNS = new Set(["assets.decocms.com", "decoims.com"]);`; + +const copy = (head: string, domain: string, strip: string, extra = "") => ` +import { forwardRef } from "react"; +${head} +let imageCdnDomain = "${domain}"; +export function registerImageCdnDomain(domain: string) { + imageCdnDomain = domain.replace(/^https?:\\/\\//, "").replace(/\\/+$/, ""); +} +/** \`registerImageQuality("80")\` — quality means 1-100 elsewhere. */ +let imageQuality: ImageQuality | undefined; +export function registerImageQuality(quality: ImageQuality | undefined) { + imageQuality = quality || undefined; +} +export function getOptimizedMediaUrl(opts: OptimizationOptions): string { + const { originalSrc, width, height, fit } = opts; + if (originalSrc.startsWith("data:")) return originalSrc;${PLATFORMS}${extra} + ${strip}${CDN_PREFIX} + return \`https://\${imageCdnDomain}/image?\${params}&src=\${imageSource}\`; +}`; + +const RELEASES = { + "7.2.0": copy(ANYWHERE, "decoims.com", STRIP_ANYWHERE), + "7.71.0": copy(ANYWHERE, "assets.decocms.com", STRIP_ANYWHERE), + "7.71.4": copy( + "const DECO_ASSET_PREFIX_RE =\n\t/^https:\\/\\/(?:assets\\.decocache\\.com|deco-sites-assets\\.s3\\.sa-east-1\\.amazonaws\\.com|decoims\\.com)\\//;", + "assets.decocms.com", + 'let imageSource = originalSrc.replace(DECO_ASSET_PREFIX_RE, "").split("?")[0];', + ), + "7.77.1": copy( + `const DECO_ASSET_PREFIX_RE =\n\t/^https:\\/\\/(?:assets\\.decocache\\.com|deco-sites-assets\\.s3\\.sa-east-1\\.amazonaws\\.com)\\//;\n${DECOIMS_CLOUDFLARE}`, + "assets.decocms.com", + 'let imageSource = originalSrc.replace(DECO_ASSET_PREFIX_RE, "").split("?")[0];\n\tif (CLOUDFLARE_CDNS.has(imageCdnDomain)) imageSource = imageSource.replace(DECOIMS_PREFIX_RE, "");', + "\n\tconst nuvemshop = originalSrc.match(NUVEMSHOP_IMAGE_RE); // *.mitiendanube.com\n\tif (nuvemshop) return optimizeNuvemshop(nuvemshop, width);", + ), + edited: copy( + `${ANYWHERE}\n${DECOIMS_CLOUDFLARE}`, + "assets.decocms.com", + `${STRIP_ANYWHERE}\n\tif (CLOUDFLARE_CDNS.has(imageCdnDomain)) imageSource = imageSource.replace(DECOIMS_PREFIX_RE, "");`, + ), +}; + +const options = (text: string, registered = {}) => { + const rules = readImageCopy(text); + if (!rules) throw new Error("not read"); + return copyDecoCdnOptions(rules, registered); +}; + +describe("a site's copy of v7's image helper", () => { + it("is told apart from code that only uses the helper", () => { + for (const text of Object.values(RELEASES)) expect(isImageCopy(text)).toBe(true); + expect(isImageCopy('import { getOptimizedMediaUrl } from "@decocms/blocks/hooks";')).toBe( + false, + ); + expect(isImageCopy("const url = getOptimizedMediaUrl({ originalSrc });")).toBe(false); + }); + + it("gives the options each release's rules need, as the per-release table does", () => { + expect(options(RELEASES["7.2.0"])).toBe('domain: "decoims.com", sources: "all"'); + expect(options(RELEASES["7.71.0"])).toBe('sources: "all", legacyHosts: []'); + expect(options(RELEASES["7.71.4"])).toBe('sources: "all"'); + expect(options(RELEASES["7.77.1"])).toBe('sources: "all"'); + expect(readImageCopy(RELEASES["7.77.1"])?.nuvemshop).toBe(true); + expect(readImageCopy(RELEASES["7.71.4"])?.nuvemshop).toBe(false); + }); + + it("follows the domain and quality the site registers, and what each CDN needs for decoims.com", () => { + const azion = { domain: "deco-assets.edgedeco.com" }; + expect(options(RELEASES["7.2.0"], azion)).toBe( + 'domain: "deco-assets.edgedeco.com", sources: "all"', + ); + expect(options(RELEASES["7.71.4"], azion)).toBe( + 'domain: "deco-assets.edgedeco.com", sources: "all", legacyHosts: ["decoims.com"]', + ); + expect(options(RELEASES["7.77.1"], { domain: "decoims.com", quality: "high" })).toBe( + 'domain: "decoims.com", quality: "high", sources: "all"', + ); + expect(options(RELEASES["7.71.0"], { domain: null, quality: null })).toBe( + 'domain, quality, sources: "all", legacyHosts: []', + ); + }); + + it("an edited copy keeps its own rules, not its release's", () => { + // 7.20.7's anywhere-stripping with 7.77.1's domain and decoims.com rule: decoCdn's defaults. + expect(options(RELEASES.edited)).toBe('sources: "all"'); + const quality = RELEASES["7.2.0"].replace( + "let imageQuality: ImageQuality | undefined;", + 'let imageQuality: ImageQuality | undefined = "high";', + ); + expect(options(quality)).toBe('domain: "decoims.com", quality: "high", sources: "all"'); + expect(readImageCopy(RELEASES["7.2.0"].replace(/let imageCdnDomain = [^;]+;/, ""))).toBeNull(); + expect(readImageCopy(RELEASES["7.2.0"].replace(CDN_PREFIX, ""))).toBeNull(); + }); +}); + +describe("the report for a site with its own copy", () => { + let root: string | undefined; + afterEach(() => root && fs.rmSync(root, { recursive: true, force: true })); + + function site(files: Record): string { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "deco-v7-image-copy-"))); + for (const [file, data] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), data); + } + return root; + } + + it("reads the options from the copy and the site's register calls, over the release's", () => { + site({ + // The release table would give 7.77.1 decoims.com on the Cloudflare CDNs only. + "package.json": JSON.stringify({ + dependencies: { "@decocms/blocks": "7.77.1", "@decocms/tanstack": "7.77.1" }, + }), + "src/vendor/blocks/Image.tsx": RELEASES.edited, + "src/setup.ts": + 'import { registerImageCdnDomain, registerImageQuality } from "./vendor/blocks/Image";\n// registerImageQuality("80") would serve the lowest quality\nregisterImageCdnDomain("decoims.com");\nregisterImageQuality("high");\n', + "src/Banner.tsx": 'import { Picture } from "@decocms/blocks/hooks";\n', + }); + const found = findImageCopies(root as string); + expect(found.copies.map((c) => c.file)).toEqual(["src/vendor/blocks/Image.tsx"]); + expect(found.registered).toEqual({ domain: "decoims.com", quality: "high" }); + + const report = createReport(); + rewriteImports(root as string, report, new Map()); + const note = (subject: string) => report.manual.find((n) => n.subject === subject)?.message; + const want = + 'createImageUrl({ providers: [vtexCatalog(), shopifyCdn(), decoCdn({ domain: "decoims.com", quality: "high", sources: "all" })] })'; + expect(note("src/vendor/blocks/Image.tsx")).toContain(want); + expect(note("src/vendor/blocks/Image.tsx")).toContain("re-resized from its src"); + expect(note("Image, Picture, Source")).toContain(want); + expect(note("Image, Picture, Source")).not.toContain("stripAssetHosts"); + }); + + it("lists a copy's Nuvemshop provider, and says when it can't read a copy", () => { + expect( + copyImageUrl({ file: "src/Image.tsx", rules: readImageCopy(RELEASES["7.77.1"]) }), + ).toContain('providers: [vtexCatalog(), shopifyCdn(), nuvemshop, decoCdn({ sources: "all" })]'); + site({ + "package.json": JSON.stringify({ dependencies: { "@decocms/blocks": "7.64.3" } }), + "src/sdk/image.ts": RELEASES["7.2.0"].replace(/let imageCdnDomain = [^;]+;/, ""), + }); + const report = createReport(); + rewriteImports(root as string, report, new Map()); + expect(report.manual.find((n) => n.subject === "src/sdk/image.ts")?.message).toContain( + "can't read", + ); + }); + + it("a site without a copy keeps the release's options", () => { + site({ + "package.json": JSON.stringify({ + dependencies: { "@decocms/blocks": "7.77.1", "@decocms/tanstack": "7.77.1" }, + }), + "src/Banner.tsx": 'import { Picture } from "@decocms/blocks/hooks";\n', + }); + const report = createReport(); + rewriteImports(root as string, report, new Map()); + expect(report.manual.find((n) => n.subject === "Image, Picture, Source")?.message).toContain( + 'decoCdn({ domain, quality, sources: "all" })', + ); + }); +}); + +describe("strip-anywhere sites on decoCdn's leading default (PO 2026-10-15)", () => { + let root: string | undefined; + afterEach(() => root && fs.rmSync(root, { recursive: true, force: true })); + + function site(files: Record): string { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "deco-v7-leading-strip-"))); + for (const [file, data] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), data); + } + return root; + } + + const NESTED = "https://images.example.com/https://assets.decocache.com/a-store/abc/x.png"; + const NESTED_S3 = + "https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a/https://assets.decocache.com/b.png"; + const SOURCES = [ + "https://assets.decocache.com/a-store/abc/x.png", + "https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-store/x.png", + "https://assets.decocache.com/a/https://assets.decocache.com/b.png", + NESTED, + NESTED_S3, + "https://cdn.example.com/x.png?v=https://assets.decocache.com/", + "https://cdn.example.com/x.png", + "/static/x.png", + ]; + + it("flags exactly the sources whose decoCdn URL changes", () => { + const leading = createImageUrl({ providers: [decoCdn({ sources: "all" })] }); + const anywhere = createImageUrl({ + providers: [decoCdn({ sources: "all", stripAssetHosts: "anywhere" })], + }); + const changed = SOURCES.filter( + (src) => leading(src, { width: 200 }) !== anywhere(src, { width: 200 }), + ); + expect(changed).toEqual([NESTED, NESTED_S3]); + expect(SOURCES.filter(changedByLeadingStrip)).toEqual(changed); + }); + + it("knows which 7.x releases stripped anywhere", () => { + expect(["7.2.0", "7.64.3", "7.71.0", "7.71.3"].map(v7StripsAnywhere)).toEqual([ + true, + true, + true, + true, + ]); + expect(["7.71.4", "7.71.5", "7.77.1", "8.0.0", undefined].map(v7StripsAnywhere)).toEqual([ + false, + false, + false, + false, + false, + ]); + expect(readImageCopy(RELEASES.edited)?.stripAssetHosts).toBe("anywhere"); + }); + + it("counts and lists the URLs in the content and code that change", () => { + site({ + "package.json": JSON.stringify({ dependencies: { "@decocms/blocks": "7.77.1" } }), + "src/vendor/blocks/Image.tsx": RELEASES.edited, + ".deco/blocks/pages-home.json": JSON.stringify({ + sections: [ + { image: NESTED, other: "https://assets.decocache.com/a-store/abc/x.png" }, + { html: ``, again: NESTED }, + ], + }), + "src/sections/Hero.tsx": `export const fallback = "${NESTED}";\n`, + }); + expect(leadingStripChanges(root as string)).toEqual([ + { file: ".deco/blocks/pages-home.json", url: NESTED }, + { file: ".deco/blocks/pages-home.json", url: NESTED_S3 }, + { file: "src/sections/Hero.tsx", url: NESTED }, + ]); + const report = createReport(); + rewriteImports(root as string, report, new Map()); + const note = report.manual.find((n) => n.subject === "image URLs")?.message ?? ""; + expect(note).toContain("src/vendor/blocks/Image.tsx"); + expect(note).toContain("3 URLs in 2 files change"); + expect(note).toContain(`${NESTED} (src/sections/Hero.tsx)`); + expect(note).toContain("PO 2026-10-15"); + expect(report.done.find((n) => n.subject === "image URLs")).toBeUndefined(); + }); + + it("says so when no URL changes, and says nothing for a site that already stripped only a leading host", () => { + site({ + "package.json": JSON.stringify({ dependencies: { "@decocms/blocks": "7.64.3" } }), + ".deco/blocks/pages-home.json": JSON.stringify({ + image: "https://assets.decocache.com/a/x.png", + }), + }); + let report = createReport(); + rewriteImports(root as string, report, new Map()); + expect(report.done.find((n) => n.subject === "image URLs")?.message).toContain( + "No URL in .deco/blocks or src/ changes", + ); + fs.writeFileSync( + path.join(root as string, "package.json"), + JSON.stringify({ dependencies: { "@decocms/blocks": "7.71.5" } }), + ); + fs.writeFileSync( + path.join(root as string, ".deco/blocks/pages-home.json"), + JSON.stringify({ image: NESTED }), + ); + report = createReport(); + rewriteImports(root as string, report, new Map()); + expect([...report.done, ...report.manual].some((n) => n.subject === "image URLs")).toBe(false); + }); +}); diff --git a/.agents/skills/deco-v7-to-v8-migration/scripts/imageCopy.ts b/.agents/skills/deco-v7-to-v8-migration/scripts/imageCopy.ts new file mode 100644 index 000000000..fa0480064 --- /dev/null +++ b/.agents/skills/deco-v7-to-v8-migration/scripts/imageCopy.ts @@ -0,0 +1,276 @@ +/** + * A site's own copy of v7's image helper (`hooks/Image.tsx` from + * `@decocms/blocks` 7.x, vendored into `src/` and sometimes edited): its URLs + * are what the site serves, so the `decoCdn` options that reproduce them are + * read from the copy, not from the release the site's `package.json` names. + * The rules read are the ones that changed across 7.x releases (or that a + * site edited): the CDN domain and quality it starts with, whether the Deco + * asset hosts are stripped anywhere or only at the start, how decoims.com + * sources are treated, and Nuvemshop's fixed sizes. + */ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const SOURCE = /\.(tsx?|mts|cts)$/; +const CLOUDFLARE_CDNS = new Set(["assets.decocms.com", "decoims.com"]); +const DEFAULT_DOMAIN = "assets.decocms.com"; + +export interface ImageCopyRules { + /** `imageCdnDomain`'s initial value. */ + domain: string; + /** `imageQuality`'s initial value, if any. */ + quality?: string; + /** `"anywhere"`: `.replace(DECO_CACHE_URL, "")`; `"leading"`: an anchored regex. */ + stripAssetHosts: "anywhere" | "leading"; + /** + * decoims.com sources: `"none"` (kept whole), `"cloudflare"` (stripped on + * the Cloudflare CDNs, v7 from 7.71.5), `"always"` (in the anchored asset + * regex, v7 7.71.4). + */ + decoims: "none" | "cloudflare" | "always"; + /** v7's VTEX catalog rule (`vtexCatalog()`), Shopify's (`shopifyCdn()`), Nuvemshop's fixed sizes. */ + vtex: boolean; + shopify: boolean; + nuvemshop: boolean; +} + +export interface ImageCopy { + /** Path from the site root. */ + file: string; + /** The rules read from the copy, or null when it doesn't have v7's shape. */ + rules: ImageCopyRules | null; +} + +const stripScheme = (d: string) => d.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + +/** Whether `text` defines v7's image helper (not just imports it). */ +export function isImageCopy(text: string): boolean { + return ( + /(?:function\s+getOptimizedMediaUrl\b|\bgetOptimizedMediaUrl\s*=\s*(?:\(|function))/.test( + text, + ) && /assets\\?\.decocache\\?\.com/.test(text) + ); +} + +/** The URL rules of a copy of v7's image helper, or null when they can't be read. */ +export function readImageCopy(text: string): ImageCopyRules | null { + const domain = /\blet\s+imageCdnDomain(?:\s*:\s*[^=]+)?\s*=\s*["'`]([^"'`]+)["'`]/.exec( + text, + )?.[1]; + if (!domain) return null; + // The CDN's own URLs are always shown relative; a copy without that check is not v7's. + if (!/startsWith\(\s*(?:cdnPrefix|`https:\/\/\$\{imageCdnDomain\}\/`)\s*\)/.test(text)) + return null; + const anchored = /\^https:\\\/\\\/\(\?:assets\\\.decocache\\\.com[^/\n]*/.exec(text)?.[0]; + const anywhere = + /\.replace\(\s*(?:DECO_CACHE_URL|["'`]https:\/\/assets\.decocache\.com\/["'`])\s*,/.test(text); + if (!anchored && !anywhere) return null; + const quality = /\blet\s+imageQuality(?:\s*:\s*[^=]+)?\s*=\s*["'`](\w+)["'`]/.exec(text)?.[1]; + const decoims = + anchored?.includes("decoims\\.com") === true + ? "always" + : /CLOUDFLARE_CDNS\.has\(\s*imageCdnDomain\s*\)/.test(text) && /decoims\\\.com/.test(text) + ? "cloudflare" + : /decoims\\\.com/.test(text) + ? null + : "none"; + if (decoims === null) return null; + return { + domain: stripScheme(domain), + ...(quality ? { quality } : {}), + stripAssetHosts: anchored ? "leading" : "anywhere", + decoims, + vtex: /vteximg|vtexassets/.test(text), + shopify: text.includes("cdn.shopify.com"), + nuvemshop: text.includes("mitiendanube"), + }; +} + +/** + * The `decoCdn` options (besides the providers' order) that reproduce a + * copy's URLs. `registered` is what the site passes the copy's + * `registerImageCdnDomain`/`registerImageQuality` (a string literal), which + * overrides the copy's initial values; `null` when it passes something else, + * left as `domain`/`quality` for a person to fill in. + */ +export function copyDecoCdnOptions( + rules: ImageCopyRules, + registered: { domain?: string | null; quality?: string | null } = {}, +): string { + const parts: string[] = []; + const domain = registered.domain === undefined ? rules.domain : registered.domain; + if (domain === null) parts.push("domain"); + else if (stripScheme(domain) !== DEFAULT_DOMAIN) parts.push(`domain: "${stripScheme(domain)}"`); + const quality = registered.quality === undefined ? rules.quality : registered.quality; + if (quality === null) parts.push("quality"); + else if (quality) parts.push(`quality: "${quality}"`); + parts.push('sources: "all"'); + // stripAssetHosts is left at decoCdn's default, "leading", for every copy: a + // copy that stripped the asset hosts anywhere moves to "leading" too + // (approved, PO 2026-10-15), and `leadingStripChanges` counts the URLs that changes. + // decoCdn's default: decoims.com on the Cloudflare CDNs (but not the CDN's own host). + if (domain !== null) { + const host = stripScheme(domain); + const fallback = CLOUDFLARE_CDNS.has(host) && host !== "decoims.com" ? ["decoims.com"] : []; + const copy = + rules.decoims === "none" + ? [] + : rules.decoims === "always" + ? host === "decoims.com" + ? [] + : ["decoims.com"] + : fallback; + if (copy.join() !== fallback.join()) parts.push(`legacyHosts: ${JSON.stringify(copy)}`); + } else if (rules.decoims !== "cloudflare") { + parts.push(rules.decoims === "none" ? "legacyHosts: []" : 'legacyHosts: ["decoims.com"]'); + } + return parts.join(", "); +} + +function listSources(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((d) => d.isFile() && SOURCE.test(d.name) && !d.name.endsWith(".d.ts")) + .map((d) => path.join(d.parentPath, d.name)) + .filter((f) => !f.split(path.sep).includes("node_modules") && !/\.test\.tsx?$/.test(f)) + .sort(); +} + +/** + * What the site's code passes to `name(…)`: the string literal, null for + * anything else (or different values), undefined when it is never called. + */ +function registeredValue(files: ts.SourceFile[], name: string): string | null | undefined { + const values = new Set(); + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + const called = ts.isIdentifier(callee) + ? callee.text + : ts.isPropertyAccessExpression(callee) + ? callee.name.text + : undefined; + if (called === name) { + const [arg] = node.arguments; + values.add(arg && ts.isStringLiteralLike(arg) ? arg.text : null); + } + } + ts.forEachChild(node, visit); + }; + for (const file of files) visit(file); + if (values.size === 0) return undefined; + return values.size === 1 ? [...values][0] : null; +} + +/** The site's copies of v7's image helper under `src/`, in path order. */ +export function findImageCopies(root: string): { + copies: ImageCopy[]; + registered: { domain?: string | null; quality?: string | null }; +} { + const parsed: ts.SourceFile[] = []; + const copies: ImageCopy[] = []; + for (const file of listSources(path.join(root, "src"))) { + const text = fs.readFileSync(file, "utf8"); + if (/registerImage(?:CdnDomain|Quality)\(/.test(text)) { + parsed.push( + ts.createSourceFile( + file, + text, + ts.ScriptTarget.Latest, + false, + file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ), + ); + } + if (isImageCopy(text)) { + copies.push({ + file: path.relative(root, file).split(path.sep).join("/"), + rules: readImageCopy(text), + }); + } + } + return { + copies, + registered: { + domain: registeredValue(parsed, "registerImageCdnDomain"), + quality: registeredValue(parsed, "registerImageQuality"), + }, + }; +} + +const DECO_CACHE_URL = "https://assets.decocache.com/"; +const DECO_S3_URL = "https://deco-sites-assets.s3.sa-east-1.amazonaws.com/"; +const LEADING_ASSET_HOST = + /^https:\/\/(?:assets\.decocache\.com|deco-sites-assets\.s3\.sa-east-1\.amazonaws\.com)\//; +const URL_IN_TEXT = /https?:\/\/[^\s"'`<>()\\]+/g; + +/** + * Whether decoCdn's `stripAssetHosts: "leading"` (its default) gives `src` a + * different source than v7's strip-anywhere did (`.replace(DECO_CACHE_URL, "")`, + * then the S3 bucket): true for a URL that merely contains an asset URL, + * such as `https://other.host/https://assets.decocache.com/…`. + */ +export function changedByLeadingStrip(src: string): boolean { + // Both drop the query afterwards, as decoCdn does. + const anywhere = src.replace(DECO_CACHE_URL, "").replace(DECO_S3_URL, "").split("?")[0]; + return anywhere !== src.replace(LEADING_ASSET_HOST, "").split("?")[0]; +} + +export interface LeadingStripChange { + /** Path from the site root. */ + file: string; + url: string; +} + +function stringsOf(value: unknown, out: string[]): string[] { + if (typeof value === "string") out.push(value); + else if (Array.isArray(value)) for (const v of value) stringsOf(v, out); + else if (value && typeof value === "object") + for (const v of Object.values(value)) stringsOf(v, out); + return out; +} + +/** + * The URLs in the saved content (`.deco/blocks`) and in `src/` whose image + * source changes when the asset hosts are stripped only at the start, for a + * site whose v7 image code stripped them anywhere. One entry per URL and file. + */ +export function leadingStripChanges(root: string): LeadingStripChange[] { + const found: LeadingStripChange[] = []; + const seen = new Set(); + const add = (file: string, texts: string[]) => { + const rel = path.relative(root, file).split(path.sep).join("/"); + for (const text of texts) { + if (!text.includes(DECO_CACHE_URL) && !text.includes(DECO_S3_URL)) continue; + for (const [url] of text.matchAll(URL_IN_TEXT)) { + const key = `${rel}\0${url}`; + if (seen.has(key) || !changedByLeadingStrip(url)) continue; + seen.add(key); + found.push({ file: rel, url }); + } + } + }; + const blocks = path.join(root, ".deco", "blocks"); + if (fs.existsSync(blocks)) { + const files = fs + .readdirSync(blocks, { recursive: true, withFileTypes: true }) + .filter((d) => d.isFile() && d.name.endsWith(".json")) + .map((d) => path.join(d.parentPath, d.name)) + .sort(); + for (const file of files) { + const text = fs.readFileSync(file, "utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + add(file, stringsOf(parsed, [])); + } + } + for (const file of listSources(path.join(root, "src"))) + add(file, [fs.readFileSync(file, "utf8")]); + return found; +} diff --git a/.agents/skills/deco-v7-to-v8-migration/scripts/imports.ts b/.agents/skills/deco-v7-to-v8-migration/scripts/imports.ts index 0e5e02792..0ccd72dd1 100644 --- a/.agents/skills/deco-v7-to-v8-migration/scripts/imports.ts +++ b/.agents/skills/deco-v7-to-v8-migration/scripts/imports.ts @@ -7,15 +7,29 @@ * points at the copy in `src/vendor`; * - `createInstrumentedFetch` from `@decocms/blocks/sdk/instrumentedFetch` * comes from `@decocms/blocks/fetch`, and `createInstrumentedFetch("x")` - * becomes `createInstrumentedFetch({ provider: "x" })`. + * becomes `createInstrumentedFetch({ provider: "x" })`; + * - names a v8 binding exports with v7's props and behaviour move to it + * (`MOVES`): `createDecoRouter` to `@decocms/tanstack/router`, + * `NavigationProgress` and, on a TanStack site, `Image`/`Picture`/`Source` + * to `@decocms/tanstack/react`, and so on; a name whose home depends on the + * binding (the draft names and Studio's origins: `@decocms/tanstack` or + * `@decocms/nextjs`, never `@decocms/blocks`) moves to the site's. An import + * declaration that mixes moved and other names is split. * - * Every other v7 import is reported with where its replacement lives; none - * is shimmed. Imports of the next major's documented entry points are left - * alone. + * Every other v7 import is reported with where its replacement lives, name by + * name where the bindings replace them (`NAME_HINTS`); none is shimmed. + * Imports of the next major's documented entry points are left alone. */ import fs from "node:fs"; import path from "node:path"; import ts from "typescript"; +import { type Framework, siteBlocksVersion, siteFramework } from "./framework"; +import { + copyDecoCdnOptions, + findImageCopies, + type ImageCopy, + leadingStripChanges, +} from "./imageCopy"; import type { Report } from "./report"; import { relativeSpecifier, resolvePackageFile } from "./vendor"; @@ -59,8 +73,322 @@ export const V8_API: Record | "*"> = { "@decocms/blocks/analytics": "*", "@decocms/blocks/secrets": "*", "@decocms/blocks/cli": "*", + // Platform-neutral image URLs only; a platform's URLs are its client's /image. + "@decocms/blocks/image": new Set([ + "createImageUrl", + "decoCdn", + "DecoCdnOptions", + "Fit", + "ImageProvider", + "ImageUrl", + "parseSrcSet", + "PreloadLink", + "PreloadOptions", + "preloadLink", + "Quality", + "QualityTier", + "ResizeOptions", + "SrcSetOptions", + "srcSet", + ]), + // The platforms' image URL helpers (pure functions, for createImageUrl). v7 had no such subpath. + "@decocms/apps-vtex/image": "*", + "@decocms/apps-shopify/image": "*", + "@decocms/blocks/vite": "*", + // v7 had a /preview too, with other names (DraftPreviewBadge). + "@decocms/blocks/preview": new Set([ + "DraftBadge", + "DraftBadgeProps", + "StudioBridge", + "StudioBridgeProps", + "StudioPage", + "StudioPageInfo", + ]), + // The v8 bindings. Subpaths v7 never had are whole; the rest list v8's names. + // The draft names and Studio's origins live in each binding's root, not in @decocms/blocks. + "@decocms/tanstack": new Set([ + "DRAFT_COOKIE", + "DRAFT_HEADER", + "DRAFT_PARAM", + "draftResponseHeaders", + "isStudioOrigin", + "STUDIO_FRAME_ANCESTORS", + "STUDIO_ORIGINS", + ]), + "@decocms/tanstack/worker": "*", + "@decocms/tanstack/server": "*", + "@decocms/tanstack/start": "*", + "@decocms/tanstack/router": "*", + "@decocms/tanstack/react": "*", + "@decocms/tanstack/scope": "*", + "@decocms/tanstack/vite": new Set([ + "decoTanstack", + "DecoTanstackOptions", + "gitBuildHash", + "resolveBuildHash", + ]), + "@decocms/nextjs/proxy": "*", + "@decocms/nextjs/server": "*", + "@decocms/nextjs/image-loader": "*", + // v7's DraftPreviewIndicator kept its name but now shows what bindDraft bound: reported. + "@decocms/nextjs": new Set([ + "DRAFT_COOKIE", + "DRAFT_HEADER", + "DRAFT_PARAM", + "DraftBadge", + "DraftPreviewIndicatorProps", + "draftResponseHeaders", + "isStudioOrigin", + "STUDIO_FRAME_ANCESTORS", + "STUDIO_ORIGINS", + "StudioBridge", + "StudioBridgeProps", + "StudioPage", + "StudioPageInfo", + ]), + "@decocms/nextjs/config": new Set([ + "DECO_DRAFT_HEADERS", + "HeaderRule", + "STUDIO_DEV_ORIGINS", + "withDeco", + "WithDecoOptions", + ]), +}; + +const IMAGE_COMPONENTS = [ + "Image", + "ImageProps", + "Picture", + "PictureProps", + "Source", + "SourceProps", +]; + +/** The draft names and Studio's origins: each binding's root exports them, `@decocms/blocks` doesn't. */ +export const BINDING_NAMES = [ + "DRAFT_COOKIE", + "DRAFT_HEADER", + "DRAFT_PARAM", + "draftResponseHeaders", + "isStudioOrigin", + "STUDIO_FRAME_ANCESTORS", + "STUDIO_ORIGINS", +]; + +/** A move's target: one entry point, or one per binding. */ +type MoveTarget = string | Partial>; + +/** + * v7 names a v8 binding exports with the same props and behaviour, by v7 + * specifier: the codemod points the import at the binding. `only` limits a + * move to sites on that binding; a per-binding target moves a name only on a + * site with one of those bindings. + */ +export const MOVES: Record }> = { + "@decocms/blocks": { + names: Object.fromEntries( + BINDING_NAMES.map((n) => [n, { tanstack: "@decocms/tanstack", nextjs: "@decocms/nextjs" }]), + ), + }, + "@decocms/tanstack": { + names: { + createDecoRouter: "@decocms/tanstack/router", + decoParseSearch: "@decocms/tanstack/router", + decoStringifySearch: "@decocms/tanstack/router", + NavigationProgress: "@decocms/tanstack/react", + StableOutlet: "@decocms/tanstack/react", + }, + }, + "@decocms/tanstack/sdk/serverFnFetch": { + names: { decoServerFnFetch: "@decocms/tanstack/start" }, + }, + "@decocms/blocks/hooks": { + only: "tanstack", + names: Object.fromEntries(IMAGE_COMPONENTS.map((n) => [n, "@decocms/tanstack/react"])), + }, }; +const IMAGE_HELPERS = + "createImageUrl and decoCdn from @decocms/blocks/image, vtexCatalog from @decocms/apps-vtex/image, shopifyCdn from @decocms/apps-shopify/image; keep both helpers whatever the site's platform, since v7 resized VTEX and Shopify images on every site"; + +/** + * v7's image URLs as v8 code, for the `@decocms/blocks` 7.x version the site + * ran: the platform helpers (their client packages' /image) before the Deco + * CDN, with the `decoCdn` options that reproduce that release's `Image` + * (checked against each release's `hooks/Image.tsx`): + * - before 7.71.0: decoims.com was the default domain, every Deco asset host + * was stripped wherever it was, and no legacy host was stripped; + * - 7.71.0 to 7.71.3: assets.decocms.com became the default, the rest alike; + * - 7.71.4: only a leading asset host is stripped, and decoims.com on any CDN; + * decoCdn's default since 8.3 ("leading"), which the PO approved for the + * older releases too (2026-10-15): `rewriteImports` reports the URLs it changes; + * - 7.71.5 on: decoims.com only on the Cloudflare CDNs (decoCdn's default); + * - 7.77.1 on: Nuvemshop product images got the CDN's fixed sizes too, which + * no @decocms/apps-* package covers: such a store lists its own provider. + */ +export function v7ImageUrl(version: string | undefined): string { + const [major, minor, patch] = (version ?? "").split(".").map(Number); + const at = (m: number, p: number) => major === 7 && (minor > m || (minor === m && patch >= p)); + const nuvemshop = + "; a store with Nuvemshop images lists its own provider before decoCdn (reference/framework-code.md)"; + const providers = (deco: string) => + `createImageUrl({ providers: [vtexCatalog(), shopifyCdn(), decoCdn({ ${deco} })] })`; + const registered = + "domain and quality as the site registered them with registerImageCdnDomain/registerImageQuality, else leave them out"; + if (major !== 7 || Number.isNaN(minor)) { + return `${providers('domain, quality, sources: "all"')} (${IMAGE_HELPERS}; ${registered}; that is the latest 7.x, and reference/framework-code.md lists the decoCdn options for older 7.x releases${nuvemshop})`; + } + if (!at(71, 0)) { + return `${providers('domain, quality, sources: "all", legacyHosts: []')} (${IMAGE_HELPERS}; domain as the site registered it with registerImageCdnDomain, else "decoims.com", the default before 7.71.0; quality as registered with registerImageQuality, else leave it out)`; + } + if (!at(71, 4)) { + return `${providers('domain, quality, sources: "all", legacyHosts: []')} (${IMAGE_HELPERS}; ${registered})`; + } + if (!at(71, 5)) { + return `${providers('domain, quality, sources: "all", legacyHosts: ["decoims.com"]')} (${IMAGE_HELPERS}; ${registered})`; + } + return `${providers('domain, quality, sources: "all"')} (${IMAGE_HELPERS}; ${registered}${at(77, 1) ? nuvemshop : ""})`; +} + +/** Whether the `@decocms/blocks` 7.x release's `Image` stripped the asset hosts anywhere (before 7.71.4). */ +export function v7StripsAnywhere(version: string | undefined): boolean { + const [major, minor, patch] = (version ?? "").split(".").map(Number); + if (major !== 7 || Number.isNaN(minor)) return false; + return minor < 71 || (minor === 71 && !(patch >= 4)); +} + +/** + * v7's image URLs as v8 code for a site that kept its own copy of v7's image + * helper: the options are read from the copy (and what the site registers + * with it), since the copy's URLs, not the release's, are what the site serves. + */ +export function copyImageUrl( + copy: ImageCopy, + registered: { domain?: string | null; quality?: string | null } = {}, +): string | undefined { + const rules = copy.rules; + if (!rules) return undefined; + const providers = [ + rules.vtex ? "vtexCatalog()" : "", + rules.shopify ? "shopifyCdn()" : "", + rules.nuvemshop ? "nuvemshop" : "", + `decoCdn({ ${copyDecoCdnOptions(rules, registered)} })`, + ].filter(Boolean); + const helpers = [ + "createImageUrl and decoCdn from @decocms/blocks/image", + rules.vtex ? "vtexCatalog from @decocms/apps-vtex/image" : "", + rules.shopify ? "shopifyCdn from @decocms/apps-shopify/image" : "", + rules.nuvemshop + ? "nuvemshop: the store's own provider for v7's Nuvemshop rule (reference/framework-code.md)" + : "", + ].filter(Boolean); + return `createImageUrl({ providers: [${providers.join(", ")}] }) (${helpers.join(", ")}; read from the site's copy of v7's image helper, ${copy.file}: these options reproduce its URLs, which win over its 7.x release's where they differ)`; +} + +/** Stands for `v7ImageUrl()` in a hint, replaced when reported. */ +const V7_IMAGE_URL = "\u0001v7-image-url\u0001"; + +/** The hints for names the bindings replace, by v7 specifier: checked before `HINTS`. */ +const NAME_HINTS: { specifier: RegExp; names: string[]; hint: string }[] = [ + { + specifier: /^@decocms\/tanstack$/, + names: ["createDecoWorkerEntry"], + hint: "createDecoWorker({ serverEntry, cms, site, buildHash }) from @decocms/tanstack/worker: drafts and their host allowlist, the edge cache, liveness, Studio framing, CMS redirects and cookies are built in; what the site's entry did differently goes in its options (segment, csp, cacheProfiles, preload, routes)", + }, + { + specifier: /^@decocms\/tanstack$/, + names: ["DecoRootLayout", "PreviewProviders", "DraftPreviewIndicator"], + hint: "DecoDocument from @decocms/tanstack/react, the root route's component: it mounts StudioBridge and DraftBadge", + }, + { + specifier: /^@decocms\/tanstack$/, + names: [ + "cmsRouteConfig", + "cmsHomeRouteConfig", + "loadCmsPage", + "loadCmsHomePage", + "CmsPage", + "NotFoundPage", + "withSiteGlobals", + "DecoPageRenderer", + "SectionRenderer", + "SectionList", + "loadDeferredSection", + ], + hint: "render pages with createCMS and matchRoute (/next/tanstack-start-descriptors), reading the draft the worker accepted with contentClient(cms) from @decocms/tanstack/server", + }, + { + specifier: /^@decocms\/tanstack$/, + names: ["decoInvokeRouteConfig", "decoMetaRouteConfig", "decoRenderRouteConfig"], + hint: "v7's admin and invoke routes: delete them (createDecoWorker answers 404 on v7's admin paths; /deco/invoke becomes server functions)", + }, + { + specifier: /^@decocms\/tanstack$/, + names: ["setupTanstackFastDeploy", "kvLoader"], + hint: "fast deploy is the hosted CMS: createCMS({ site, token }) (/next/content#create-the-cms)", + }, + { + specifier: /^@decocms\/nextjs$/, + names: [ + "createDecoPage", + "createDecoPreviewPage", + "DecoPageRenderer", + "SectionRenderer", + "DeferredSectionBoundary", + "ClientOnlySection", + "DecoRootLayout", + ], + hint: "render pages with createCMS (/next/nextjs); drafts with bindDraft and contentClient from @decocms/nextjs/server, and StudioBridge from @decocms/nextjs in the root layout", + }, + { + specifier: /^@decocms\/nextjs$/, + names: ["DraftPreviewIndicator"], + hint: "the same name in @decocms/nextjs 8: it shows the draft bindDraft(cms, { searchParams, site }) from @decocms/nextjs/server bound for the page, so call that first", + }, + { + specifier: /^@decocms\/(nextjs|blocks\/preview)$/, + names: [ + "DraftPreviewBadge", + "DraftPreviewBadgeProps", + "buildExitUrl", + "buildShareUrl", + "isFramed", + "DECO_MARK_DATA_URI", + ], + hint: "DraftBadge from @decocms/blocks/preview (DecoDocument and DraftPreviewIndicator mount it)", + }, + { + specifier: /^@decocms\/nextjs\/config$/, + names: ["DECO_REWRITES"], + hint: "gone with v7's /deco/* handlers: withDeco from @decocms/nextjs/config adds no rewrites", + }, + { + specifier: /^@decocms\/blocks\/hooks$/, + names: IMAGE_COMPONENTS, + hint: "on Next.js, next/image with createDecoImageLoader from @decocms/nextjs/image-loader; elsewhere, URLs from createImageUrl, srcSet and preloadLink in @decocms/blocks/image, with the platform's helper listed first (vtexCatalog from @decocms/apps-vtex/image, shopifyCdn from @decocms/apps-shopify/image)", + }, + { + specifier: /^@decocms\/blocks\/hooks$/, + names: [ + "registerImageCdnDomain", + "registerImageQuality", + "getImageCdnDomain", + "getImageQuality", + "getOptimizedMediaUrl", + "getSrcSet", + "FACTORS", + "ImageQuality", + "FitOptions", + ], + hint: `${V7_IMAGE_URL} gives v7's URLs; url(src, { width, height, fit }) and srcSet(url, src, …) replace getOptimizedMediaUrl and getSrcSet; on TanStack, pass it to ImageUrlProvider from @decocms/tanstack/react`, + }, + { + specifier: /^@decocms\/blocks\/hooks$/, + names: ["LiveControls"], + hint: "StudioBridge from @decocms/blocks/preview: DecoDocument (@decocms/tanstack/react) mounts it; a Next.js root layout renders StudioBridge from @decocms/nextjs", + }, +]; + /** Where each v7 import's replacement lives, first match wins. */ const HINTS: [RegExp, string][] = [ [/^@decocms\/start(\/|$)/, "a 6.x import: upgrade the site to 7.x first"], @@ -117,15 +445,66 @@ const HINTS: [RegExp, string][] = [ "apps are thin upstream clients now; call the platform's client from your own code (/next/upstream-clients)", ], [ - /^@decocms\/(tanstack|nextjs)(\/|$)/, - "the v7 framework binding: the next major has none, so drop the dependency and follow your framework's guide (/next/tanstack-start-descriptors, /next/nextjs)", + /^@decocms\/blocks\/sdk\/(cacheHeaders|responseCache|cacheStorage|mergeCacheControl)$/, + "on TanStack Start, createDecoWorker's edge cache (cacheProfiles changes DEFAULT_CACHE_PROFILES, from @decocms/tanstack/worker); a Next.js site keeps its own cache headers", + ], + [ + /^@decocms\/blocks\/sdk\/csp$/, + "createDecoWorker's csp option (@decocms/tanstack/worker): Studio's frame-ancestors are always added", + ], + [ + /^@decocms\/blocks\/sdk\/redirects$/, + "createDecoWorker applies the CMS redirects before any route", + ], + [ + /^@decocms\/tanstack\/vite$/, + "decoTanstack({ buildHash }) from @decocms/tanstack/vite; it includes decoContent() from @decocms/blocks/vite", + ], + [ + /^@decocms\/tanstack\/sdk\/startEntry$/, + "createStart(() => ({ serverFns: { fetch: decoServerFnFetch } })) with decoServerFnFetch from @decocms/tanstack/start; createDecoWorker decides the draft, so no draft middleware", + ], + [ + /^@decocms\/tanstack\/sdk\/cdnSegment$/, + "createDecoWorker's segment option; DecoDocument publishes the __cseg token and decoServerFnFetch sends it", + ], + [ + /^@decocms\/tanstack\/sdk\/cookiePassthrough$/, + "createDecoWorker sends one Set-Cookie per cookie; a server function sets cookies with TanStack Start's own setCookie", + ], + [ + /^@decocms\/tanstack\/sdk\/deferredSectionLoader$/, + "the next major has no async rendering (the script unwrapped the Lazy and Deferred sections)", + ], + [/^@decocms\/tanstack\/daemon$/, "local editing is deco serve (/next/cli)"], + [ + /^@decocms\/nextjs\/routeHandlers$/, + "v7's /deco/* route handlers: delete them; the site editor uses the content protocol (/next/studio-compatibility)", + ], + [ + /^@decocms\/nextjs\/setup$/, + "setup is createCMS from @decocms/blocks, with the block map in .deco/index.ts (/next/content#create-the-cms)", + ], + [ + /^@decocms\/nextjs\/middleware$/, + "draft previews in proxy.ts: decideDraft, draftRequestHeaders, rewriteToDraftRoute and applyDraft from @decocms/nextjs/proxy, or draftProxy", + ], + [ + /^@decocms\/tanstack(\/|$)/, + "a v7 name: @decocms/tanstack 8 has createDecoWorker (/worker), DecoDocument and Image (/react), createDecoRouter (/router), decoServerFnFetch (/start), contentClient (/server) and decoTanstack (/vite); see the skill's import map", + ], + [ + /^@decocms\/nextjs(\/|$)/, + "a v7 name: @decocms/nextjs 8 has the draft proxy (/proxy), bindDraft and contentClient (/server), withDeco (/config), createDecoImageLoader (/image-loader) and StudioBridge; see the skill's import map", ], ]; const DEFAULT_HINT = "no v8 equivalent"; /** Prerelease root exports that became CMS methods. */ -const DRAFT_HELPERS = new Set(["draftPointer", "draftCookie", "DRAFT_COOKIE"]); +const DRAFT_HELPERS = new Set(["draftPointer", "draftCookie"]); const DRAFT_HINT = - "draftPointer and draftCookie are the async cms.draftPointer and cms.draftCookie now (they check the preview hosts), and DRAFT_COOKIE is gone: pass cms.draftPointer the request, or { url, headers } (/next/api-reference#draft-pointers)"; + "draftPointer and draftCookie are the async cms.draftPointer and cms.draftCookie now: pass cms.draftPointer the request, or { url, headers } (/next/api-reference#draft-pointers); on TanStack Start and Next.js the binding decides the draft (contentClient from @decocms/tanstack/server, bindDraft from @decocms/nextjs/server)"; +const BINDING_NAMES_HINT = + "the draft names and Studio's origins come from the site's binding, not @decocms/blocks: @decocms/tanstack on TanStack Start, @decocms/nextjs on Next.js"; const SOURCE = /\.(tsx?|mts|cts)$/; @@ -193,17 +572,31 @@ function findImports(source: ts.SourceFile): Found[] { return found; } -function isDocumented(specifier: string, names: string[]): boolean { - const api = V8_API[specifier]; - if (!api) return false; - return api === "*" || names.every((n) => api.has(n)); +/** The hint for each of `names` imported from `specifier`, names grouped by hint. */ +function hintsFor(specifier: string, names: string[]): Map { + const out = new Map(); + const add = (hint: string, name: string) => out.set(hint, [...(out.get(hint) ?? []), name]); + const general = HINTS.find(([pattern]) => pattern.test(specifier))?.[1] ?? DEFAULT_HINT; + for (const name of names) { + if (specifier === "@decocms/blocks" && DRAFT_HELPERS.has(name)) { + add(DRAFT_HINT, name); + continue; + } + if (specifier === "@decocms/blocks" && BINDING_NAMES.includes(name)) { + add(BINDING_NAMES_HINT, name); + continue; + } + const byName = NAME_HINTS.find((h) => h.specifier.test(specifier) && h.names.includes(name)); + add(byName?.hint ?? general, name); + } + return out; } -function hintFor(specifier: string, names: Iterable): string { - if (specifier === "@decocms/blocks" && [...names].some((n) => DRAFT_HELPERS.has(n))) { - return DRAFT_HINT; - } - return HINTS.find(([pattern]) => pattern.test(specifier))?.[1] ?? DEFAULT_HINT; +/** The names of `specifier` the next major doesn't export (all of them for an unknown entry point). */ +function undocumented(specifier: string, names: string[]): string[] { + const api = V8_API[specifier]; + if (api === "*") return []; + return api ? names.filter((n) => !api.has(n)) : names; } /** `createInstrumentedFetch("x")` calls, or null when one has another argument shape. */ @@ -232,6 +625,41 @@ interface Edit { text: string; } +/** How many changed URLs the report spells out; the rest are counted. */ +const LISTED_URLS = 10; + +/** + * A site whose v7 image code stripped the Deco asset hosts anywhere in a + * source now gets decoCdn's "leading" (approved, PO 2026-10-15): the report + * counts the URLs in the content and code whose image URL changes. + */ +function reportLeadingStrip(root: string, report: Report, copy: string | undefined): void { + const changes = leadingStripChanges(root); + const by = copy + ? `its copy of v7's image helper (${copy})` + : "its 7.x release's Image (before 7.71.4)"; + const rule = `v7 stripped the Deco asset hosts (assets.decocache.com, the S3 bucket) anywhere in an image source, by ${by}; decoCdn strips only a leading one, an approved difference (PO 2026-10-15), so a source that merely contains an asset URL keeps it`; + if (changes.length === 0) { + report.done.push({ + step: "imports", + subject: "image URLs", + message: `${rule}. No URL in .deco/blocks or src/ changes`, + }); + return; + } + const files = new Set(changes.map((c) => c.file)); + const listed = changes + .slice(0, LISTED_URLS) + .map((c) => `${c.url} (${c.file})`) + .join(", "); + const more = changes.length > LISTED_URLS ? `, +${changes.length - LISTED_URLS} more` : ""; + report.manual.push({ + step: "imports", + subject: "image URLs", + message: `${rule}. ${changes.length} URL${changes.length === 1 ? "" : "s"} in ${files.size} file${files.size === 1 ? "" : "s"} change${changes.length === 1 ? "s" : ""}: ${listed}${more}. List them as the approved difference in the PR; nothing to change`, + }); +} + /** * Rewrite and report the `@decocms/*` imports of every source file in `src/`. * `vendored` maps an installed source file to its copy in the site. @@ -239,6 +667,18 @@ interface Edit { export function rewriteImports(root: string, report: Report, vendored: Map): void { const reported = new Map; files: Set }>(); let rewrittenFiles = 0; + const framework = siteFramework(root); + const images = findImageCopies(root); + const fromCopy = images.copies + .map((copy) => copyImageUrl(copy, images.registered)) + .find((url) => url !== undefined); + const imageUrl = fromCopy ?? v7ImageUrl(siteBlocksVersion(root)); + const readCopy = images.copies.find((copy) => copy.rules !== null); + const stripsAnywhere = readCopy + ? readCopy.rules?.stripAssetHosts === "anywhere" + : v7StripsAnywhere(siteBlocksVersion(root)); + /** Names moved to a binding, by target, for the report. */ + const moved = new Map>(); for (const file of listSources(path.join(root, "src"))) { const text = fs.readFileSync(file, "utf8"); @@ -300,16 +740,56 @@ export function rewriteImports(root: string, report: Report, vendored: Map(); + const rest: ts.ImportSpecifier[] = []; + for (const el of bindings.elements) { + const to = move.names[(el.propertyName ?? el.name).text]; + const target = typeof to === "string" ? to : framework && to?.[framework]; + if (target) byTarget.set(target, [...(byTarget.get(target) ?? []), el]); + else rest.push(el); + } + if (byTarget.size > 0) { + const typeOnly = decl.importClause?.isTypeOnly ? "type " : ""; + const quote = found.literal.getText(source)[0]; + const line = (els: ts.ImportSpecifier[], from: string) => + `import ${typeOnly}{ ${els.map((el) => el.getText(source)).join(", ")} } from ${quote}${from}${quote};`; + const lines = [...byTarget].map(([target, els]) => line(els, target)); + if (rest.length > 0) lines.push(line(rest, specifier)); + edits.push({ start: decl.getStart(source), end: decl.getEnd(), text: lines.join("\n") }); + for (const [target, els] of byTarget) { + const set = moved.get(target) ?? new Set(); + for (const el of els) set.add((el.propertyName ?? el.name).text); + moved.set(target, set); + } + names = rest.map((el) => (el.propertyName ?? el.name).text); + if (names.length === 0) continue; + } + } + + // 4. The next major's documented API: nothing to do. + const left = undocumented(specifier, names); + if (left.length === 0) continue; - // 4. Everything else: report. - const entry = reported.get(specifier) ?? { names: new Set(), files: new Set() }; - for (const n of found.names) entry.names.add(n); - entry.files.add( - `${rel}:${source.getLineAndCharacterOfPosition(found.literal.getStart(source)).line + 1}`, - ); - reported.set(specifier, entry); + // 5. Everything else: report, by hint. + const where = `${rel}:${source.getLineAndCharacterOfPosition(found.literal.getStart(source)).line + 1}`; + for (const [hint, group] of hintsFor(specifier, left)) { + const key = `${specifier}\0${hint}`; + const entry = reported.get(key) ?? { names: new Set(), files: new Set() }; + for (const n of group) entry.names.add(n); + entry.files.add(where); + reported.set(key, entry); + } } if (edits.length > 0) { @@ -329,13 +809,45 @@ export function rewriteImports(root: string, report: Report, vendored: Map (a < b ? -1 : 1))) { + for (const [target, names] of [...moved].sort(([a], [b]) => (a < b ? -1 : 1))) { + report.done.push({ + step: "imports", + subject: target, + message: `${[...names].join(", ")} now come from the v8 binding`, + }); + } + if ( + moved.get("@decocms/tanstack/react")?.has("Image") || + moved.get("@decocms/tanstack/react")?.has("Picture") + ) { + report.manual.push({ + step: "imports", + subject: "Image, Picture, Source", + message: `they take their URLs from ImageUrlProvider (@decocms/tanstack/react) now, and @decocms/tanstack knows no commerce platform: without a provider every source goes through the Deco image CDN, where v7 resized VTEX catalog and Shopify images on their own hosts. Wrap every page in through createDecoRouter's Wrap option, with imageUrl = ${imageUrl}, so every URL stays v7's`, + }); + } + for (const copy of images.copies) { + const url = copyImageUrl(copy, images.registered); + report.manual.push({ + step: "imports", + subject: copy.file, + message: url + ? `a copy of v7's image helper (getOptimizedMediaUrl): on v8, imageUrl = ${url}. url(src, { width, height, fit }) and srcSet(url, src, …) from @decocms/blocks/image replace its getOptimizedMediaUrl and getSrcSet, and on TanStack Start its Image, Picture and Source are @decocms/tanstack/react's under . One difference, approved: an already-resized Deco CDN URL in the content (https://assets.decocms.com/image?…&src=…) is re-resized from its src, where the copy wrote src=image` + : "a copy of v7's image helper (getOptimizedMediaUrl) whose URL rules the script can't read (it was edited): compare its URLs with createImageUrl's before replacing it, and pick the decoCdn options that reproduce them (reference/framework-code.md)", + }); + } + if (stripsAnywhere) reportLeadingStrip(root, report, readCopy?.file); + const resolved = [...reported].map( + ([key, entry]) => [key.replaceAll(V7_IMAGE_URL, imageUrl), entry] as const, + ); + for (const [key, { names, files }] of resolved.sort(([a], [b]) => (a < b ? -1 : 1))) { + const [specifier, hint] = key.split("\0"); const list = [...files]; const where = `${list.slice(0, 3).join(", ")}${list.length > 3 ? `, +${list.length - 3} more` : ""}`; report.manual.push({ step: "imports", subject: `${specifier} {${[...names].join(", ")}}`, - message: `${hintFor(specifier, names)}; in ${where}`, + message: `${hint}; in ${where}`, }); } } diff --git a/.agents/skills/deco-v7-to-v8-migration/scripts/migrate.test.ts b/.agents/skills/deco-v7-to-v8-migration/scripts/migrate.test.ts index 3d71813a7..ef92a02c6 100644 --- a/.agents/skills/deco-v7-to-v8-migration/scripts/migrate.test.ts +++ b/.agents/skills/deco-v7-to-v8-migration/scripts/migrate.test.ts @@ -186,6 +186,14 @@ beforeAll(async () => { write(root, ".deco/blocks/Telemetry.json", { __resolveType: "telemetry", errorSampleRate: 0.01 }); write(root, ".deco/meta.gen.json", { v7: true }); write(root, ".deco/sections.gen.ts", "export {};\n"); + // The site's repository and its pull-request CI, which gets `deco schema --check`. + fs.mkdirSync(path.join(root, ".git")); + write(root, "bun.lock", ""); + write( + root, + ".github/workflows/ci.yml", + "on: pull_request\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: bun install\n - run: bun run build\n", + ); report = await migrate({ root }); }); @@ -263,6 +271,15 @@ describe("migrate", () => { }); }); + it("adds deco schema --check to the site's pull-request CI, after the install (PO 2026-10-15)", () => { + expect(read(".github/workflows/ci.yml")).toContain( + " - run: bun install\n - name: Check that .deco/schema.gen.json is up to date\n run: bunx deco schema --check\n - run: bun run build\n", + ); + expect(report.done.find((n) => n.subject === "CI")?.message).toContain( + ".github/workflows/ci.yml", + ); + }); + it("re-encrypts the v7 secret with the site's public key", () => { const secret = JSON.parse(read(".deco/blocks/API_KEY.json")); expect(Object.keys(secret)).toEqual(["__resolveType", "ciphertext"]); diff --git a/.agents/skills/deco-v7-to-v8-migration/scripts/migrate.ts b/.agents/skills/deco-v7-to-v8-migration/scripts/migrate.ts index 2059d3964..28c9675e0 100644 --- a/.agents/skills/deco-v7-to-v8-migration/scripts/migrate.ts +++ b/.agents/skills/deco-v7-to-v8-migration/scripts/migrate.ts @@ -11,7 +11,8 @@ * 3. block map: `.deco/index.ts` with aliases under the v7 names, after * vendoring the app loaders and actions the content calls; * 4. imports: the codemod over `src/`; - * 5. scripts: `deco schema && deco content` before dev and build. + * 5. scripts: `deco schema && deco content` before dev and build, and + * `deco schema --check` in the site's CI after the install step. * * It changes files in place, so run it on a clean working tree and review the * diff. What it can't do is returned in the report. @@ -20,8 +21,10 @@ import fs from "node:fs"; import path from "node:path"; import { unwrapAsyncRendering } from "./asyncRendering"; import { writeBlockMap } from "./blockMap"; +import { addSchemaCheck } from "./ciSchemaCheck"; import { moveContent } from "./content"; import { copyExperimentIds } from "./experiments"; +import { siteFramework, V8_RANGE } from "./framework"; import { rewriteImports } from "./imports"; import { renameLegacyTypes } from "./legacyNames"; import { createReport, type Report } from "./report"; @@ -67,11 +70,12 @@ function addScripts(root: string, report: Report): void { message: `added ${added.join(" and ")}`, }); } + const framework = siteFramework(root); + const binding = framework ? `@decocms/${framework}` : undefined; report.manual.push({ step: "scripts", subject: "package.json", - message: - "depend on the next major of @decocms/blocks, drop the v7 codegen from build and the @decocms/blocks-admin, @decocms/blocks-cli, @decocms/tanstack and @decocms/nextjs dependencies once nothing imports them (the next major has no framework binding)", + message: `depend on @decocms/blocks ${V8_RANGE}${binding ? ` and ${binding} ${V8_RANGE} (the v8 binding, released with blocks at one version)` : ""}, drop the v7 codegen from build and the @decocms/blocks-admin and @decocms/blocks-cli dependencies once nothing imports them`, }); } @@ -90,5 +94,6 @@ export async function migrate(options: MigrateOptions): Promise { const { vendored } = writeBlockMap(root, report); rewriteImports(root, report, vendored); addScripts(root, report); + addSchemaCheck(root, report); return report; } diff --git a/.agents/skills/deco-v7-to-v8-migration/scripts/siteSettings.ts b/.agents/skills/deco-v7-to-v8-migration/scripts/siteSettings.ts index de86c64f0..e39c5a390 100644 --- a/.agents/skills/deco-v7-to-v8-migration/scripts/siteSettings.ts +++ b/.agents/skills/deco-v7-to-v8-migration/scripts/siteSettings.ts @@ -7,9 +7,10 @@ * - the v7 Site block's (`Site` or `site`) `previewHosts` → `preview.hosts`, * each entry trimmed and lowercased as v7 compared it (the field moves; the * rest of the Site block stays). An entry that isn't a host pattern is left - * out and reported. On TanStack Start, v7 also allowed the site's - * `.deco.site` and `.deco-cx.workers.dev` (from `DECO_SITE_NAME`): - * they're added when the name is found, and reported when it isn't; + * out and reported. The hosts Deco runs the site on (`.deco.site`, + * `.deco-cx.workers.dev`, which v7's TanStack worker added for + * `DECO_SITE_NAME`) are not written: the v8 bindings allow them from their + * `site` option, and the report names the value to pass; * - a literal `collectorAddress` on v7's `OneDollarStats` component in * `src/` → `analytics.collector`; * - a prerelease `Telemetry` / `Analytics` saved block (type `telemetry` / @@ -24,6 +25,7 @@ import fs from "node:fs"; import path from "node:path"; import { formatHostPattern, LEGACY_ALIASES, parseHostPattern } from "@decocms/blocks/cli"; import { blockFileName, serializeBlock } from "@decocms/blocks/protocol/keys"; +import { siteFramework } from "./framework"; import type { Report } from "./report"; import { isPlainObject, type JsonObject, readContent } from "./walk"; @@ -90,22 +92,7 @@ function isPrereleaseBlock(node: unknown, type: string): boolean { } /** - * Whether v7 ran on `@decocms/tanstack`, whose worker entry added the site's - * deco-hosted domains to the preview hosts. v7's Next.js binding never did. - */ -function isTanStackSite(root: string): boolean { - try { - const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); - return ["dependencies", "devDependencies"].some( - (field) => isPlainObject(pkg[field]) && Object.hasOwn(pkg[field], "@decocms/tanstack"), - ); - } catch { - return false; - } -} - -/** - * The `DECO_SITE_NAME` v7's TanStack worker read: from the Workers vars in + * The site's name, `DECO_SITE_NAME` as v7 read it: from the Workers vars in * `wrangler.*` first, else from `.env`/`.dev.vars` and the Vite config's * fallback. `undefined` when there's none, or several that disagree. */ @@ -144,7 +131,7 @@ function v7SiteName(root: string): { name?: string; candidates: string[] } { return { name: candidates.length === 1 ? candidates[0] : undefined, candidates }; } -/** The hosts v7's TanStack worker always allowed for a named site. */ +/** The hosts Deco runs a named site on: v7's TanStack worker always allowed them, the v8 bindings do with `site`. */ function decoHostedHosts(site: string): string[] { return [`${site}.deco.site`, `${site}.deco-cx.workers.dev`]; } @@ -231,9 +218,9 @@ export function foldSiteSettings(root: string, report: Report): void { /** Saved blocks to write back, without a field that moved. */ const rewrite = new Map(); - // The v7 Site block's preview hosts, plus the deco-hosted ones v7's TanStack worker added. - const tanstack = isTanStackSite(root); - const siteName = tanstack ? v7SiteName(root) : { candidates: [] }; + // The v7 Site block's preview hosts. The hosts Deco runs the site on come from the binding's `site`. + const framework = siteFramework(root); + const siteName = v7SiteName(root); let previewHosts: string[] | undefined; for (const name of SITE_BLOCKS) { const site = blocks[name]; @@ -253,15 +240,7 @@ export function foldSiteSettings(root: string, report: Report): void { } previewHosts.push(formatHostPattern(pattern)); } - if (tanstack && siteName.name) previewHosts.push(...decoHostedHosts(siteName.name)); previewHosts = [...new Set(previewHosts)]; - if (tanstack && !siteName.name) { - report.manual.push({ - step: "settings", - subject: "preview hosts", - message: `v7 also allowed previews on .deco.site and .deco-cx.workers.dev for the site named by DECO_SITE_NAME${siteName.candidates.length > 1 ? ` (found several: ${siteName.candidates.join(", ")})` : ""}; add the ones you use to CMS.json's preview.hosts (/next/releases-and-drafts#allow-previews-per-host)`, - }); - } const folded = fold(settings, "preview", previewHosts, "hosts"); const preview = settings.preview; const hosts = isPlainObject(preview) ? preview.hosts : undefined; @@ -280,20 +259,20 @@ export function foldSiteSettings(root: string, report: Report): void { } break; } - - // No previewHosts at all: v7's TanStack worker still allowed previews only on the site's - // deco-hosted hosts, while v8 allows every host without a list. Keep v7's list when the name is known. - if ( - previewHosts === undefined && - settings.preview === undefined && - tanstack && - siteName.name && - fold(settings, "preview", decoHostedHosts(siteName.name), "hosts") - ) { - previewHosts = decoHostedHosts(siteName.name); - done.push( - `preview.hosts: ${previewHosts.join(", ")} (v7 allowed previews only there, from DECO_SITE_NAME; add your dev and staging hosts)`, - ); + const noList = previewHosts === undefined && settings.preview === undefined; + if (framework) { + report.manual.push({ + step: "settings", + subject: "preview hosts", + message: bindingPreviewHosts(framework, siteName, noList), + }); + } else if (noList && current === undefined) { + report.manual.push({ + step: "settings", + subject: "preview hosts", + message: + "with no previewHosts, v7 allowed previews nowhere; the next major allows every host unless preview.hosts lists some, so add that list to CMS.json if you relied on it (/next/releases-and-drafts#allow-previews-per-host)", + }); } // A literal collectorAddress on OneDollarStats. @@ -373,8 +352,9 @@ export function foldSiteSettings(root: string, report: Report): void { { step: "settings", subject: "DECO_ALLOWED_PREVIEW_HOSTS", - message: - "if your hosting sets it, put its hosts in CMS.json's preview.hosts, or in createCMS({ preview: { hosts } }) to make them the most content may allow; its none is an empty list (/next/releases-and-drafts#allow-previews-per-host)", + message: framework + ? `if your hosting sets it, pass its hosts as the previewHosts option of ${framework === "tanstack" ? "createDecoWorker" : "decideDraft and bindDraft"} (they replace CMS.json's preview.hosts; the site's Deco hosts stay allowed), and its none as previewHosts: "none"` + : "if your hosting sets it, put its hosts in CMS.json's preview.hosts, or in createCMS({ preview: { hosts } }) to make them the most content may allow; its none is an empty list (/next/releases-and-drafts#allow-previews-per-host)", }, { step: "settings", @@ -389,18 +369,6 @@ export function foldSiteSettings(root: string, report: Report): void { "if your hosting sets them, the enabled ones become the analytics section's enabled and ONEDOLLAR_COLLECTOR its collector (/next/analytics)", }, ); - if (previewHosts === undefined && settings.preview === undefined && current === undefined) { - const v7Hosts = siteName.name - ? `only on ${decoHostedHosts(siteName.name).join(" and ")} (from DECO_SITE_NAME)` - : tanstack - ? "only on .deco.site and .deco-cx.workers.dev for the site named by DECO_SITE_NAME, or nowhere without one" - : "nowhere"; - report.manual.push({ - step: "settings", - subject: "preview hosts", - message: `with no previewHosts, v7 allowed previews ${v7Hosts}; the next major allows every host unless preview.hosts lists some, so add that list to CMS.json if you relied on it (/next/releases-and-drafts#allow-previews-per-host)`, - }); - } } /** Whether every field of `part` is already in `whole` with the same value. */ @@ -410,3 +378,29 @@ function isSubset(part: unknown, whole: unknown): boolean { } return Object.entries(part).every(([key, value]) => isSubset(value, whole[key])); } + +/** What to pass the binding so drafts preview where v7 let them. */ +function bindingPreviewHosts( + framework: "tanstack" | "nextjs", + siteName: { name?: string; candidates: string[] }, + noList: boolean, +): string { + const name = siteName.name ?? ""; + const found = + siteName.name === undefined + ? ` (DECO_SITE_NAME wasn't found${siteName.candidates.length > 1 ? `; several: ${siteName.candidates.join(", ")}` : ""}: use the site's name in Deco)` + : ""; + const hosts = decoHostedHosts(name).join(" and "); + if (framework === "tanstack") { + return `pass site: "${name}" to createDecoWorker${found}: the worker lets ${hosts} preview, as v7 did${ + noList + ? `; with no preview.hosts in CMS.json the worker allows only those two, as v7 did, so add your staging hosts to CMS.json's preview.hosts and your dev server (localhost:5173) to previewHosts in development` + : "" + } (@decocms/tanstack/worker)`; + } + return `pass site: "${name}" to decideDraft and bindDraft${found}, the same options to both: they let ${hosts} preview, which v7's Next.js binding didn't (name it for the product owner)${ + noList + ? `; with no preview.hosts in CMS.json the binding allows only those two (v7 allowed none: leave site out to keep none), so add your staging hosts to CMS.json's preview.hosts and your dev server (localhost:3000) to previewHosts in development` + : "" + } (@decocms/nextjs/proxy, @decocms/nextjs/server)`; +} diff --git a/.agents/skills/deco-v7-to-v8-migration/scripts/steps.test.ts b/.agents/skills/deco-v7-to-v8-migration/scripts/steps.test.ts index d7ccbe031..a50f22a31 100644 --- a/.agents/skills/deco-v7-to-v8-migration/scripts/steps.test.ts +++ b/.agents/skills/deco-v7-to-v8-migration/scripts/steps.test.ts @@ -7,7 +7,8 @@ import { unwrapAsyncRendering } from "./asyncRendering"; import { writeBlockMap } from "./blockMap"; import { decofileEntries, moveContent } from "./content"; import { copyExperimentIds } from "./experiments"; -import { rewriteImports, V8_API } from "./imports"; +import { siteBlocksVersion } from "./framework"; +import { BINDING_NAMES, MOVES, rewriteImports, V8_API, v7ImageUrl } from "./imports"; import { renameLegacyTypes } from "./legacyNames"; import { createReport } from "./report"; import { reencryptSecrets } from "./secrets"; @@ -308,29 +309,124 @@ describe("imports", () => { ]); }); - it("points prerelease draft helpers at the CMS methods", () => { + it("points prerelease draft helpers at the CMS methods, and the draft names at the binding", () => { site({ "src/a.ts": 'import { createCMS, draftPointer, DRAFT_COOKIE } from "@decocms/blocks";\n', }); const report = createReport(); rewriteImports(root, report, new Map()); - expect(report.manual).toHaveLength(1); + expect(report.manual.map((n) => n.subject)).toEqual([ + "@decocms/blocks {draftPointer}", + "@decocms/blocks {DRAFT_COOKIE}", + ]); expect(report.manual[0]?.message).toContain("cms.draftPointer"); + // No binding in package.json: reported, since the home depends on the binding. + expect(report.manual[1]?.message).toContain("@decocms/tanstack on TanStack Start"); }); - it("reports every framework-binding import, kvLoader included: the next major has no binding", () => { + it.each([ + ["@decocms/tanstack", "@decocms/tanstack"], + ["@decocms/nextjs", "@decocms/nextjs"], + ])("on a %s site, moves the draft names and Studio's origins from @decocms/blocks to %s", (binding, target) => { site({ + "package.json": { name: "site", dependencies: { [binding]: "7.0.0" } }, "src/a.ts": - 'import { kvLoader } from "@decocms/tanstack";\nimport { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers";\n', + 'import { createCMS, DRAFT_COOKIE, STUDIO_ORIGINS, isStudioOrigin } from "@decocms/blocks";\n', + }); + const report = createReport(); + rewriteImports(root, report, new Map()); + expect(fs.readFileSync(path.join(root, "src/a.ts"), "utf8")).toBe( + `import { DRAFT_COOKIE, STUDIO_ORIGINS, isStudioOrigin } from "${target}";\nimport { createCMS } from "@decocms/blocks";\n`, + ); + expect(report.manual).toEqual([]); + }); + + it("reports v7 binding imports name by name, with the v8 binding's replacement", () => { + site({ + "src/a.ts": + 'import { kvLoader, createDecoWorkerEntry } from "@decocms/tanstack";\nimport { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers";\nimport { LiveControls, registerImageQuality } from "@decocms/blocks/hooks";\n', }); const report = createReport(); rewriteImports(root, report, new Map()); const notes = report.manual.map((n) => `${n.subject}: ${n.message}`); expect(notes.map((n) => n.split(":")[0])).toEqual([ + "@decocms/blocks/hooks {LiveControls}", + "@decocms/blocks/hooks {registerImageQuality}", "@decocms/nextjs/routeHandlers {createDecoRouteHandlers}", + "@decocms/tanstack {createDecoWorkerEntry}", "@decocms/tanstack {kvLoader}", ]); - for (const note of notes) expect(note).toContain("drop the dependency"); + const note = (subject: string) => notes.find((n) => n.startsWith(subject)) ?? ""; + expect(note("@decocms/tanstack {createDecoWorkerEntry}")).toContain( + "createDecoWorker({ serverEntry, cms, site, buildHash }) from @decocms/tanstack/worker", + ); + expect(note("@decocms/tanstack {kvLoader}")).toContain("createCMS({ site, token })"); + expect(note("@decocms/blocks/hooks {LiveControls}")).toContain("StudioBridge"); + expect(note("@decocms/blocks/hooks {registerImageQuality}")).toContain("ImageUrlProvider"); + // Platform image URLs come from the platforms' client packages, never @decocms/blocks/image. + expect(note("@decocms/blocks/hooks {registerImageQuality}")).toContain( + "vtexCatalog from @decocms/apps-vtex/image, shopifyCdn from @decocms/apps-shopify/image", + ); + expect(note("@decocms/nextjs/routeHandlers")).toContain("delete them"); + }); + + it("moves names the v8 bindings export unchanged, splitting a mixed import", () => { + site({ + "package.json": { name: "site", dependencies: { "@decocms/tanstack": "7.0.0" } }, + "src/router.tsx": + 'import { createDecoRouter, NavigationProgress, StableOutlet, DecoRootLayout } from "@decocms/tanstack";\n', + "src/start.ts": "import { decoServerFnFetch } from '@decocms/tanstack/sdk/serverFnFetch';\n", + "src/Banner.tsx": + 'import { Image, Picture, Source, type ImageProps, LiveControls } from "@decocms/blocks/hooks";\n', + "src/v8.ts": + 'import { createDecoWorker } from "@decocms/tanstack/worker";\nimport { decoTanstack } from "@decocms/tanstack/vite";\nimport { createImageUrl } from "@decocms/blocks/image";\nimport { DraftBadge, StudioPage } from "@decocms/blocks/preview";\nimport { DecoPreview } from "@decocms/tanstack/react";\n', + }); + const report = createReport(); + rewriteImports(root, report, new Map()); + const read = (f: string) => fs.readFileSync(path.join(root, f), "utf8"); + expect(read("src/router.tsx")).toBe( + [ + 'import { createDecoRouter } from "@decocms/tanstack/router";', + 'import { NavigationProgress, StableOutlet } from "@decocms/tanstack/react";', + 'import { DecoRootLayout } from "@decocms/tanstack";', + "", + ].join("\n"), + ); + expect(read("src/start.ts")).toBe( + "import { decoServerFnFetch } from '@decocms/tanstack/start';\n", + ); + expect(read("src/Banner.tsx")).toBe( + [ + 'import { Image, Picture, Source, type ImageProps } from "@decocms/tanstack/react";', + 'import { LiveControls } from "@decocms/blocks/hooks";', + "", + ].join("\n"), + ); + // Only what's left is reported; the v8 imports are not. + expect(report.manual.map((n) => n.subject)).toEqual([ + "Image, Picture, Source", + "@decocms/blocks/hooks {LiveControls}", + "@decocms/tanstack {DecoRootLayout}", + ]); + expect(report.done.map((n) => n.subject)).toEqual([ + "src/", + "@decocms/tanstack/react", + "@decocms/tanstack/router", + "@decocms/tanstack/start", + ]); + }); + + it("leaves Image on a Next.js site to the image loader", () => { + site({ + "package.json": { name: "site", dependencies: { "@decocms/nextjs": "7.0.0" } }, + "src/a.tsx": 'import { Image } from "@decocms/blocks/hooks";\n', + }); + const report = createReport(); + rewriteImports(root, report, new Map()); + expect(fs.readFileSync(path.join(root, "src/a.tsx"), "utf8")).toContain( + "@decocms/blocks/hooks", + ); + expect(report.manual[0]?.message).toContain("createDecoImageLoader"); }); }); @@ -350,6 +446,77 @@ describe("the codemod's list of the root's next-major exports", () => { }); }); +describe("the codemod's lists of the v8 bindings' and subpaths' exports", () => { + const PACKAGES = path.resolve(__dirname, "../../../../packages"); + const entry = (specifier: string) => { + const [, pkg, sub] = /^@decocms\/([^/]+)(?:\/(.+))?$/.exec(specifier) ?? []; + const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGES, pkg, "package.json"), "utf8")); + const target = manifest.exports[sub ? `./${sub}` : "."]; + expect(target, specifier).toBeDefined(); + // The file a type-checker reads: the source, else the declarations of a CommonJS entry. + const file = target.source ?? target.default?.source ?? target.workerd?.source ?? target.types; + return path.join(PACKAGES, pkg, file); + }; + const v8Entries = Object.entries(V8_API).filter(([s]) => s !== "@decocms/blocks"); + + it.each(v8Entries)("%s exists, and exports every name listed", (specifier, api) => { + const text = fs.readFileSync(entry(specifier), "utf8"); + if (api === "*") return; + for (const name of api) + expect(text, `${specifier} ${name}`).toMatch(new RegExp(`\\b${name}\\b`)); + }); + + it("every name a binding's root, /preview or /image exports at runtime is listed, or reported on purpose", async () => { + const REPORTED = new Set(["DraftPreviewIndicator"]); + for (const specifier of [ + "@decocms/tanstack", + "@decocms/nextjs", + "@decocms/blocks/preview", + "@decocms/blocks/image", + ]) { + const api = V8_API[specifier] as Set; + const runtime = Object.keys(await import(specifier)); + expect( + runtime.filter((n) => !api.has(n) && !REPORTED.has(n)), + specifier, + ).toEqual([]); + } + }); + + it("every move points at a v8 entry that exports the name", () => { + for (const { names } of Object.values(MOVES)) { + for (const [name, to] of Object.entries(names)) { + for (const target of typeof to === "string" ? [to] : Object.values(to)) { + const api = V8_API[target]; + expect(api === "*" || api?.has(name), `${target} ${name}`).toBe(true); + expect(fs.readFileSync(entry(target), "utf8"), `${target} ${name}`).toMatch( + new RegExp(`\\b${name}\\b`), + ); + } + } + } + }); + + it("@decocms/blocks exports none of the names that moved to the bindings", async () => { + const runtime = Object.keys(await import("@decocms/blocks")); + expect(runtime.filter((n) => BINDING_NAMES.includes(n))).toEqual([]); + expect( + [...(V8_API["@decocms/blocks"] as Set)].filter((n) => BINDING_NAMES.includes(n)), + ).toEqual([]); + }); + + it("@decocms/blocks/image names no commerce platform; the platforms' helpers are their clients' /image", () => { + const names = [...(V8_API["@decocms/blocks/image"] as Set)]; + expect(names.filter((n) => /vtex|shopify/i.test(n))).toEqual([]); + expect(fs.readFileSync(entry("@decocms/apps-vtex/image"), "utf8")).toMatch( + /export function vtexCatalog\b/, + ); + expect(fs.readFileSync(entry("@decocms/apps-shopify/image"), "utf8")).toMatch( + /export function shopifyCdn\b/, + ); + }); +}); + describe("site settings", () => { const read = (file: string) => JSON.parse(fs.readFileSync(path.join(root, ".deco/blocks", file), "utf8")); @@ -424,7 +591,7 @@ export function Root() { expect(bad.map((n) => n.message).join("\n")).toContain("*.x.com"); }); - it("on TanStack Start, adds the deco-hosted hosts v7 allowed for DECO_SITE_NAME", () => { + it("on TanStack Start, writes only the site's own hosts and names the site the worker needs", () => { site({ "package.json": { name: "site", dependencies: { "@decocms/tanstack": "7.0.0" } }, "wrangler.jsonc": `{ "vars": { "DECO_SITE_NAME": "acme" } }`, @@ -436,16 +603,20 @@ export function Root() { }); const report = createReport(); foldSiteSettings(root, report); - expect(read("CMS.json").preview).toEqual({ - hosts: ["staging.example.com", "acme.deco.site", "acme.deco-cx.workers.dev"], - }); - expect(report.manual.map((n) => n.subject)).not.toContain("preview hosts"); + // The Deco hosts come from createDecoWorker's site, not from content. + expect(read("CMS.json").preview).toEqual({ hosts: ["staging.example.com", "acme.deco.site"] }); + const note = report.manual.find((n) => n.subject === "preview hosts")?.message ?? ""; + expect(note).toContain('pass site: "acme" to createDecoWorker'); + expect(note).toContain("acme.deco-cx.workers.dev"); + expect(note).not.toContain("previewHosts: []"); + const allowed = report.manual.find((n) => n.subject === "DECO_ALLOWED_PREVIEW_HOSTS")?.message; + expect(allowed).toContain("previewHosts option of createDecoWorker"); const before = snapshotDir(); foldSiteSettings(root, createReport()); expect(snapshotDir()).toEqual(before); }); - it("on TanStack Start without a site name, reports the deco-hosted hosts; a Next.js site never gets them", () => { + it("without a site name, the note asks for it; a Next.js site is told the Deco hosts are new", () => { site({ "package.json": { name: "site", dependencies: { "@decocms/tanstack": "7.0.0" } }, ".deco/blocks/site.json": { @@ -457,7 +628,8 @@ export function Root() { foldSiteSettings(root, report); expect(read("CMS.json").preview).toEqual({ hosts: ["a.example.com"] }); const note = report.manual.find((n) => n.subject === "preview hosts"); - expect(note?.message).toContain(".deco.site"); + expect(note?.message).toContain('site: ""'); + expect(note?.message).toContain("DECO_SITE_NAME wasn't found"); fs.rmSync(root, { recursive: true, force: true }); site({ @@ -471,10 +643,12 @@ export function Root() { const next = createReport(); foldSiteSettings(root, next); expect(read("CMS.json").preview).toEqual({ hosts: ["a.example.com"] }); - expect(next.manual.map((n) => n.subject)).not.toContain("preview hosts"); + const nextNote = next.manual.find((n) => n.subject === "preview hosts")?.message ?? ""; + expect(nextNote).toContain('pass site: "acme" to decideDraft and bindDraft'); + expect(nextNote).toContain("v7's Next.js binding didn't"); }); - it("with no previewHosts on a named TanStack site, keeps v7's deco-hosted hosts in CMS.json", () => { + it("with no previewHosts on a named TanStack site, writes no list: the worker keeps v7's two hosts with no extra option", () => { site({ "package.json": { name: "site", dependencies: { "@decocms/tanstack": "7.0.0" } }, ".env": "DECO_SITE_NAME=acme\n", @@ -482,10 +656,11 @@ export function Root() { }); const report = createReport(); foldSiteSettings(root, report); - expect(read("CMS.json").preview).toEqual({ - hosts: ["acme.deco.site", "acme.deco-cx.workers.dev"], - }); - expect(report.manual.map((n) => n.subject)).not.toContain("preview hosts"); + expect(exists("CMS.json")).toBe(false); + const note = report.manual.find((n) => n.subject === "preview hosts")?.message ?? ""; + expect(note).toContain('pass site: "acme" to createDecoWorker'); + expect(note).toContain("allows only those two, as v7 did"); + expect(note).not.toContain("previewHosts: []"); }); it("reads a Site block named Site, and collectorAddress={'…'}", () => { @@ -642,3 +817,52 @@ export function Root() { expect(subjects).toContain("src/c.tsx"); }); }); + +describe("v7's image URLs by 7.x release", () => { + const deco = (v: string | undefined) => /decoCdn\(\{ ([^}]*) \}\)/.exec(v7ImageUrl(v))?.[1]; + + it("gives the decoCdn options that reproduce each release's Image", () => { + expect(deco("7.64.3")).toBe('domain, quality, sources: "all", legacyHosts: []'); + expect(v7ImageUrl("7.64.3")).toContain('else "decoims.com", the default before 7.71.0'); + expect(deco("7.71.0")).toBe('domain, quality, sources: "all", legacyHosts: []'); + expect(deco("7.71.3")).toBe('domain, quality, sources: "all", legacyHosts: []'); + expect(deco("7.71.4")).toBe('domain, quality, sources: "all", legacyHosts: ["decoims.com"]'); + expect(deco("7.71.5")).toBe('domain, quality, sources: "all"'); + expect(deco("7.80.0")).toBe('domain, quality, sources: "all"'); + expect(v7ImageUrl("7.77.0")).not.toContain("Nuvemshop"); + expect(v7ImageUrl("7.77.1")).toContain("Nuvemshop"); + expect(v7ImageUrl(undefined)).toContain( + "reference/framework-code.md lists the decoCdn options", + ); + for (const v of ["7.2.0", "7.77.1", undefined]) { + expect(v7ImageUrl(v)).toContain( + "createImageUrl({ providers: [vtexCatalog(), shopifyCdn(), decoCdn(", + ); + } + }); + + it("reads the site's 7.x version: installed copy first, else the package.json range", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deco-v7-version-")); + try { + expect(siteBlocksVersion(root)).toBeUndefined(); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ dependencies: { "@decocms/blocks": "^7.71.2" } }), + ); + expect(siteBlocksVersion(root)).toBe("7.71.2"); + fs.mkdirSync(path.join(root, "node_modules", "@decocms", "blocks"), { recursive: true }); + fs.writeFileSync( + path.join(root, "node_modules", "@decocms", "blocks", "package.json"), + JSON.stringify({ version: "7.77.4" }), + ); + expect(siteBlocksVersion(root)).toBe("7.77.4"); + fs.writeFileSync( + path.join(root, "node_modules", "@decocms", "blocks", "package.json"), + JSON.stringify({ version: "8.3.0" }), + ); + expect(siteBlocksVersion(root)).toBe("7.71.2"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/CLAUDE.md b/CLAUDE.md index e39ce246d..0ed9642b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ packages/ ├── apps-algolia/ @decocms/apps-algolia │ depend only on @decocms/blocks ├── apps-resend/ @decocms/apps-resend │ └── apps-sfmc-personalization/ @decocms/apps-sfmc-personalization ┘ -tests/ cross-package tests (upstream-client and helper guardrails, conformance) +tests/ cross-package tests (upstream-client and helper guardrails, conformance, bindings' image goldens) examples/ tanstack-smoke, tanstack-rsc-smoke, nextjs-smoke, tanstack-worker-smoke, nextjs-binding-smoke .agents/skills/ agent skills (index: .agents/skills/README.md); .claude/skills is a symlink to it └── deco-v7-to-v8-migration/ the v7 → v8 site migration (SKILL.md, reference/, runnable scripts/) @@ -99,7 +99,7 @@ Opinionated by design: the behaviour every Deco TanStack site should have is fix | `@decocms/tanstack/server` | `src/server/index.ts` — `contentClient(cms, request?)`, `requestDraft`, `markPrivate`, `isServerFnCall` (read the draft the worker decided). Server only | | `@decocms/tanstack/start` | `src/start/index.ts` — `decoServerFnFetch` for `createStart({ serverFns: { fetch } })`. Browser-safe | | `@decocms/tanstack/router` | `src/router/index.ts` — `createDecoRouter`, `decoParseSearch`/`decoStringifySearch` (keep `?q=`, v7 #646), `PAGE_STATUS_HEADER`. Isomorphic | -| `@decocms/tanstack/react` | `src/react/index.ts` — `DecoDocument` (mounts `StudioBridge` and `DraftBadge` from `@decocms/blocks/preview`, publishes the `__cseg` token and the draft), `NavigationProgress`, `StableOutlet`. Browser-safe | +| `@decocms/tanstack/react` | `src/react/index.ts` — `DecoDocument` (mounts `StudioBridge` and `DraftBadge` from `@decocms/blocks/preview`, publishes the `__cseg` token and the draft), `NavigationProgress`, `StableOutlet`; `Image`, `Picture`, `Source` (v7's props and markup) with `ImageUrlProvider` for the site's `ImageUrl` from `@decocms/blocks/image`; no platform knowledge (without a provider, `DECO_IMAGE_URL`: every source through the Deco CDN), the site lists its platform's helper (`@decocms/apps-vtex/image`, …) itself. `tests/tanstack-image-markup.golden.test.ts` holds v7's markup byte for byte (250 cases: no width and Nuvemshop images included, the latter with a store's own provider) with those helpers, outside the package since it depends on no `@decocms/apps-*`. Browser-safe | | `@decocms/tanstack/vite` | `src/vite/index.ts` — `decoTanstack({ buildHash })` (= `decoContent()` from `@decocms/blocks/vite`, `__BUILD_HASH__`, a browser stub for `node:async_hooks`, `resolve.dedupe`), `gitBuildHash` | | `@decocms/tanstack/scope` | `src/scope/server.ts` / `browser.ts` — the request scope the worker hands the render (`AsyncLocalStorage`). Conditional export: `workerd` and `node` before `browser`, because a Workers build turns `browser` on too; `src/__tests__/exports.test.ts` resolves it under those conditions | @@ -140,7 +140,7 @@ Every client builds its requests with `createInstrumentedFetch({ provider: "`. -- **`deco-v7-to-v8-migration`** — moves a v7 site onto v8: `scripts/main.ts` (run with `bun` or `npx tsx`) writes the block map with legacy aliases, vendors the app loaders the content calls, moves content to `.deco/blocks`, re-encrypts secrets and reports every v7 import with its replacement. `SKILL.md` is the index; `reference/` holds the script steps, manual steps, verification, import map, legacy-name table, approved parity patterns and the gotchas, one subject per file. Its tests run with `bun run test`; it imports `@decocms/blocks` (`/cli`, `/protocol/keys`, `/secrets`), so keep those exports stable. +- **`deco-v7-to-v8-migration`** — moves a v7 site onto v8: `scripts/main.ts` (run with `bun` or `npx tsx`) writes the block map with legacy aliases, vendors the app loaders the content calls, moves content to `.deco/blocks`, re-encrypts secrets, points the v7 imports the bindings keep at `@decocms/tanstack` (`MOVES` in `imports.ts`) and reports every other v7 import with its replacement. Its lists of the bindings' exports are pinned to `packages/` by `steps.test.ts`, and `bindings.test.ts` type-checks a migrated TanStack fixture against them. `SKILL.md` is the index; `reference/` holds the script steps, manual steps, verification, import map, legacy-name table, approved parity patterns and the gotchas, one subject per file. Its tests run with `bun run test`; it imports `@decocms/blocks` (`/cli`, `/protocol/keys`, `/secrets`), so keep those exports stable. Writing a skill: the `description` is the only thing the model reads before deciding to load it — lead with the package, then "Use when …" with the symbols, file paths, error strings and symptoms someone would actually type. Keep it quoted (the check enforces it). Prefer adding a `reference/` file to an existing skill over a new skill: every skill's description sits in context all the time. diff --git a/README.md b/README.md index 0a970e4c6..ea094261d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ v7 (`@decocms/tanstack` 7.x, `@decocms/nextjs` 7.x, `@decocms/blocks-admin`, `@d - **Your functions, editable:** register any function with a typed first parameter in a block map; the CLI turns its types into editor forms and checks saved content against them. - **Content is JSON in your repo:** saved blocks live in `.deco/blocks`, read through `createCMS` with no database and no framework lock-in. -- **Any framework:** TanStack Start, Next.js App Router or plain Node; the package has no framework binding and runs on Workers, Node and Bun. +- **Any framework:** TanStack Start, Next.js App Router or plain Node; the core package has no framework binding (`@decocms/tanstack` and `@decocms/nextjs` are separate) and runs on Workers, Node and Bun. - **Site-editor ready:** the content protocol (`@decocms/blocks/protocol`) lets the site editor read and write saved blocks without running your code. - **Measured upstream calls:** every client sends through `createInstrumentedFetch`, so each upstream request is timed and labeled. @@ -115,7 +115,7 @@ const [props, error] = await cms.forRelease().resolve("Home Hero"); ### TanStack Start -Follow the docs' TanStack Start guide (`/next/tanstack-start-descriptors`, or `/next/tanstack-start-rsc` for React Server Components): `createCMS` over the content module, `matchRoute` in a catch-all route, one promise per block. In `vite.config.ts`, add `decoContent()` from `@decocms/blocks/vite` so a block saved in the site editor shows in open pages during `vite dev`. [`examples/tanstack-smoke`](./examples/tanstack-smoke) and [`examples/tanstack-rsc-smoke`](./examples/tanstack-rsc-smoke) are complete apps. +Follow the docs' TanStack Start guide (`/next/tanstack-start-descriptors`, or `/next/tanstack-start-rsc` for React Server Components): `createCMS` over the content module, `matchRoute` in a catch-all route, one promise per block. In `vite.config.ts`, add `decoContent()` from `@decocms/blocks/vite` so a block saved in the site editor shows in open pages during `vite dev`. [`examples/tanstack-smoke`](./examples/tanstack-smoke) and [`examples/tanstack-rsc-smoke`](./examples/tanstack-rsc-smoke) are complete apps. A site on Cloudflare Workers deployed the Deco way uses [`@decocms/tanstack`](./packages/tanstack): `createDecoWorker` (drafts, the edge cache, Studio framing), `DecoDocument`, the router, `Image`/`Picture` and `decoTanstack()` (which includes `decoContent()`); [`examples/tanstack-worker-smoke`](./examples/tanstack-worker-smoke) is the same app on it. ### Next.js App Router diff --git a/knip.json b/knip.json index 1737319c6..dcac87740 100644 --- a/knip.json +++ b/knip.json @@ -16,8 +16,12 @@ "ignoreDependencies": [ "@decocms/blocks", "@decocms/apps-algolia", + "@decocms/apps-shopify", "@decocms/apps-vtex", - "@decocms/nextjs" + "@decocms/nextjs", + "@decocms/tanstack", + "react", + "react-dom" ] }, "packages/*": { diff --git a/packages/tanstack/README.md b/packages/tanstack/README.md index d49852e64..9061a6e92 100644 --- a/packages/tanstack/README.md +++ b/packages/tanstack/README.md @@ -56,4 +56,24 @@ export const Route = createRootRoute({ component: () => `, say) renders `` in its `` instead: the bridge, the badge and the draft they read. +## Images + +```tsx +// src/imageUrl.ts: the site's image URLs (@decocms/blocks/image plus its platform's helpers) +import { createImageUrl, decoCdn } from "@decocms/blocks/image"; +import { vtexCatalog } from "@decocms/apps-vtex/image"; // a VTEX store +import { shopifyCdn } from "@decocms/apps-shopify/image"; // a Shopify store +export const imageUrl = createImageUrl({ + providers: [vtexCatalog(), shopifyCdn(), decoCdn({ quality: "high", sources: "all" })], +}); + +// src/router.tsx: around every page +createDecoRouter({ routeTree, Wrap: ({ children }) => {children} }); + +// any component + +``` + +`Image`, `Picture` and `Source` from `/react` take v7's props and render v7's markup, so a migrated call site only changes its import. This package knows no commerce platform: v7's components resized VTEX catalog and Shopify images on those platforms' hosts, and a site keeps those URLs by listing the platform's helper before `decoCdn` as above (v7's URLs, byte for byte, with the `decoCdn` options of the 7.x release the site ran, e.g. `stripAssetHosts: "leading"` from 7.71.4: the v7 to v8 migration skill lists them). An `Image` or `Source` without a width writes v7's `width=NaN` candidates, as v7 did. Without an `ImageUrlProvider` every source goes through `assets.decocms.com` (`DECO_IMAGE_URL`). + `examples/tanstack-worker-smoke` in the repository is a complete app. diff --git a/packages/tanstack/src/react/Image.test.tsx b/packages/tanstack/src/react/Image.test.tsx new file mode 100644 index 000000000..0d832b327 --- /dev/null +++ b/packages/tanstack/src/react/Image.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment node +/** + * The image components' own behaviour. v7's markup, byte for byte over 160 + * cases, is checked in `tests/tanstack-image-markup.golden.test.ts`, with the + * VTEX and Shopify helpers a site supplies (this package depends on no + * `@decocms/apps-*` package). + */ +import { createImageUrl, decoCdn } from "@decocms/blocks/image"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { DECO_IMAGE_URL, Image, ImageUrlProvider, useImageUrl } from "./index.ts"; + +const decoimsHigh = createImageUrl({ + providers: [decoCdn({ domain: "decoims.com", quality: "high", sources: "all" })], +}); + +describe("no platform knowledge", () => { + it("without a provider, a VTEX catalog image goes through the Deco CDN like any other source", () => { + const html = renderToStaticMarkup( + createElement(Image, { + src: "https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg", + width: 300, + height: 300, + }), + ); + expect(html).toContain( + 'src="https://assets.decocms.com/image?fit=cover&width=300&height=300&src=', + ); + }); + + it("a site's provider decides the URL", () => { + const html = renderToStaticMarkup( + createElement( + ImageUrlProvider, + { value: decoimsHigh }, + createElement(Image, { src: "/static/hero.png", width: 100, height: 50 }), + ), + ); + expect(html).toContain( + 'src="https://decoims.com/image?fit=cover&width=100&height=50&quality=high&src=/static/hero.png"', + ); + }); +}); + +describe("the provider", () => { + it("useImageUrl gives DECO_IMAGE_URL without a provider, and the provider's value under one", () => { + let seen: unknown; + const Probe = () => { + seen = useImageUrl(); + return null; + }; + renderToStaticMarkup(createElement(Probe)); + expect(seen).toBe(DECO_IMAGE_URL); + renderToStaticMarkup( + createElement(ImageUrlProvider, { value: decoimsHigh }, createElement(Probe)), + ); + expect(seen).toBe(decoimsHigh); + }); + + it("never warns about a missing height", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + renderToStaticMarkup(createElement(Image, { src: "/a.png", width: 10 })); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/packages/tanstack/src/react/Image.tsx b/packages/tanstack/src/react/Image.tsx new file mode 100644 index 000000000..c25bd4d82 --- /dev/null +++ b/packages/tanstack/src/react/Image.tsx @@ -0,0 +1,213 @@ +"use client"; +/** + * `Image`, `Picture` and `Source`: v7's components (`@decocms/blocks` 7.x + * `hooks/Image.tsx` and `hooks/Picture.tsx`) with the same props and the same + * markup, so call sites move by changing the import. The URLs come from an + * `ImageUrl` (`@decocms/blocks/image`) instead of v7's module-wide + * `registerImageCdnDomain` / `registerImageQuality`: wrap the app in + * `` with the site's own `createImageUrl`. + * + * This package knows no commerce platform. v7's components resized VTEX + * catalog and Shopify images on those platforms' hosts; a site that shows + * them lists the platform's helper first in its own `createImageUrl` + * (`vtexCatalog()` from `@decocms/apps-vtex/image`, `shopifyCdn()` from + * `@decocms/apps-shopify/image`), then `decoCdn({ sources: "all" })`, and + * gets v7's URLs byte for byte. Without a provider every source goes through + * the Deco image CDN (`assets.decocms.com`, the CDN's default quality): + * {@link DECO_IMAGE_URL}. + * + * Difference from v7, on purpose: no "Missing height" warning on every render. + */ +import { + createImageUrl, + decoCdn, + type Fit, + type ImageUrl, + srcSet as srcSetOf, +} from "@decocms/blocks/image"; +import { + type ComponentPropsWithoutRef, + type Context, + createContext, + forwardRef, + type ImgHTMLAttributes, + type ReactNode, + useContext, + useMemo, +} from "react"; + +/** + * The URLs without an `ImageUrlProvider`: every source through the Deco image + * CDN, as v7 sent every source that wasn't a VTEX catalog or Shopify image. + */ +export const DECO_IMAGE_URL: ImageUrl = createImageUrl({ + providers: [decoCdn({ sources: "all" })], +}); + +interface Contexts { + imageUrl: Context; + preload: Context<{ preload: boolean }>; +} + +// One pair of contexts per process even if a bundler duplicates this module, +// so a provider always reaches its components. +const KEY = Symbol.for("decocms.tanstack.imageContexts"); +const holder = globalThis as unknown as Record; +const contexts: Contexts = holder[KEY] ?? { + imageUrl: createContext(DECO_IMAGE_URL), + preload: createContext({ preload: false }), +}; +holder[KEY] = contexts; + +/** The site's image URLs for every `Image`, `Picture` and `Source` below it. */ +export function ImageUrlProvider({ value, children }: { value: ImageUrl; children?: ReactNode }) { + return {children}; +} + +/** The `ImageUrl` the nearest `ImageUrlProvider` gives ({@link DECO_IMAGE_URL} without one). */ +export function useImageUrl(): ImageUrl { + return useContext(contexts.imageUrl); +} + +const DEFAULT_SIZES = "(max-width: 768px) 100vw, 50vw"; + +export interface ImageProps + extends Omit, "src" | "width" | "height"> { + src: string; + /** @description Improves Web Vitals (CLS/LCP) */ + width: number; + /** @description Improves Web Vitals (CLS/LCP) */ + height?: number; + /** @description Object-fit */ + fit?: Fit; + /** + * @description Web Vitals (LCP). Injects a `` tag + * alongside the ``, sets `fetchPriority="high"` and `loading="eager"`. + * Use once per page for the LCP image. + */ + preload?: boolean; + /** @description Media query for responsive preloading (e.g. "(min-width: 768px)") */ + media?: string; +} + +export const Image = forwardRef(function Image( + { + src, + width, + height, + fit = "cover", + preload, + media, + loading, + decoding, + srcSet: srcSetProp, + sizes, + fetchPriority, + ...rest + }, + ref, +) { + const url = useImageUrl(); + const optimizedSrc = url(src, { width, height, fit }); + const srcSet = srcSetProp ?? srcSetOf(url, src, { width, height, fit }); + const resolvedSizes = srcSet ? (sizes ?? DEFAULT_SIZES) : undefined; + + return ( + <> + {preload && ( + + )} + {/* biome-ignore lint/a11y/useAltText: alt comes from the caller's props, as in v7 */} + + + ); +}); + +export type SourceProps = Omit, "width" | "height"> & { + src: string; + /** @description Improves Web Vitals (CLS|LCP) */ + width: number; + /** @description Improves Web Vitals (CLS|LCP) */ + height?: number; + /** @description Improves Web Vitals (LCP). Use high for LCP image. */ + fetchPriority?: "high" | "low" | "auto"; + /** @description Object-fit */ + fit?: Fit; +}; + +/** A `` with its srcset built for it; inside ``, it also preloads for its media query. */ +export const Source = forwardRef(function Source( + { src, width, height, fetchPriority, fit = "cover", ...rest }, + ref, +) { + const { preload } = useContext(contexts.preload); + const url = useImageUrl(); + const optimizedSrc = url(src, { width, height, fit }); + const srcSet = rest.srcSet ?? srcSetOf(url, src, { width, height, fit }); + + return ( + <> + {preload && ( + + )} + + + ); +}); + +export type PictureProps = ComponentPropsWithoutRef<"picture"> & { + children: ReactNode; + /** @description When true, child `` elements inject `` tags for their media queries. */ + preload?: boolean; +}; + +/** + * Art direction: ``s per media query and an `` fallback. + * + * ```tsx + * + * + * + * + * + * ``` + */ +export const Picture = forwardRef(function Picture( + { children, preload = false, ...props }, + ref, +) { + const value = useMemo(() => ({ preload }), [preload]); + return ( + + + {children} + + + ); +}); diff --git a/packages/tanstack/src/react/index.ts b/packages/tanstack/src/react/index.ts index 118fadba3..af8acf417 100644 --- a/packages/tanstack/src/react/index.ts +++ b/packages/tanstack/src/react/index.ts @@ -1,6 +1,6 @@ /** - * `@decocms/tanstack/react`: the root document and its pieces. Safe in the - * browser bundle. + * `@decocms/tanstack/react`: the root document and its pieces, and the image + * components. Safe in the browser bundle. */ export { DecoDocument, @@ -11,3 +11,14 @@ export { type NavigationProgressProps, StableOutlet, } from "./DecoDocument.tsx"; +export { + DECO_IMAGE_URL, + Image, + type ImageProps, + ImageUrlProvider, + Picture, + type PictureProps, + Source, + type SourceProps, + useImageUrl, +} from "./Image.tsx"; diff --git a/tests/__fixtures__/tanstack-v7-image-markup-nowidth-nuvemshop.jsonl b/tests/__fixtures__/tanstack-v7-image-markup-nowidth-nuvemshop.jsonl new file mode 100644 index 000000000..e838d1a6e --- /dev/null +++ b/tests/__fixtures__/tanstack-v7-image-markup-nowidth-nuvemshop.jsonl @@ -0,0 +1,90 @@ +{"config":"default","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{},"html":""} +{"config":"default","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"height":300},"html":""} +{"config":"default","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"preload":true},"html":""} +{"config":"default","kind":"PictureNoWidth","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"PictureNoWidth","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{},"html":""} +{"config":"default","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"height":300},"html":""} +{"config":"default","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"preload":true},"html":""} +{"config":"default","kind":"PictureNoWidth","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"PictureNoWidth","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{},"html":""} +{"config":"default","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"height":300},"html":""} +{"config":"default","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"preload":true},"html":""} +{"config":"default","kind":"PictureNoWidth","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"PictureNoWidth","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"https://images.example.com/photo.jpg","props":{},"html":""} +{"config":"default","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"height":300},"html":""} +{"config":"default","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"preload":true},"html":""} +{"config":"default","kind":"PictureNoWidth","src":"https://images.example.com/photo.jpg","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"PictureNoWidth","src":"https://images.example.com/photo.jpg","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"/static/hero.png","props":{},"html":""} +{"config":"default","kind":"Image","src":"/static/hero.png","props":{"height":300},"html":""} +{"config":"default","kind":"Image","src":"/static/hero.png","props":{"preload":true},"html":""} +{"config":"default","kind":"PictureNoWidth","src":"/static/hero.png","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"PictureNoWidth","src":"/static/hero.png","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{},"html":""} +{"config":"default","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"height":300},"html":""} +{"config":"default","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"preload":true},"html":""} +{"config":"default","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"width":40},"html":""} +{"config":"default","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"width":300,"height":300},"html":""} +{"config":"default","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"width":640},"html":""} +{"config":"default","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"width":641},"html":""} +{"config":"default","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"width":400,"preload":true},"html":""} +{"config":"default","kind":"PictureNoWidth","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"PictureNoWidth","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{},"html":""} +{"config":"default","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"height":300},"html":""} +{"config":"default","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"preload":true},"html":""} +{"config":"default","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"width":40},"html":""} +{"config":"default","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"width":300,"height":300},"html":""} +{"config":"default","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"width":640},"html":""} +{"config":"default","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"width":641},"html":""} +{"config":"default","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"width":400,"preload":true},"html":""} +{"config":"default","kind":"PictureNoWidth","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"PictureNoWidth","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"height":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"preload":true},"html":""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"height":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"preload":true},"html":""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"height":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"preload":true},"html":""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"https://images.example.com/photo.jpg","props":{},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"height":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"preload":true},"html":""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"https://images.example.com/photo.jpg","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"https://images.example.com/photo.jpg","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"/static/hero.png","props":{},"html":""} +{"config":"decoims-high","kind":"Image","src":"/static/hero.png","props":{"height":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"/static/hero.png","props":{"preload":true},"html":""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"/static/hero.png","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"/static/hero.png","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"height":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"preload":true},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"width":40},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"width":300,"height":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"width":640},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"width":641},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"width":400,"preload":true},"html":""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"https://acme.mitiendanube.com/stores/001/234/products/sneaker-1-480-0.webp","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"height":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"preload":true},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"width":40},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"width":300,"height":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"width":640},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"width":641},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"width":400,"preload":true},"html":""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"PictureNoWidth","src":"https://dcdn-us.mitiendanube.com/stores/005/products/bag-1024-1024.jpg","props":{"preload":true},"html":"\"x\""} diff --git a/tests/__fixtures__/tanstack-v7-image-markup.jsonl b/tests/__fixtures__/tanstack-v7-image-markup.jsonl new file mode 100644 index 000000000..fd0030252 --- /dev/null +++ b/tests/__fixtures__/tanstack-v7-image-markup.jsonl @@ -0,0 +1,160 @@ +{"config":"default","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":300},"html":""} +{"config":"default","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":300,"height":200},"html":""} +{"config":"default","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"default","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"default","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"default","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"default","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"default","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"default","kind":"Picture","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"Picture","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":300},"html":""} +{"config":"default","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":300,"height":200},"html":""} +{"config":"default","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"default","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"default","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"default","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"default","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"default","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"default","kind":"Picture","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"Picture","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":300},"html":""} +{"config":"default","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":300,"height":200},"html":""} +{"config":"default","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"default","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"default","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"default","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"default","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"default","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"default","kind":"Picture","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"Picture","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":300},"html":""} +{"config":"default","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":300,"height":200},"html":""} +{"config":"default","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"default","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"default","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"default","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"default","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"default","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"default","kind":"Picture","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"Picture","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":300},"html":""} +{"config":"default","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":300,"height":200},"html":""} +{"config":"default","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"default","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"default","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"default","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"default","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"default","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"default","kind":"Picture","src":"https://images.example.com/photo.jpg","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"Picture","src":"https://images.example.com/photo.jpg","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"/static/hero.png","props":{"width":300},"html":""} +{"config":"default","kind":"Image","src":"/static/hero.png","props":{"width":300,"height":200},"html":""} +{"config":"default","kind":"Image","src":"/static/hero.png","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"default","kind":"Image","src":"/static/hero.png","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"default","kind":"Image","src":"/static/hero.png","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"default","kind":"Image","src":"/static/hero.png","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"default","kind":"Image","src":"/static/hero.png","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"default","kind":"Image","src":"/static/hero.png","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"default","kind":"Picture","src":"/static/hero.png","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"Picture","src":"/static/hero.png","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":300},"html":""} +{"config":"default","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":300,"height":200},"html":""} +{"config":"default","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"default","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"default","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"default","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"default","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"default","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"default","kind":"Picture","src":"data:image/png;base64,AAAA","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"Picture","src":"data:image/png;base64,AAAA","props":{"preload":true},"html":"\"x\""} +{"config":"default","kind":"Image","src":"","props":{"width":300},"html":""} +{"config":"default","kind":"Image","src":"","props":{"width":300,"height":200},"html":""} +{"config":"default","kind":"Image","src":"","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"default","kind":"Image","src":"","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"default","kind":"Image","src":"","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"default","kind":"Image","src":"","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"default","kind":"Image","src":"","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"default","kind":"Image","src":"","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"default","kind":"Picture","src":"","props":{"preload":false},"html":"\"x\""} +{"config":"default","kind":"Picture","src":"","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":300,"height":200},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"decoims-high","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Picture","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"Picture","src":"https://assets.decocache.com/a-storefront/0f1e2d3c-1111-4222-8333-444455556666/banner.png","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":300,"height":200},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"decoims-high","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Picture","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"Picture","src":"https://deco-sites-assets.s3.sa-east-1.amazonaws.com/a-storefront/1a2b/logo.webp","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":300,"height":200},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"decoims-high","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Picture","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"Picture","src":"https://a-store.vtexassets.com/arquivos/ids/155392-800-800/shirt.jpg?v=1","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":300,"height":200},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"decoims-high","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Picture","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"Picture","src":"https://cdn.shopify.com/s/files/1/0001/products/shoe.jpg?v=2","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":300,"height":200},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"decoims-high","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"decoims-high","kind":"Image","src":"https://images.example.com/photo.jpg","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Picture","src":"https://images.example.com/photo.jpg","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"Picture","src":"https://images.example.com/photo.jpg","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"/static/hero.png","props":{"width":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"/static/hero.png","props":{"width":300,"height":200},"html":""} +{"config":"decoims-high","kind":"Image","src":"/static/hero.png","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"decoims-high","kind":"Image","src":"/static/hero.png","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"decoims-high","kind":"Image","src":"/static/hero.png","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"decoims-high","kind":"Image","src":"/static/hero.png","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Image","src":"/static/hero.png","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"decoims-high","kind":"Image","src":"/static/hero.png","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Picture","src":"/static/hero.png","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"Picture","src":"/static/hero.png","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":300,"height":200},"html":""} +{"config":"decoims-high","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"decoims-high","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"decoims-high","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"decoims-high","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"decoims-high","kind":"Image","src":"data:image/png;base64,AAAA","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Picture","src":"data:image/png;base64,AAAA","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"Picture","src":"data:image/png;base64,AAAA","props":{"preload":true},"html":"\"x\""} +{"config":"decoims-high","kind":"Image","src":"","props":{"width":300},"html":""} +{"config":"decoims-high","kind":"Image","src":"","props":{"width":300,"height":200},"html":""} +{"config":"decoims-high","kind":"Image","src":"","props":{"width":300,"height":200,"fit":"contain","alt":"A","className":"w-full"},"html":"\"A\""} +{"config":"decoims-high","kind":"Image","src":"","props":{"width":1280,"height":400,"preload":true},"html":""} +{"config":"decoims-high","kind":"Image","src":"","props":{"width":1280,"height":400,"preload":true,"media":"(min-width: 768px)","sizes":"100vw"},"html":""} +{"config":"decoims-high","kind":"Image","src":"","props":{"width":300,"height":300,"loading":"eager","decoding":"sync","fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Image","src":"","props":{"width":300,"height":300,"srcSet":"a.png 1x, b.png 2x"},"html":""} +{"config":"decoims-high","kind":"Image","src":"","props":{"width":320,"height":280,"preload":true,"fetchPriority":"low"},"html":""} +{"config":"decoims-high","kind":"Picture","src":"","props":{"preload":false},"html":"\"x\""} +{"config":"decoims-high","kind":"Picture","src":"","props":{"preload":true},"html":"\"x\""} diff --git a/tests/tanstack-image-markup.golden.test.ts b/tests/tanstack-image-markup.golden.test.ts new file mode 100644 index 000000000..b6517ebad --- /dev/null +++ b/tests/tanstack-image-markup.golden.test.ts @@ -0,0 +1,170 @@ +// @vitest-environment node +/** + * `Image`, `Picture` and `Source` from `@decocms/tanstack/react` render v7's + * markup. `__fixtures__/tanstack-v7-image-markup.jsonl` was rendered by v7's + * own components (`@decocms/blocks` 7.x `hooks/Image.tsx` and + * `hooks/Picture.tsx`) over a set of sources and props, once with v7's + * defaults and once with `registerImageCdnDomain("decoims.com")` plus + * `registerImageQuality("high")`. Each must come out byte for byte. + * + * The package knows no commerce platform, so the test supplies the URLs the + * way a site does: VTEX's and Shopify's helpers (`@decocms/apps-vtex/image`, + * `@decocms/apps-shopify/image`) listed before the Deco CDN in its own + * `createImageUrl`, under an `ImageUrlProvider`. It lives here, not in the + * package, because `@decocms/tanstack` depends on no `@decocms/apps-*` + * package, not even for its tests. + */ +import { readFileSync } from "node:fs"; +import { shopifyCdn } from "@decocms/apps-shopify/image"; +import { vtexCatalog } from "@decocms/apps-vtex/image"; +import { createImageUrl, decoCdn, type ImageProvider, type ImageUrl } from "@decocms/blocks/image"; +import { Image, ImageUrlProvider, Picture, Source } from "@decocms/tanstack/react"; +import { createElement, type ReactElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +interface Case { + config: "default" | "decoims-high"; + kind: "Image" | "Picture" | "PictureNoWidth"; + src: string; + props: Record; + html: string; +} + +const fixture = (name: string): Case[] => + readFileSync(new URL(`./__fixtures__/${name}`, import.meta.url), "utf-8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + +const cases = fixture("tanstack-v7-image-markup.jsonl"); +/** + * Rendered by the same v7 components (7.x `hooks/Image.tsx`, `hooks/Picture.tsx`): + * `Image`s and `Source`s without a width (v7 wrote `width=NaN … NaNw` + * candidates) and Nuvemshop product images (v7 picked one of the CDN's fixed + * sizes by a filename suffix). + */ +const extra = fixture("tanstack-v7-image-markup-nowidth-nuvemshop.jsonl"); + +/** + * A Nuvemshop store's own provider, as `reference/framework-code.md` in the + * migration skill gives it: no `@decocms/apps-*` package covers Nuvemshop, so + * the site keeps v7's rule in its `src/imageUrl.ts`. + */ +const NUVEMSHOP_IMAGE = + /^(https:\/\/[a-z0-9-]+\.mitiendanube\.com\/stores\/.+\/products\/.+)-\d+-\d+\.(\w+)$/; +const NUVEMSHOP_SIZES = [50, 100, 240, 320, 480, 640]; +const nuvemshop: ImageProvider = { + name: "nuvemshop", + match: (src) => NUVEMSHOP_IMAGE.test(src), + format(src, { width }) { + const m = src.match(NUVEMSHOP_IMAGE); + if (!m) return null; + const size = NUVEMSHOP_SIZES.find((s) => s >= width); + return `${m[1]}-${size ? `${size}-0` : "1024-1024"}.${m[2]}`; + }, +}; + +/** A site's `src/imageUrl.ts`, for each v7 configuration. */ +const SITE: Record = { + default: createImageUrl({ + providers: [vtexCatalog(), shopifyCdn(), decoCdn({ sources: "all" })], + }), + "decoims-high": createImageUrl({ + providers: [ + vtexCatalog(), + shopifyCdn(), + decoCdn({ domain: "decoims.com", quality: "high", sources: "all" }), + ], + }), +}; + +function tree(c: Case): ReactElement { + if (c.kind === "Image") return createElement(Image, { src: c.src, ...c.props } as never); + const { src } = c; + if (c.kind === "PictureNoWidth") { + const nuvem = src.includes("mitiendanube"); + return createElement( + Picture, + { preload: c.props.preload as boolean, className: "block" } as never, + createElement(Source, { media: "(max-width: 767px)", src, height: 280 } as never), + createElement(Source, { + media: "(min-width: 768px)", + src, + width: nuvem ? 1280 : undefined, + } as never), + createElement(Image, { src, alt: "x" } as never), + ); + } + return createElement( + Picture, + { preload: c.props.preload as boolean, className: "block" } as never, + createElement(Source, { media: "(max-width: 767px)", src, width: 320, height: 280 }), + createElement(Source, { + media: "(min-width: 768px)", + src, + width: 1280, + height: 280, + fetchPriority: "low", + }), + createElement(Source, { media: "print", src, width: 640, srcSet: "p.png" }), + createElement(Image, { src, width: 1280, height: 280, alt: "x" }), + ); +} + +function diffs(list: Case[], wrap: (c: Case, el: ReactElement) => ReactElement) { + return list + .map((c) => ({ c, got: renderToStaticMarkup(wrap(c, tree(c))) })) + .filter(({ c, got }) => got !== c.html) + .map(({ c, got }) => ({ config: c.config, src: c.src, props: c.props, expected: c.html, got })); +} + +// React warns about the empty `src` cases, as it did for v7's components. +let errors: ReturnType; +beforeAll(() => { + errors = vi.spyOn(console, "error").mockImplementation(() => {}); +}); +afterAll(() => errors.mockRestore()); + +describe("v7's markup, byte for byte", () => { + it("covers both configs, Image and Picture (160 cases)", () => { + expect(cases).toHaveLength(160); + expect(new Set(cases.map((c) => `${c.config}/${c.kind}`))).toEqual( + new Set(["default/Image", "default/Picture", "decoims-high/Image", "decoims-high/Picture"]), + ); + }); + + it("with the site's ImageUrlProvider (VTEX and Shopify helpers, then the Deco CDN)", () => { + expect( + diffs(cases, (c, el) => createElement(ImageUrlProvider, { value: SITE[c.config] }, el)), + ).toEqual([]); + }); + + it("without a width, and Nuvemshop images with the store's own provider (90 cases)", () => { + expect(extra).toHaveLength(90); + const site: Record = { + default: createImageUrl({ + providers: [vtexCatalog(), shopifyCdn(), nuvemshop, decoCdn({ sources: "all" })], + }), + "decoims-high": createImageUrl({ + providers: [ + vtexCatalog(), + shopifyCdn(), + nuvemshop, + decoCdn({ domain: "decoims.com", quality: "high", sources: "all" }), + ], + }), + }; + expect( + diffs(extra, (c, el) => createElement(ImageUrlProvider, { value: site[c.config] }, el)), + ).toEqual([]); + }); + + it("with no provider, every source that isn't a VTEX or Shopify image: v7's defaults", () => { + const neutral = cases.filter( + (c) => c.config === "default" && !/vtexassets|cdn\.shopify\.com/.test(c.src), + ); + expect(neutral.length).toBeGreaterThan(0); + expect(diffs(neutral, (_c, el) => el)).toEqual([]); + }); +});