From 199490083f11d66b8c74fe595adc5f4712f27e78 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 15:39:34 -0300 Subject: [PATCH 01/85] refactor(monorepo): split @decocms/start into a bun workspace monorepo + ship @decocms/next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed history of PR #307 (originally merged to a branch named 7.x, then migrated to v7 — see below). Three phases plus release automation: Phase 1 — Monorepo split ========================= Splits the single `@decocms/start` package into a 5-package Bun workspace with a one-way dependency graph: packages/live → @decocms/live (CMS core: cms/, matchers/, sdk/, hooks/, types/, admin schema composition — named "live" not "runtime": @decocms/runtime already exists on npm as a real, unrelated, actively-published package at v2.3.1) packages/admin → @decocms/admin (admin protocol handlers, depends on live) packages/cli → @decocms/cli (codegen scripts + Fresh→ TanStack migration tooling, depends on live) packages/tanstack → @decocms/tanstack (TanStack Start + Cloudflare Workers binding: workerEntry, cmsRoute, vite plugin, daemon; depends on live + admin) packages/next → @decocms/next (new) Root-caused and fixed the real bug motivating the split: dist/tsup bundling caused module-state duplication across package boundaries, which is why an earlier attempt at this shipped as v5.2.2 and had to be reverted. This split ships plain .ts source exports with no bundling step. Phase 2 — @decocms/next (Next.js App Router binding) ====================================================== New package mirroring @decocms/tanstack's capabilities for Next.js: SectionRenderer, DeferredSectionBoundary (RSC Suspense streaming), client-only-forced section rendering, DecoPageRenderer, createDecoPage, DecoRootLayout, and Route Handler re-exports of the admin protocol. Validated against tanstack-smoke and next-smoke fixtures. Follow-up cleanup ================== Refreshed README.md, CLAUDE.md, and deep-dive docs for the new architecture. Replaced GAP_ANALYSIS.md + GAP_ANALYSIS_V2.md with docs/known-gaps.md. Audited all ~30 .cursor/skills/ directories: deleted 8 stale ones, merged 2 near-duplicate pairs, path-fixed the rest. Forward-ported 3 fixes that landed on main while this branch was in flight (x-deco-matchers-override, @title/@ignore JSDoc on app loaders, a vite dev-watcher delta-apply fix for a real production incident). Release automation (v7 branch, ci/release.yml + .releaserc.v7.json) ====================================================================== main keeps releasing the legacy @decocms/start (v*.*.* tags, unchanged config). v7 releases the 5 new @decocms/* packages independently, under a separate tag namespace (live-v*.*.*) via a dedicated semantic-release config — deliberately NOT sharing main's `branches` array, since main's @decocms/start and the 5 new packages are unrelated npm packages with no real version relationship. Two hard-won, non-obvious fixes baked into this config, both costly to rediscover: 1. The branch cannot be named like a semver range ("7.x"). Semantic- release auto-classifies any `N.x`-shaped branch NAME as a "maintenance" branch — regardless of how it's written in `branches` config, since classification runs on the resolved branch name, not the config-side glob. Maintenance branches are structurally required to have a LOWER version than a companion "release"-type branch, which is backwards from what we want (v7 should be newer than legacy main). No in-branches-array trick escapes this (confirmed empirically against real semantic-release source: lib/branches/index.js's per-type branchesValidator). Renamed the branch 7.x -> v7 to sidestep it entirely — "v7" doesn't match the range-shape pattern, so it's just a normal branch with a seeded starting tag (live-v7.0.0, on a commit unique to this branch's history, NOT shared with main — sharing it caused semantic-release to compute an impossible `>=7.0.0 <7.0.0`-shaped range once, since both branches would "see" the same tag as their own last release). v7 still needs a companion branch in `branches` (semantic-release requires ≥1 non-maintenance-shaped branch even for a single-branch config) — used a genuine orphan placeholder branch (decocms-live-legacy-anchor, pinned at a fixed v0.0.1 seed tag) rather than main, since main's own fluctuating computed version was ALSO capping v7's valid range in confusing ways. 2. semantic-release's `--dry-run` only skips its OWN native git-tag creation step. It calls `plugins.prepare()` and `plugins.publish()` UNCONDITIONALLY either way, and @semantic-release/exec's prepare/publish hooks don't check dry-run themselves. A naive exec-based publishCmd would attempt a REAL `npm publish` during a "dry run". .releaserc.v7.json's publishCmd checks a `$DRY_RUN` env var (set by the workflow from the workflow_dispatch dry_run input) before calling npm — verified this is a real, not theoretical, risk: an early version of this config without the guard reached a real `npm publish --access public` for @decocms/admin during testing (caught immediately; failed on 404 only because publish permissions aren't provisioned yet, not because of the dry-run guard — that guard didn't exist at the time). Added a workflow_dispatch trigger with a dry_run input (default true) specifically so the version computation and publish plan can be inspected in real CI logs before trusting an automatic push-triggered release — this is genuinely a one-way door (5 packages that have never been published before) and deserves that scrutiny. First verified computation: 7.0.1. Known follow-up, not yet resolved: whatever npm credentials are currently configured for this repo's Actions (NODE_AUTH_TOKEN via actions/setup-node) return 404 (not authorized to CREATE new packages) for @decocms/admin and @decocms/cli — confirmed via the real publish attempt above. The actual first release will need proper npm token provisioning with create-package rights on the @decocms org before it can succeed. Verified: all 5 packages typecheck clean and pass their full test suites via vitest (live 455, admin 15, next 10, cli 369, tanstack 59 — 908 tests total). Co-Authored-By: Claude Sonnet 5 --- .agents/skills/deco-migrate-script/SKILL.md | 44 +- .../deco-next-package-migration/SKILL.md | 32 + .../references/import-mapping.md | 30 + .../templates/admin-routes.md | 151 ++ .../templates/setup-ts.md | 186 +++ .cursor/skills/deco-api-call-dedup/SKILL.md | 445 ++++-- .../skills/deco-apps-architecture/SKILL.md | 255 ---- .../deco-apps-architecture/app-pattern.md | 288 ---- .../deco-apps-architecture/commerce-types.md | 239 ---- .../deco-apps-architecture/new-app-guide.md | 268 ---- .../deco-apps-architecture/scripts-codegen.md | 148 -- .../deco-apps-architecture/shared-utils.md | 181 --- .../vtex-deep-structure.md | 253 ---- .../deco-apps-architecture/website-app.md | 169 --- .../skills/deco-apps-vtex-porting/SKILL.md | 2 +- .../cookie-auth-patterns.md | 161 --- .../deco-apps-vtex-porting/website-porting.md | 36 +- .cursor/skills/deco-apps-vtex-review/SKILL.md | 122 +- .../skills/deco-cms-layout-caching/SKILL.md | 55 +- .cursor/skills/deco-cms-route-config/SKILL.md | 99 +- .../deco-vs-deco-start.md | 191 --- .cursor/skills/deco-e2e-testing/SKILL.md | 182 ++- .cursor/skills/deco-e2e-testing/discovery.md | 52 +- .../deco-e2e-testing/templates/package.json | 9 +- .../templates/scripts/baseline.ts | 63 +- .../templates/scripts/run-e2e.ts | 84 +- .../templates/specs/ecommerce-flow.spec.ts | 47 +- .../templates/utils/metrics-collector.ts | 622 ++++----- .../deco-e2e-testing/troubleshooting.md | 107 +- .cursor/skills/deco-edge-caching/SKILL.md | 24 +- .../checklists/asset-optimization.md | 123 +- .../deco-full-analysis/checklists/bug-fix.md | 31 +- .../checklists/cache-strategy.md | 79 +- .../checklists/dependency-update.md | 150 -- .../checklists/hydration-fix.md | 46 +- .../checklists/image-optimization.md | 56 +- .../checklists/loader-optimization.md | 79 +- .../deco-full-analysis/checklists/seo-fix.md | 51 +- .../checklists/site-cleanup.md | 281 ---- .../skills/deco-incident-debugging/SKILL.md | 179 ++- .../deco-incident-debugging/headless-mode.md | 61 +- .../learnings-index.md | 227 ++- .../triage-workflow.md | 101 +- .../deco-loader-n-plus-1-detector/SKILL.md | 275 ---- .../deco-server-functions-invoke/SKILL.md | 31 +- .../architecture.md | 85 +- .../deco-server-functions-invoke/generator.md | 118 +- .../deco-server-functions-invoke/problem.md | 3 +- .../troubleshooting.md | 16 +- .../deco-site-memory-debugging/SKILL.md | 208 ++- .../cdp-connection.md | 194 +-- .../memory-analysis.md | 171 ++- .cursor/skills/deco-site-patterns/SKILL.md | 124 -- .../deco-site-patterns/app-composition.md | 337 ----- .../deco-site-patterns/client-patterns.md | 341 ----- .../skills/deco-site-patterns/cms-wiring.md | 230 --- .../deco-site-patterns/section-patterns.md | 340 ----- .../skills/deco-site-scaling-tuning/SKILL.md | 30 +- .../analysis-scripts.md | 8 + .../skills/deco-start-architecture/SKILL.md | 218 --- .../deco-start-architecture/admin-protocol.md | 156 --- .../deco-start-architecture/cms-resolution.md | 277 ---- .../deco-start-architecture/code-quality.md | 158 --- .../deco-start-architecture/gap-analysis.md | 129 -- .../deco-start-architecture/sdk-utilities.md | 197 --- .../worker-entry-caching.md | 154 --- .cursor/skills/deco-startup-analysis/SKILL.md | 248 ---- .../deco-storefront-test-checklist/SKILL.md | 8 +- .cursor/skills/deco-typescript-fixes/SKILL.md | 178 --- .../deco-typescript-fixes/common-fixes.md | 330 ----- .../skills/deco-typescript-fixes/strategy.md | 148 -- .cursor/skills/deco-vtex-fetch-cache/SKILL.md | 2 +- .cursor/skills/find-skills/SKILL.md | 133 -- .github/workflows/release.yml | 52 +- .gitignore | 4 + .releaserc.json | 4 +- .releaserc.v7.json | 27 + CLAUDE.md | 182 ++- FRAMEWORK_TODO.md | 123 +- GAP_ANALYSIS.md | 224 --- GAP_ANALYSIS_V2.md | 1013 -------------- README.md | 220 +-- biome.json | 2 +- bun.lock | 1229 ++++++++++++----- docs/fast-deploy.md | 16 +- docs/hydration-and-ssr-migration.md | 4 +- docs/known-gaps.md | 44 + docs/next-steps-tanstack-native.md | 20 +- docs/observability.md | 18 +- docs/troubleshooting.md | 4 +- examples/next-smoke/next-env.d.ts | 6 + examples/next-smoke/next.config.ts | 25 + examples/next-smoke/package.json | 24 + .../next-smoke/src/app/[[...slug]]/page.tsx | 12 + .../next-smoke/src/app/deco-decofile/route.ts | 1 + examples/next-smoke/src/app/layout.tsx | 6 + .../next-smoke/src/app/live/meta/route.ts | 1 + examples/next-smoke/src/sections/Hero.tsx | 14 + examples/next-smoke/src/setup.ts | 35 + examples/next-smoke/tsconfig.json | 20 + examples/tanstack-smoke/package.json | 28 + examples/tanstack-smoke/src/router.tsx | 22 + examples/tanstack-smoke/src/routes/$.tsx | 6 + examples/tanstack-smoke/src/routes/__root.tsx | 10 + examples/tanstack-smoke/src/setup.ts | 15 + examples/tanstack-smoke/tsconfig.json | 16 + examples/tanstack-smoke/vite.config.ts | 13 + package.json | 154 +-- packages/admin/package.json | 39 + {src => packages/admin/src}/admin/cors.ts | 0 .../admin/src}/admin/decofile.test.ts | 18 +- {src => packages/admin/src}/admin/decofile.ts | 27 +- {src => packages/admin/src}/admin/index.ts | 4 +- .../admin/src}/admin/invoke.test.ts | 2 +- {src => packages/admin/src}/admin/invoke.ts | 2 +- .../admin/src}/admin/liveControls.ts | 0 {src => packages/admin/src}/admin/meta.ts | 4 +- {src => packages/admin/src}/admin/render.ts | 9 +- {src => packages/admin/src}/admin/setup.ts | 2 +- .../admin/src}/apps/autoconfig.ts | 4 +- {src => packages/admin/src}/apps/index.ts | 0 packages/admin/src/createAdminSetup.ts | 64 + {src => packages/admin/src}/sdk/htmlShell.ts | 0 {src => packages/admin/src}/sdk/setupApps.ts | 6 +- packages/admin/tsconfig.json | 7 + packages/cli/package.json | 44 + .../cli/scripts}/analyze-traces.mjs | 0 .../audit-observability-config.test.ts | 0 .../scripts}/audit-observability-config.ts | 0 .../cli/scripts}/deco-migrate-cli.ts | 0 .../cli/scripts}/fast-deploy-kv.test.ts | 2 +- packages/cli/scripts/generate-blocks.test.ts | 94 ++ .../cli/scripts}/generate-blocks.ts | 4 +- .../cli/scripts}/generate-invoke.test.ts | 2 +- .../cli/scripts}/generate-invoke.ts | 6 +- .../cli/scripts}/generate-loaders.ts | 6 +- .../cli/scripts}/generate-schema.ts | 2 +- .../cli/scripts}/generate-sections.ts | 4 +- .../cli/scripts}/htmx-analyze.ts | 0 .../cli/scripts}/lib/blocks-dedupe.test.ts | 0 .../cli/scripts}/lib/blocks-dedupe.ts | 0 .../cli/scripts}/lib/cf-kv-rest.ts | 0 .../cli/scripts}/lib/jsonc.ts | 0 .../cli/scripts}/lib/kv-snapshot.ts | 2 +- .../cli/scripts}/lib/read-decofile.ts | 0 .../cli/scripts}/lib/sync-helpers.ts | 0 .../cli/scripts}/migrate-blocks-to-kv.ts | 0 .../cli/scripts}/migrate-post-cleanup.ts | 0 .../migrate-to-cf-observability.test.ts | 0 .../scripts}/migrate-to-cf-observability.ts | 0 {scripts => packages/cli/scripts}/migrate.ts | 0 .../migrate/analyzers/htmx-analyze.test.ts | 0 .../migrate/analyzers/htmx-analyze.ts | 0 .../migrate/analyzers/island-classifier.ts | 0 .../migrate/analyzers/loader-inventory.ts | 0 .../migrate/analyzers/section-metadata.ts | 0 .../migrate/analyzers/theme-extractor.ts | 0 .../cli/scripts}/migrate/colors.ts | 0 .../cli/scripts}/migrate/config.test.ts | 0 .../cli/scripts}/migrate/config.ts | 0 .../scripts}/migrate/phase-analyze.test.ts | 0 .../cli/scripts}/migrate/phase-analyze.ts | 0 .../migrate/phase-cleanup-audit.test.ts | 0 .../scripts}/migrate/phase-cleanup-audit.ts | 0 .../scripts}/migrate/phase-cleanup.test.ts | 0 .../cli/scripts}/migrate/phase-cleanup.ts | 0 .../scripts}/migrate/phase-compile.test.ts | 0 .../cli/scripts}/migrate/phase-compile.ts | 0 .../cli/scripts}/migrate/phase-report.ts | 0 .../cli/scripts}/migrate/phase-scaffold.ts | 0 .../cli/scripts}/migrate/phase-transform.ts | 0 .../cli/scripts}/migrate/phase-verify.test.ts | 0 .../cli/scripts}/migrate/phase-verify.ts | 0 .../scripts}/migrate/post-cleanup/rules.ts | 0 .../migrate/post-cleanup/runner.test.ts | 0 .../scripts}/migrate/post-cleanup/runner.ts | 0 .../post-cleanup/shim-classify.test.ts | 0 .../migrate/post-cleanup/shim-classify.ts | 0 .../scripts}/migrate/post-cleanup/types.ts | 0 .../scripts}/migrate/source-layout.test.ts | 0 .../cli/scripts}/migrate/source-layout.ts | 0 .../cli/scripts}/migrate/templates/app-css.ts | 0 .../migrate/templates/cache-config.ts | 0 .../migrate/templates/commerce-loaders.ts | 0 .../migrate/templates/cursor-rules.test.ts | 0 .../migrate/templates/cursor-rules.ts | 0 .../scripts}/migrate/templates/hooks.test.ts | 0 .../cli/scripts}/migrate/templates/hooks.ts | 0 .../scripts}/migrate/templates/knip-config.ts | 0 .../migrate/templates/lib-utils.test.ts | 0 .../scripts}/migrate/templates/lib-utils.ts | 0 .../templates/lockfile-check-yml.test.ts | 0 .../migrate/templates/lockfile-check-yml.ts | 0 .../migrate/templates/package-json.ts | 0 .../cli/scripts}/migrate/templates/routes.ts | 0 .../cli/scripts}/migrate/templates/sdk-gen.ts | 0 .../migrate/templates/section-loaders.ts | 0 .../migrate/templates/server-entry.ts | 0 .../cli/scripts}/migrate/templates/setup.ts | 0 .../scripts}/migrate/templates/tsconfig.ts | 0 .../scripts}/migrate/templates/types-gen.ts | 0 .../migrate/templates/ui-components.ts | 0 .../scripts}/migrate/templates/vite-config.ts | 0 .../scripts}/migrate/transforms/dead-code.ts | 0 .../scripts}/migrate/transforms/deno-isms.ts | 0 .../scripts}/migrate/transforms/fresh-apis.ts | 0 .../migrate/transforms/htmx-on-events.test.ts | 0 .../migrate/transforms/htmx-on-events.ts | 0 .../scripts}/migrate/transforms/imports.ts | 0 .../cli/scripts}/migrate/transforms/jsx.ts | 0 .../migrate/transforms/section-conventions.ts | 0 .../scripts}/migrate/transforms/tailwind.ts | 0 .../cli/scripts}/migrate/types.ts | 0 .../cli/scripts}/smoke-otlp-errorlog.ts | 2 +- .../cli/scripts}/smoke-otlp-meter.ts | 2 +- .../cli/scripts}/sync-blocks-to-kv.ts | 0 .../cli/scripts}/tailwind-lint.ts | 0 packages/cli/tsconfig.json | 7 + packages/live/package.json | 97 ++ .../live/src}/cms/applySectionConventions.ts | 0 .../live/src}/cms/blockSource.test.ts | 0 {src => packages/live/src}/cms/blockSource.ts | 0 {src => packages/live/src}/cms/index.ts | 13 + packages/live/src/cms/layoutCacheRace.test.ts | 146 ++ .../src/cms/loadDecofileDirectory.test.ts | 52 + .../live/src/cms/loadDecofileDirectory.ts | 48 + {src => packages/live/src}/cms/loader.test.ts | 0 {src => packages/live/src}/cms/loader.ts | 27 +- .../live/src}/cms/registry.test.ts | 0 {src => packages/live/src}/cms/registry.ts | 0 .../live/src}/cms/resolve.test.ts | 0 {src => packages/live/src}/cms/resolve.ts | 2 +- .../admin => packages/live/src/cms}/schema.ts | 0 .../live/src}/cms/sectionLoaders.test.ts | 0 .../live/src}/cms/sectionLoaders.ts | 0 .../live/src}/cms/sectionMixins.test.ts | 0 .../live/src}/cms/sectionMixins.ts | 0 .../live/src}/hooks/LazySection.tsx | 10 + .../live/src}/hooks/LiveControls.tsx | 0 .../live/src}/hooks/RenderSection.test.tsx | 0 .../live/src}/hooks/RenderSection.tsx | 0 .../live/src}/hooks/SectionErrorFallback.tsx | 12 + packages/live/src/hooks/index.ts | 4 + {src => packages/live/src}/index.ts | 5 +- .../live/src}/matchers/builtins.test.ts | 0 .../live/src}/matchers/builtins.ts | 0 .../live/src}/matchers/countryNames.ts | 0 .../live/src}/matchers/override.test.ts | 0 .../live/src}/matchers/override.ts | 0 .../live/src}/matchers/posthog.ts | 0 .../live/src}/middleware/decoState.ts | 0 .../live/src}/middleware/healthMetrics.ts | 0 .../src}/middleware/hydrationContext.test.ts | 0 .../live/src}/middleware/hydrationContext.ts | 0 .../live/src}/middleware/index.ts | 0 .../live/src}/middleware/liveness.ts | 0 .../src}/middleware/observability.test.ts | 0 .../live/src}/middleware/observability.ts | 0 .../src}/middleware/validateSection.test.ts | 0 .../live/src}/middleware/validateSection.ts | 0 .../live/src}/sdk/abTesting.test.ts | 0 {src => packages/live/src}/sdk/abTesting.ts | 0 {src => packages/live/src}/sdk/analytics.ts | 0 .../live/src}/sdk/cacheHeaders.test.ts | 0 .../live/src}/sdk/cacheHeaders.ts | 0 .../live/src}/sdk/cachedLoader.ts | 0 {src => packages/live/src}/sdk/clx.ts | 0 {src => packages/live/src}/sdk/cn.test.ts | 0 {src => packages/live/src}/sdk/cn.ts | 0 .../live/src}/sdk/composite.test.ts | 0 {src => packages/live/src}/sdk/composite.ts | 0 {src => packages/live/src}/sdk/cookie.test.ts | 0 {src => packages/live/src}/sdk/cookie.ts | 0 {src => packages/live/src}/sdk/crypto.ts | 0 {src => packages/live/src}/sdk/csp.ts | 0 {src => packages/live/src}/sdk/djb2.ts | 0 .../live/src}/sdk/encoding.test.ts | 0 {src => packages/live/src}/sdk/encoding.ts | 0 {src => packages/live/src}/sdk/env.ts | 0 {src => packages/live/src}/sdk/http.test.ts | 0 {src => packages/live/src}/sdk/http.ts | 0 {src => packages/live/src}/sdk/index.ts | 1 - .../live/src}/sdk/inflightTimeout.test.ts | 0 .../live/src}/sdk/inflightTimeout.ts | 0 .../live/src}/sdk/instrumentedFetch.test.ts | 0 .../live/src}/sdk/instrumentedFetch.ts | 0 {src => packages/live/src}/sdk/invoke.test.ts | 0 {src => packages/live/src}/sdk/invoke.ts | 0 {src => packages/live/src}/sdk/logger.test.ts | 0 {src => packages/live/src}/sdk/logger.ts | 0 .../live/src}/sdk/mergeCacheControl.ts | 0 .../live/src}/sdk/normalizeUrls.ts | 0 .../live/src}/sdk/observability.ts | 0 {src => packages/live/src}/sdk/otel.test.ts | 0 {src => packages/live/src}/sdk/otel.ts | 0 .../live/src}/sdk/otelAdapters.test.ts | 0 .../live/src}/sdk/otelAdapters.ts | 0 .../sdk/otelAdapters/clickhouseCollector.ts | 0 .../live/src}/sdk/otelHttpLog.test.ts | 0 {src => packages/live/src}/sdk/otelHttpLog.ts | 0 .../live/src}/sdk/otelHttpMeter.test.ts | 0 .../live/src}/sdk/otelHttpMeter.ts | 0 .../live/src}/sdk/otelHttpTracer.test.ts | 0 .../live/src}/sdk/otelHttpTracer.ts | 0 {src => packages/live/src}/sdk/redirects.ts | 0 .../live/src}/sdk/requestContext.ts | 14 +- .../sdk/requestContextStorage.browser.test.ts | 29 + .../src/sdk/requestContextStorage.browser.ts | 43 + .../live/src/sdk/requestContextStorage.ts | 35 + {src => packages/live/src}/sdk/retry.ts | 0 .../live/src}/sdk/serverTimings.ts | 0 {src => packages/live/src}/sdk/signal.ts | 0 {src => packages/live/src}/sdk/sitemap.ts | 0 .../live/src}/sdk/urlRedaction.test.ts | 0 .../live/src}/sdk/urlRedaction.ts | 0 {src => packages/live/src}/sdk/urlUtils.ts | 0 .../live/src}/sdk/useDevice.test.ts | 0 packages/live/src/sdk/useDevice.ts | 109 ++ packages/live/src/sdk/useDeviceContext.tsx | 108 ++ {src => packages/live/src}/sdk/useId.ts | 0 .../live/src}/sdk/useScript.test.ts | 0 {src => packages/live/src}/sdk/useScript.ts | 0 .../live/src}/sdk/useSuggestions.test.ts | 0 .../live/src}/sdk/useSuggestions.ts | 0 .../live/src}/sdk/wrapCaughtErrors.ts | 0 packages/live/src/setup.ts | 119 ++ {src => packages/live/src}/types/index.ts | 0 {src => packages/live/src}/types/widgets.ts | 0 packages/live/tsconfig.json | 7 + packages/next/package.json | 36 + packages/next/src/ClientOnlySection.tsx | 20 + packages/next/src/DecoPageRenderer.test.tsx | 27 + packages/next/src/DecoPageRenderer.tsx | 103 ++ packages/next/src/DecoRootLayout.test.tsx | 22 + packages/next/src/DecoRootLayout.tsx | 82 ++ packages/next/src/DeferredSection.test.tsx | 27 + packages/next/src/DeferredSection.tsx | 59 + packages/next/src/SectionRenderer.test.tsx | 97 ++ packages/next/src/SectionRenderer.tsx | 62 + packages/next/src/createDecoPage.test.tsx | 65 + packages/next/src/createDecoPage.tsx | 107 ++ packages/next/src/index.ts | 14 + packages/next/src/routeHandlers.test.ts | 11 + packages/next/src/routeHandlers.ts | 37 + packages/next/tsconfig.json | 7 + packages/tanstack/package.json | 48 + .../tanstack/src}/cms/kvBlockSource.test.ts | 2 +- .../tanstack/src}/cms/kvBlockSource.ts | 2 +- {src => packages/tanstack/src}/daemon/auth.ts | 0 {src => packages/tanstack/src}/daemon/fs.ts | 0 .../tanstack/src}/daemon/index.ts | 0 .../tanstack/src}/daemon/middleware.ts | 0 .../tanstack/src}/daemon/tunnel.ts | 0 .../tanstack/src}/daemon/volumes.ts | 0 .../tanstack/src}/daemon/watch.ts | 0 .../tanstack/src}/hooks/DecoPageRenderer.tsx | 12 +- .../tanstack/src}/hooks/DecoRootLayout.tsx | 4 +- .../src}/hooks/NavigationProgress.tsx | 0 .../tanstack/src}/hooks/PreviewProviders.tsx | 0 .../tanstack/src}/hooks/StableOutlet.tsx | 0 {src => packages/tanstack/src}/hooks/index.ts | 6 +- packages/tanstack/src/index.ts | 30 + .../tanstack/src}/routes/adminRoutes.ts | 7 +- .../tanstack/src}/routes/cmsRoute.ts | 19 +- .../tanstack/src}/routes/components.tsx | 4 +- .../tanstack/src}/routes/index.ts | 4 +- .../tanstack/src}/routes/pageUrl.test.ts | 0 .../tanstack/src}/routes/pageUrl.ts | 0 .../src}/routes/withSiteGlobals.test.ts | 4 +- .../tanstack/src}/routes/withSiteGlobals.ts | 4 +- .../tanstack/src}/sdk/cookiePassthrough.ts | 0 .../tanstack/src}/sdk/createInvoke.ts | 0 .../tanstack/src}/sdk/kvHydration.test.ts | 3 +- .../tanstack/src}/sdk/kvHydration.ts | 6 +- {src => packages/tanstack/src}/sdk/router.ts | 0 .../tanstack/src}/sdk/useHydrated.ts | 0 .../tanstack/src}/sdk/workerEntry.test.ts | 0 .../tanstack/src}/sdk/workerEntry.ts | 43 +- packages/tanstack/src/setupFastDeploy.ts | 13 + {src => packages/tanstack/src}/vite/plugin.js | 51 +- .../tanstack/src}/vite/plugin.test.js | 0 packages/tanstack/tsconfig.json | 7 + scripts/sync-versions.mjs | 37 + src/sdk/useDevice.ts | 184 --- src/setup.ts | 192 --- tsconfig.json => tsconfig.base.json | 7 +- vitest.config.ts | 13 +- 387 files changed, 6707 insertions(+), 10467 deletions(-) create mode 100644 .agents/skills/deco-next-package-migration/SKILL.md create mode 100644 .agents/skills/deco-next-package-migration/references/import-mapping.md create mode 100644 .agents/skills/deco-next-package-migration/templates/admin-routes.md create mode 100644 .agents/skills/deco-next-package-migration/templates/setup-ts.md delete mode 100644 .cursor/skills/deco-apps-architecture/SKILL.md delete mode 100644 .cursor/skills/deco-apps-architecture/app-pattern.md delete mode 100644 .cursor/skills/deco-apps-architecture/commerce-types.md delete mode 100644 .cursor/skills/deco-apps-architecture/new-app-guide.md delete mode 100644 .cursor/skills/deco-apps-architecture/scripts-codegen.md delete mode 100644 .cursor/skills/deco-apps-architecture/shared-utils.md delete mode 100644 .cursor/skills/deco-apps-architecture/vtex-deep-structure.md delete mode 100644 .cursor/skills/deco-apps-architecture/website-app.md delete mode 100644 .cursor/skills/deco-apps-vtex-porting/cookie-auth-patterns.md delete mode 100644 .cursor/skills/deco-core-architecture/deco-vs-deco-start.md delete mode 100644 .cursor/skills/deco-full-analysis/checklists/dependency-update.md delete mode 100644 .cursor/skills/deco-full-analysis/checklists/site-cleanup.md delete mode 100644 .cursor/skills/deco-loader-n-plus-1-detector/SKILL.md delete mode 100644 .cursor/skills/deco-site-patterns/SKILL.md delete mode 100644 .cursor/skills/deco-site-patterns/app-composition.md delete mode 100644 .cursor/skills/deco-site-patterns/client-patterns.md delete mode 100644 .cursor/skills/deco-site-patterns/cms-wiring.md delete mode 100644 .cursor/skills/deco-site-patterns/section-patterns.md delete mode 100644 .cursor/skills/deco-start-architecture/SKILL.md delete mode 100644 .cursor/skills/deco-start-architecture/admin-protocol.md delete mode 100644 .cursor/skills/deco-start-architecture/cms-resolution.md delete mode 100644 .cursor/skills/deco-start-architecture/code-quality.md delete mode 100644 .cursor/skills/deco-start-architecture/gap-analysis.md delete mode 100644 .cursor/skills/deco-start-architecture/sdk-utilities.md delete mode 100644 .cursor/skills/deco-start-architecture/worker-entry-caching.md delete mode 100644 .cursor/skills/deco-startup-analysis/SKILL.md delete mode 100644 .cursor/skills/deco-typescript-fixes/SKILL.md delete mode 100644 .cursor/skills/deco-typescript-fixes/common-fixes.md delete mode 100644 .cursor/skills/deco-typescript-fixes/strategy.md delete mode 100644 .cursor/skills/find-skills/SKILL.md create mode 100644 .releaserc.v7.json delete mode 100644 GAP_ANALYSIS.md delete mode 100644 GAP_ANALYSIS_V2.md create mode 100644 docs/known-gaps.md create mode 100644 examples/next-smoke/next-env.d.ts create mode 100644 examples/next-smoke/next.config.ts create mode 100644 examples/next-smoke/package.json create mode 100644 examples/next-smoke/src/app/[[...slug]]/page.tsx create mode 100644 examples/next-smoke/src/app/deco-decofile/route.ts create mode 100644 examples/next-smoke/src/app/layout.tsx create mode 100644 examples/next-smoke/src/app/live/meta/route.ts create mode 100644 examples/next-smoke/src/sections/Hero.tsx create mode 100644 examples/next-smoke/src/setup.ts create mode 100644 examples/next-smoke/tsconfig.json create mode 100644 examples/tanstack-smoke/package.json create mode 100644 examples/tanstack-smoke/src/router.tsx create mode 100644 examples/tanstack-smoke/src/routes/$.tsx create mode 100644 examples/tanstack-smoke/src/routes/__root.tsx create mode 100644 examples/tanstack-smoke/src/setup.ts create mode 100644 examples/tanstack-smoke/tsconfig.json create mode 100644 examples/tanstack-smoke/vite.config.ts create mode 100644 packages/admin/package.json rename {src => packages/admin/src}/admin/cors.ts (100%) rename {src => packages/admin/src}/admin/decofile.test.ts (85%) rename {src => packages/admin/src}/admin/decofile.ts (86%) rename {src => packages/admin/src}/admin/index.ts (86%) rename {src => packages/admin/src}/admin/invoke.test.ts (98%) rename {src => packages/admin/src}/admin/invoke.ts (99%) rename {src => packages/admin/src}/admin/liveControls.ts (100%) rename {src => packages/admin/src}/admin/meta.ts (95%) rename {src => packages/admin/src}/admin/render.ts (98%) rename {src => packages/admin/src}/admin/setup.ts (98%) rename {src => packages/admin/src}/apps/autoconfig.ts (97%) rename {src => packages/admin/src}/apps/index.ts (100%) create mode 100644 packages/admin/src/createAdminSetup.ts rename {src => packages/admin/src}/sdk/htmlShell.ts (100%) rename {src => packages/admin/src}/sdk/setupApps.ts (98%) create mode 100644 packages/admin/tsconfig.json create mode 100644 packages/cli/package.json rename {scripts => packages/cli/scripts}/analyze-traces.mjs (100%) rename {scripts => packages/cli/scripts}/audit-observability-config.test.ts (100%) rename {scripts => packages/cli/scripts}/audit-observability-config.ts (100%) rename {scripts => packages/cli/scripts}/deco-migrate-cli.ts (100%) rename {scripts => packages/cli/scripts}/fast-deploy-kv.test.ts (98%) create mode 100644 packages/cli/scripts/generate-blocks.test.ts rename {scripts => packages/cli/scripts}/generate-blocks.ts (98%) rename {scripts => packages/cli/scripts}/generate-invoke.test.ts (99%) rename {scripts => packages/cli/scripts}/generate-invoke.ts (98%) rename {scripts => packages/cli/scripts}/generate-loaders.ts (97%) rename {scripts => packages/cli/scripts}/generate-schema.ts (99%) rename {scripts => packages/cli/scripts}/generate-sections.ts (98%) rename {scripts => packages/cli/scripts}/htmx-analyze.ts (100%) rename {scripts => packages/cli/scripts}/lib/blocks-dedupe.test.ts (100%) rename {scripts => packages/cli/scripts}/lib/blocks-dedupe.ts (100%) rename {scripts => packages/cli/scripts}/lib/cf-kv-rest.ts (100%) rename {scripts => packages/cli/scripts}/lib/jsonc.ts (100%) rename {scripts => packages/cli/scripts}/lib/kv-snapshot.ts (96%) rename {scripts => packages/cli/scripts}/lib/read-decofile.ts (100%) rename {scripts => packages/cli/scripts}/lib/sync-helpers.ts (100%) rename {scripts => packages/cli/scripts}/migrate-blocks-to-kv.ts (100%) mode change 100644 => 100755 rename {scripts => packages/cli/scripts}/migrate-post-cleanup.ts (100%) rename {scripts => packages/cli/scripts}/migrate-to-cf-observability.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate-to-cf-observability.ts (100%) rename {scripts => packages/cli/scripts}/migrate.ts (100%) rename {scripts => packages/cli/scripts}/migrate/analyzers/htmx-analyze.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/analyzers/htmx-analyze.ts (100%) rename {scripts => packages/cli/scripts}/migrate/analyzers/island-classifier.ts (100%) rename {scripts => packages/cli/scripts}/migrate/analyzers/loader-inventory.ts (100%) rename {scripts => packages/cli/scripts}/migrate/analyzers/section-metadata.ts (100%) rename {scripts => packages/cli/scripts}/migrate/analyzers/theme-extractor.ts (100%) rename {scripts => packages/cli/scripts}/migrate/colors.ts (100%) rename {scripts => packages/cli/scripts}/migrate/config.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/config.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-analyze.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-analyze.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-cleanup-audit.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-cleanup-audit.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-cleanup.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-cleanup.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-compile.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-compile.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-report.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-scaffold.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-transform.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-verify.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/phase-verify.ts (100%) rename {scripts => packages/cli/scripts}/migrate/post-cleanup/rules.ts (100%) rename {scripts => packages/cli/scripts}/migrate/post-cleanup/runner.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/post-cleanup/runner.ts (100%) rename {scripts => packages/cli/scripts}/migrate/post-cleanup/shim-classify.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/post-cleanup/shim-classify.ts (100%) rename {scripts => packages/cli/scripts}/migrate/post-cleanup/types.ts (100%) rename {scripts => packages/cli/scripts}/migrate/source-layout.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/source-layout.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/app-css.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/cache-config.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/commerce-loaders.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/cursor-rules.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/cursor-rules.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/hooks.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/hooks.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/knip-config.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/lib-utils.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/lib-utils.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/lockfile-check-yml.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/lockfile-check-yml.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/package-json.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/routes.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/sdk-gen.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/section-loaders.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/server-entry.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/setup.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/tsconfig.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/types-gen.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/ui-components.ts (100%) rename {scripts => packages/cli/scripts}/migrate/templates/vite-config.ts (100%) rename {scripts => packages/cli/scripts}/migrate/transforms/dead-code.ts (100%) rename {scripts => packages/cli/scripts}/migrate/transforms/deno-isms.ts (100%) rename {scripts => packages/cli/scripts}/migrate/transforms/fresh-apis.ts (100%) rename {scripts => packages/cli/scripts}/migrate/transforms/htmx-on-events.test.ts (100%) rename {scripts => packages/cli/scripts}/migrate/transforms/htmx-on-events.ts (100%) rename {scripts => packages/cli/scripts}/migrate/transforms/imports.ts (100%) rename {scripts => packages/cli/scripts}/migrate/transforms/jsx.ts (100%) rename {scripts => packages/cli/scripts}/migrate/transforms/section-conventions.ts (100%) rename {scripts => packages/cli/scripts}/migrate/transforms/tailwind.ts (100%) rename {scripts => packages/cli/scripts}/migrate/types.ts (100%) rename {scripts => packages/cli/scripts}/smoke-otlp-errorlog.ts (94%) rename {scripts => packages/cli/scripts}/smoke-otlp-meter.ts (95%) rename {scripts => packages/cli/scripts}/sync-blocks-to-kv.ts (100%) mode change 100644 => 100755 rename {scripts => packages/cli/scripts}/tailwind-lint.ts (100%) create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/live/package.json rename {src => packages/live/src}/cms/applySectionConventions.ts (100%) rename {src => packages/live/src}/cms/blockSource.test.ts (100%) rename {src => packages/live/src}/cms/blockSource.ts (100%) rename {src => packages/live/src}/cms/index.ts (86%) create mode 100644 packages/live/src/cms/layoutCacheRace.test.ts create mode 100644 packages/live/src/cms/loadDecofileDirectory.test.ts create mode 100644 packages/live/src/cms/loadDecofileDirectory.ts rename {src => packages/live/src}/cms/loader.test.ts (100%) rename {src => packages/live/src}/cms/loader.ts (86%) rename {src => packages/live/src}/cms/registry.test.ts (100%) rename {src => packages/live/src}/cms/registry.ts (100%) rename {src => packages/live/src}/cms/resolve.test.ts (100%) rename {src => packages/live/src}/cms/resolve.ts (99%) rename {src/admin => packages/live/src/cms}/schema.ts (100%) rename {src => packages/live/src}/cms/sectionLoaders.test.ts (100%) rename {src => packages/live/src}/cms/sectionLoaders.ts (100%) rename {src => packages/live/src}/cms/sectionMixins.test.ts (100%) rename {src => packages/live/src}/cms/sectionMixins.ts (100%) rename {src => packages/live/src}/hooks/LazySection.tsx (85%) rename {src => packages/live/src}/hooks/LiveControls.tsx (100%) rename {src => packages/live/src}/hooks/RenderSection.test.tsx (100%) rename {src => packages/live/src}/hooks/RenderSection.tsx (100%) rename {src => packages/live/src}/hooks/SectionErrorFallback.tsx (76%) create mode 100644 packages/live/src/hooks/index.ts rename {src => packages/live/src}/index.ts (62%) rename {src => packages/live/src}/matchers/builtins.test.ts (100%) rename {src => packages/live/src}/matchers/builtins.ts (100%) rename {src => packages/live/src}/matchers/countryNames.ts (100%) rename {src => packages/live/src}/matchers/override.test.ts (100%) rename {src => packages/live/src}/matchers/override.ts (100%) rename {src => packages/live/src}/matchers/posthog.ts (100%) rename {src => packages/live/src}/middleware/decoState.ts (100%) rename {src => packages/live/src}/middleware/healthMetrics.ts (100%) rename {src => packages/live/src}/middleware/hydrationContext.test.ts (100%) rename {src => packages/live/src}/middleware/hydrationContext.ts (100%) rename {src => packages/live/src}/middleware/index.ts (100%) rename {src => packages/live/src}/middleware/liveness.ts (100%) rename {src => packages/live/src}/middleware/observability.test.ts (100%) rename {src => packages/live/src}/middleware/observability.ts (100%) rename {src => packages/live/src}/middleware/validateSection.test.ts (100%) rename {src => packages/live/src}/middleware/validateSection.ts (100%) rename {src => packages/live/src}/sdk/abTesting.test.ts (100%) rename {src => packages/live/src}/sdk/abTesting.ts (100%) rename {src => packages/live/src}/sdk/analytics.ts (100%) rename {src => packages/live/src}/sdk/cacheHeaders.test.ts (100%) rename {src => packages/live/src}/sdk/cacheHeaders.ts (100%) rename {src => packages/live/src}/sdk/cachedLoader.ts (100%) rename {src => packages/live/src}/sdk/clx.ts (100%) rename {src => packages/live/src}/sdk/cn.test.ts (100%) rename {src => packages/live/src}/sdk/cn.ts (100%) rename {src => packages/live/src}/sdk/composite.test.ts (100%) rename {src => packages/live/src}/sdk/composite.ts (100%) rename {src => packages/live/src}/sdk/cookie.test.ts (100%) rename {src => packages/live/src}/sdk/cookie.ts (100%) rename {src => packages/live/src}/sdk/crypto.ts (100%) rename {src => packages/live/src}/sdk/csp.ts (100%) rename {src => packages/live/src}/sdk/djb2.ts (100%) rename {src => packages/live/src}/sdk/encoding.test.ts (100%) rename {src => packages/live/src}/sdk/encoding.ts (100%) rename {src => packages/live/src}/sdk/env.ts (100%) rename {src => packages/live/src}/sdk/http.test.ts (100%) rename {src => packages/live/src}/sdk/http.ts (100%) rename {src => packages/live/src}/sdk/index.ts (96%) rename {src => packages/live/src}/sdk/inflightTimeout.test.ts (100%) rename {src => packages/live/src}/sdk/inflightTimeout.ts (100%) rename {src => packages/live/src}/sdk/instrumentedFetch.test.ts (100%) rename {src => packages/live/src}/sdk/instrumentedFetch.ts (100%) rename {src => packages/live/src}/sdk/invoke.test.ts (100%) rename {src => packages/live/src}/sdk/invoke.ts (100%) rename {src => packages/live/src}/sdk/logger.test.ts (100%) rename {src => packages/live/src}/sdk/logger.ts (100%) rename {src => packages/live/src}/sdk/mergeCacheControl.ts (100%) rename {src => packages/live/src}/sdk/normalizeUrls.ts (100%) rename {src => packages/live/src}/sdk/observability.ts (100%) rename {src => packages/live/src}/sdk/otel.test.ts (100%) rename {src => packages/live/src}/sdk/otel.ts (100%) rename {src => packages/live/src}/sdk/otelAdapters.test.ts (100%) rename {src => packages/live/src}/sdk/otelAdapters.ts (100%) rename {src => packages/live/src}/sdk/otelAdapters/clickhouseCollector.ts (100%) rename {src => packages/live/src}/sdk/otelHttpLog.test.ts (100%) rename {src => packages/live/src}/sdk/otelHttpLog.ts (100%) rename {src => packages/live/src}/sdk/otelHttpMeter.test.ts (100%) rename {src => packages/live/src}/sdk/otelHttpMeter.ts (100%) rename {src => packages/live/src}/sdk/otelHttpTracer.test.ts (100%) rename {src => packages/live/src}/sdk/otelHttpTracer.ts (100%) rename {src => packages/live/src}/sdk/redirects.ts (100%) rename {src => packages/live/src}/sdk/requestContext.ts (91%) create mode 100644 packages/live/src/sdk/requestContextStorage.browser.test.ts create mode 100644 packages/live/src/sdk/requestContextStorage.browser.ts create mode 100644 packages/live/src/sdk/requestContextStorage.ts rename {src => packages/live/src}/sdk/retry.ts (100%) rename {src => packages/live/src}/sdk/serverTimings.ts (100%) rename {src => packages/live/src}/sdk/signal.ts (100%) rename {src => packages/live/src}/sdk/sitemap.ts (100%) rename {src => packages/live/src}/sdk/urlRedaction.test.ts (100%) rename {src => packages/live/src}/sdk/urlRedaction.ts (100%) rename {src => packages/live/src}/sdk/urlUtils.ts (100%) rename {src => packages/live/src}/sdk/useDevice.test.ts (100%) create mode 100644 packages/live/src/sdk/useDevice.ts create mode 100644 packages/live/src/sdk/useDeviceContext.tsx rename {src => packages/live/src}/sdk/useId.ts (100%) rename {src => packages/live/src}/sdk/useScript.test.ts (100%) rename {src => packages/live/src}/sdk/useScript.ts (100%) rename {src => packages/live/src}/sdk/useSuggestions.test.ts (100%) rename {src => packages/live/src}/sdk/useSuggestions.ts (100%) rename {src => packages/live/src}/sdk/wrapCaughtErrors.ts (100%) create mode 100644 packages/live/src/setup.ts rename {src => packages/live/src}/types/index.ts (100%) rename {src => packages/live/src}/types/widgets.ts (100%) create mode 100644 packages/live/tsconfig.json create mode 100644 packages/next/package.json create mode 100644 packages/next/src/ClientOnlySection.tsx create mode 100644 packages/next/src/DecoPageRenderer.test.tsx create mode 100644 packages/next/src/DecoPageRenderer.tsx create mode 100644 packages/next/src/DecoRootLayout.test.tsx create mode 100644 packages/next/src/DecoRootLayout.tsx create mode 100644 packages/next/src/DeferredSection.test.tsx create mode 100644 packages/next/src/DeferredSection.tsx create mode 100644 packages/next/src/SectionRenderer.test.tsx create mode 100644 packages/next/src/SectionRenderer.tsx create mode 100644 packages/next/src/createDecoPage.test.tsx create mode 100644 packages/next/src/createDecoPage.tsx create mode 100644 packages/next/src/index.ts create mode 100644 packages/next/src/routeHandlers.test.ts create mode 100644 packages/next/src/routeHandlers.ts create mode 100644 packages/next/tsconfig.json create mode 100644 packages/tanstack/package.json rename {src => packages/tanstack/src}/cms/kvBlockSource.test.ts (96%) rename {src => packages/tanstack/src}/cms/kvBlockSource.ts (98%) rename {src => packages/tanstack/src}/daemon/auth.ts (100%) rename {src => packages/tanstack/src}/daemon/fs.ts (100%) rename {src => packages/tanstack/src}/daemon/index.ts (100%) rename {src => packages/tanstack/src}/daemon/middleware.ts (100%) rename {src => packages/tanstack/src}/daemon/tunnel.ts (100%) rename {src => packages/tanstack/src}/daemon/volumes.ts (100%) rename {src => packages/tanstack/src}/daemon/watch.ts (100%) rename {src => packages/tanstack/src}/hooks/DecoPageRenderer.tsx (98%) rename {src => packages/tanstack/src}/hooks/DecoRootLayout.tsx (96%) rename {src => packages/tanstack/src}/hooks/NavigationProgress.tsx (100%) rename {src => packages/tanstack/src}/hooks/PreviewProviders.tsx (100%) rename {src => packages/tanstack/src}/hooks/StableOutlet.tsx (100%) rename {src => packages/tanstack/src}/hooks/index.ts (57%) create mode 100644 packages/tanstack/src/index.ts rename {src => packages/tanstack/src}/routes/adminRoutes.ts (92%) rename {src => packages/tanstack/src}/routes/cmsRoute.ts (98%) rename {src => packages/tanstack/src}/routes/components.tsx (91%) rename {src => packages/tanstack/src}/routes/index.ts (81%) rename {src => packages/tanstack/src}/routes/pageUrl.test.ts (100%) rename {src => packages/tanstack/src}/routes/pageUrl.ts (100%) rename {src => packages/tanstack/src}/routes/withSiteGlobals.test.ts (98%) rename {src => packages/tanstack/src}/routes/withSiteGlobals.ts (98%) rename {src => packages/tanstack/src}/sdk/cookiePassthrough.ts (100%) rename {src => packages/tanstack/src}/sdk/createInvoke.ts (100%) rename {src => packages/tanstack/src}/sdk/kvHydration.test.ts (97%) rename {src => packages/tanstack/src}/sdk/kvHydration.ts (97%) rename {src => packages/tanstack/src}/sdk/router.ts (100%) rename {src => packages/tanstack/src}/sdk/useHydrated.ts (100%) rename {src => packages/tanstack/src}/sdk/workerEntry.test.ts (100%) rename {src => packages/tanstack/src}/sdk/workerEntry.ts (98%) create mode 100644 packages/tanstack/src/setupFastDeploy.ts rename {src => packages/tanstack/src}/vite/plugin.js (93%) rename {src => packages/tanstack/src}/vite/plugin.test.js (100%) create mode 100644 packages/tanstack/tsconfig.json create mode 100644 scripts/sync-versions.mjs delete mode 100644 src/sdk/useDevice.ts delete mode 100644 src/setup.ts rename tsconfig.json => tsconfig.base.json (68%) diff --git a/.agents/skills/deco-migrate-script/SKILL.md b/.agents/skills/deco-migrate-script/SKILL.md index e49cce6..c40332e 100644 --- a/.agents/skills/deco-migrate-script/SKILL.md +++ b/.agents/skills/deco-migrate-script/SKILL.md @@ -1,9 +1,9 @@ --- name: deco-migrate-script -description: Automated migration script that converts Deco storefronts from Fresh/Preact/Deno to TanStack Start/React/Cloudflare Workers. Runs 8 phases (analyze, scaffold, transform, cleanup, report, verify, bootstrap, compile). Use when running the migration script, debugging its output, extending it with new transforms, or understanding what it does. Located at scripts/migrate.ts in @decocms/start. +description: Automated migration script that converts Deco storefronts from Fresh/Preact/Deno to TanStack Start/React/Cloudflare Workers. Runs 8 phases (analyze, scaffold, transform, cleanup, report, verify, bootstrap, compile). Use when running the migration script, debugging its output, extending it with new transforms, or understanding what it does. Located at packages/cli/scripts/migrate.ts in @decocms/cli. globs: - - "scripts/migrate.ts" - - "scripts/migrate/**/*" + - "packages/cli/scripts/migrate.ts" + - "packages/cli/scripts/migrate/**/*" --- # Deco Migration Script @@ -13,11 +13,11 @@ Automated TypeScript script that converts a Deco storefront from Fresh/Preact/De ## Quick Start ```bash -# From the NEW site root (already has @decocms/start installed): -npx tsx node_modules/@decocms/start/scripts/migrate.ts --source /path/to/old-site +# From the NEW site root (already has @decocms/cli linked/installed): +npx tsx node_modules/@decocms/cli/scripts/migrate.ts --source /path/to/old-site # Dry run first: -npx tsx node_modules/@decocms/start/scripts/migrate.ts --source /path/to/old-site --dry-run --verbose +npx tsx node_modules/@decocms/cli/scripts/migrate.ts --source /path/to/old-site --dry-run --verbose ``` ### Options @@ -64,8 +64,8 @@ When the file is absent the baked-in casaevideo defaults apply, so existing migr ## Architecture ``` -scripts/migrate.ts ← Entry point, runs all phases -scripts/migrate/ +packages/cli/scripts/migrate.ts ← Entry point, runs all phases +packages/cli/scripts/migrate/ ├── types.ts ← MigrationContext, FileRecord, DetectedPattern ├── colors.ts ← Terminal output formatting ├── phase-analyze.ts ← Phase 1: scan source, detect patterns @@ -160,9 +160,9 @@ Applies 6 transforms in sequence to every source file: preact/hooks → react preact/compat → react preact → react -@preact/signals → @decocms/start/sdk/signal -@deco/deco/hooks → @decocms/start/sdk/useScript -@deco/deco/blocks→ @decocms/start/types +@preact/signals → @decocms/live/sdk/signal +@deco/deco/hooks → @decocms/live/sdk/useScript +@deco/deco/blocks→ @decocms/live/types apps/commerce/* → @decocms/apps/commerce/* apps/website/* → ~/components/ui/* or @decocms/apps/* site/* → ~/* @@ -250,7 +250,7 @@ bg-black bg-opacity-20 → bg-black/20 **Deletes root files:** - `deno.json`, `fresh.gen.ts`, `main.ts`, `dev.ts`, `tailwind.config.ts`, `runtime.ts`, `constants.ts` -**Deletes SDK files** (now in @decocms/start or @decocms/apps): +**Deletes SDK files** (now in @decocms/live or @decocms/apps): - `sdk/clx.ts`, `sdk/useId.ts`, `sdk/useOffer.ts`, `sdk/useVariantPossiblities.ts`, `sdk/usePlatform.tsx` **Moves:** @@ -264,7 +264,7 @@ Generates `MIGRATION_REPORT.md` with: - Manual review items with severity - Always-check section (FormEmail, Slider, Theme, DaisyUI, Tailwind) - Known issues (z-index stacking, opacity modifiers) -- Framework findings (patterns to consolidate into @decocms/start) +- Framework findings (patterns to consolidate into @decocms/live) - Next steps ### Phase 6: Verify @@ -294,8 +294,8 @@ Generates `MIGRATION_REPORT.md` with: Runs automatically after all phases (skipped in `--dry-run`): 1. `bun install` -2. `bunx tsx node_modules/@decocms/start/scripts/generate-blocks.ts` -3. `bunx tsx node_modules/@decocms/start/scripts/generate-invoke.ts` — emits `src/server/invoke.gen.ts` (top-level `createServerFn` declarations for every VTEX action, plus the `forwardResponseCookies()` Set-Cookie bridge). Without this step the site falls back to the `/deco/invoke/...` proxy and the cart breaks at `/checkout` after addItemToCart. See `.cursor/skills/deco-server-functions-invoke/troubleshooting.md` ("Cart 'forgets' items between requests") for the failure mode. +2. `bunx tsx node_modules/@decocms/cli/scripts/generate-blocks.ts` +3. `bunx tsx node_modules/@decocms/cli/scripts/generate-invoke.ts` — emits `src/server/invoke.gen.ts` (top-level `createServerFn` declarations for every VTEX action, plus the `forwardResponseCookies()` Set-Cookie bridge). Without this step the site falls back to the `/deco/invoke/...` proxy and the cart breaks at `/checkout` after addItemToCart. See `.cursor/skills/deco-server-functions-invoke/troubleshooting.md` ("Cart 'forgets' items between requests") for the failure mode. 4. `bunx tsr generate` ### Phase 8: Compile @@ -374,7 +374,7 @@ Platform affects: commerce type imports, loader registration, setup.ts template, ### Adding a New Transform -1. Create `scripts/migrate/transforms/my-transform.ts`: +1. Create `packages/cli/scripts/migrate/transforms/my-transform.ts`: ```typescript import type { TransformResult } from "../types"; @@ -407,7 +407,7 @@ const transforms = [imports, jsx, freshApis, deadCode, denoIsms, tailwind, myTra ### Adding a New Template -1. Create `scripts/migrate/templates/my-file.ts`: +1. Create `packages/cli/scripts/migrate/templates/my-file.ts`: ```typescript import type { MigrationContext } from "../types"; @@ -495,13 +495,13 @@ script leaves behind on existing-but-pre-framework-helpers sites: ```bash # Read-only audit (default) -npx -p @decocms/start deco-post-cleanup +npx -p @decocms/cli deco-post-cleanup # Auto-fix the safe rules (dead-lib-shims, dead-runtime-shim, local-widgets-types) -npx -p @decocms/start deco-post-cleanup --fix +npx -p @decocms/cli deco-post-cleanup --fix # CI gate: auto-fix safe rules, exit 2 if any warnings remain -npx -p @decocms/start deco-post-cleanup --fix --strict +npx -p @decocms/cli deco-post-cleanup --fix --strict ``` The audit covers 7 rules (delete dead lib shims, drop obsolete inline @@ -522,5 +522,5 @@ detection logic mirrors the canonical checklist at shim files have valid TypeScript signatures. The audit's pattern matches surface what compilation cannot. -Source: `scripts/migrate-post-cleanup.ts` + `scripts/migrate/post-cleanup/`. -Tests: `scripts/migrate/post-cleanup/runner.test.ts`. +Source: `packages/cli/scripts/migrate-post-cleanup.ts` + `packages/cli/scripts/migrate/post-cleanup/`. +Tests: `packages/cli/scripts/migrate/post-cleanup/runner.test.ts`. diff --git a/.agents/skills/deco-next-package-migration/SKILL.md b/.agents/skills/deco-next-package-migration/SKILL.md new file mode 100644 index 0000000..d1f2679 --- /dev/null +++ b/.agents/skills/deco-next-package-migration/SKILL.md @@ -0,0 +1,32 @@ +--- +name: deco-next-package-migration +description: Migrates a Next.js App Router site off the abandoned @decocms/start@5.x /next, /core, /node export tiers onto the current @decocms/live, @decocms/admin, and @decocms/next packages. Use when a site's package.json pins @decocms/start to a 5.x-next prerelease, or imports from @decocms/start/next, @decocms/start/core, or @decocms/start/node. +--- + +# Deco Next.js Package Migration + +Moves a Next.js site off the reverted `@decocms/start@5.x` framework-agnostic-entrypoints tiers onto the current package split. Proven on faststore-fila, a production Next.js 15 App Router VTEX FastStore storefront with a pre-existing `.deco/blocks/*.json` legacy content snapshot. + +## When this applies + +- `package.json` has `"@decocms/start": "5.x-next.*"` or similar prerelease pin +- Code imports from `@decocms/start/next`, `@decocms/start/core`, or `@decocms/start/node` +- The site has its own `src/sdk/deco/`-style wrapper layer (or equivalent) around the framework, rather than using the framework's route/page helpers directly + +## Import mapping + +See `references/import-mapping.md` for the full table. Summary: `@decocms/start/core`'s CMS functions (`setBlocks`, `registerSectionLoaders`, etc.) move to `@decocms/live/cms` under the *same names* — these did not change across the package split, only the import path. `@decocms/start/next`'s `createDecoAdminRouteHandlers`/`loadCmsPage` have no direct equivalent — `@decocms/next` splits the admin protocol into one function per concern (`metaGET`, `decofileGET`/`decofilePOST`, `invokePOST`, `renderGET`/`renderPOST`) instead of one dispatcher, and page-level CMS resolution goes through `@decocms/live/cms`'s `resolveDecoPage` + `extractSeoFromSections` directly (or `@decocms/next`'s generic `createDecoPage` helper, if the site doesn't need custom SEO-merging/curated-block-override logic). `@decocms/start/node`'s `loadAllDecofileBlocks` has no equivalent — if the site loads a directory of pre-existing legacy block JSON files (not build-time-generated ones), use `@decocms/live/cms/loadDecofileDirectory` (new). + +## Steps + +1. **Dependencies**: remove the old `@decocms/start` pin, add `@decocms/live`, `@decocms/admin`, and (if the site uses Next.js pages/route handlers directly rather than its own wrapper) `@decocms/next`. During development before anything is published, use `bun link` against a local `deco-start` checkout — see `docs/fast-deploy.md`'s general local-dev-linking pattern, or this migration's own Task 2 for the exact commands. +2. **Section registration**: swap `@decocms/start/core`'s `registerSection`/`registerSectionsSync` imports for `@decocms/live/cms` — same names, no other changes needed. +3. **CMS setup/resolution**: rewrite the site's setup wrapper against `@decocms/live/cms`'s `setBlocks`/`setResolveErrorHandler`/`registerLayoutSections`/`registerSectionLoaders` (same names) plus `resolveDecoPage`/`extractSeoFromSections` (replacing `loadCmsPage`) and, if needed, `loadDecofileDirectory` (replacing `loadAllDecofileBlocks`). See `templates/setup-ts.md` for a full worked example derived from the faststore-fila migration. Keep the wrapper's own exported function names and return shapes unchanged wherever possible — this is what let faststore-fila's page files avoid any changes at all. +4. **Admin routes**: `@decocms/next` exports one function per admin concern rather than one dispatcher — rewrite the site's admin-route wrapper as thin per-concern re-exports. See `templates/admin-routes.md`. Any route the old dispatcher served that has no `@decocms/admin`/`@decocms/next` equivalent (live-editing dev tunnel: file-watch SSE, JSON-Patch file mutation) should be deleted or replaced with a simple non-daemon stub (e.g. a readiness check reading `loadBlocks()`'s size directly) — these were deliberately scoped out of the current package split, not omitted by oversight. +5. **Validate end-to-end against a real dev server**, not just unit tests — specifically the site's actual `.deco/blocks`-derived content (a real page path, not a synthetic fixture), since the on-disk block format and its resolution are exactly the surface most likely to have a real, only-visible-at-runtime discrepancy (see this migration's own Task 6). + +## Gotchas + +- `resolveDecoPage`'s return shape (`DecoPageResult`) is `{name, path, params, blockKey, resolvedSections, deferredSections, seoSection}` — note `seoSection` is a *resolved section*, not a plain `{title, description}` object. Call `extractSeoFromSections([result.seoSection].filter(Boolean))` to get a plain SEO object. +- `findPageByPath` matches on each page block's `.path` field, not on the blocks map's key — so a `loadDecofileDirectory`-style loader can assign any unique key per file without needing to reverse-engineer the original admin's exact key-naming convention. +- Next.js App Router route folder naming is asymmetric between `_` and `.` prefixes, and it's easy to get backwards: `_`-prefixed folders (e.g. `_healthcheck`) must be URL-encoded as `%5Ffoldername`, because Next treats a literal `_folder` as a private folder and excludes it from routing. Dot-prefixed folders (e.g. `.decofile`) must do the **opposite** — keep the literal dot. The URL-encoded form (`%2E…`) is NOT decoded by Next App Router (verified against Next 16.2.6 / Turbopack) — it resolves to the catchall route instead of the intended handler. This is a Next.js routing constraint, unrelated to the deco-start package split, and was already handled in whatever site is being migrated (verify it still is, post-migration). diff --git a/.agents/skills/deco-next-package-migration/references/import-mapping.md b/.agents/skills/deco-next-package-migration/references/import-mapping.md new file mode 100644 index 0000000..cdaf02f --- /dev/null +++ b/.agents/skills/deco-next-package-migration/references/import-mapping.md @@ -0,0 +1,30 @@ +# Import Mapping: `@decocms/start@5.x` → `@decocms/live` / `@decocms/admin` / `@decocms/next` + +Every import faststore-fila's migration actually touched, derived from the real commits (`9de6fa2`, `0109681`, `ec9d395`, `7ef5573`, `e61ef9a`, `23c30f1` on faststore-fila's `main`), not invented. Old paths are the `5.x-next` prerelease's framework-agnostic entrypoint tiers (`/core`, `/next`, `/node`); new paths are the current package split. + +| Old import | New import | Notes | +|---|---|---| +| `registerSection` from `@decocms/start/core` | `registerSection` from `@decocms/live/cms` | Same name, same signature. Import-path-only change (`src/sdk/deco/sections.ts`). | +| `registerSectionsSync` from `@decocms/start/core` | `registerSectionsSync` from `@decocms/live/cms` | Same name, same signature. Paired with `registerSection` in the same file. | +| `getResolvedComponent` from `@decocms/start/core` | `getResolvedComponent` from `@decocms/live/cms` | Same name. Used both in a client component that renders a registered section by key (`SectionsList.tsx`) and in a dev-only diagnostic page (`_dev/deco-spike`) — both were plain import-path swaps. | +| `loadBlocks` from `@decocms/start/core` | `loadBlocks` from `@decocms/live/cms` | Same name, same shape (`Record`). Used for a readiness check (`_ready` route: `Object.keys(loadBlocks()).length > 0`) and for a dev diagnostic dump. | +| `listRegisteredSections` from `@decocms/start/core` | `listRegisteredSections` from `@decocms/live/cms` | Same name. Dev-diagnostic use only in this migration, but not gated to dev-only by the package itself. | +| `setBlocks` from `@decocms/start/core` | `setBlocks` from `@decocms/live/cms` | Same name, same signature. Called once from the site's setup wrapper after the block registry (curated + legacy) is assembled. | +| `setResolveErrorHandler` from `@decocms/start/core` | `setResolveErrorHandler` from `@decocms/live/cms` | Same name, same signature: `(error, resolveType, context) => void`. | +| `registerLayoutSections` from `@decocms/start/core` | `registerLayoutSections` from `@decocms/live/cms` | Same name, same signature (array of section keys). | +| `registerSectionLoaders` from `@decocms/start/core` | `registerSectionLoaders` from `@decocms/live/cms` | Same name, same signature (map of section key → async `(props) => props`). | +| `registerSeoSections` — **new call needed**, not a rename | `registerSeoSections` from `@decocms/live/cms` | Not previously called by the site at all under `@decocms/start`. `extractSeoFromSections` (below) only extracts fields from section types registered here — if the site skips this call, page SEO silently resolves to `{}` regardless of what the decofile's `seo` field contains. Register every `__resolveType` the site's page blocks use for their top-level `seo` slot. | +| `loadCmsPage` from `@decocms/start/next` | **No direct equivalent.** Replace with `resolveDecoPage` (from `@decocms/live/cms`) + `extractSeoFromSections` (from `@decocms/live/cms`), composed in the site's own wrapper — or `@decocms/next`'s generic `createDecoPage` helper if the site has no custom SEO-merging/curated-block-override logic. | `resolveDecoPage(pathname, {})` resolves only the CMS block tree — it does **not** run registered section loaders. The caller must call `runSectionLoaders(page.resolvedSections, req)` itself (verified against both `packages/tanstack/src/routes/cmsRoute.ts` and `packages/next/src/createDecoPage.tsx` in deco-start — both call it from the caller side). Skipping this silently leaves `_server`-populated props (product listings, PDP data, etc.) never populated. `resolveDecoPage`'s return shape also carries `seoSection` as a *resolved section*, not a plain object — pass `[result.seoSection].filter(Boolean)` into `extractSeoFromSections`. | +| `loadAllDecofileBlocks` from `@decocms/start/node` | `loadDecofileDirectory` from `@decocms/live/cms/loadDecofileDirectory` | New function name, not a rename — different loading model. `loadDecofileDirectory(dirPath)` reads a directory of pre-existing legacy block JSON files (e.g. a `.deco/blocks/` snapshot exported from an old admin) and returns a flat `Record` ready for `setBlocks`. Only applies if the site has such a directory to begin with; sites with build-time-generated blocks (a single `blocks.gen.ts`/`.json`) don't need this — they pass that generated object to `setBlocks` directly. `findPageByPath` (used internally by `resolveDecoPage`) matches on each block's own `.path` field, not on the map key, so the keys `loadDecofileDirectory` assigns per file don't need to reverse-engineer the original admin's key-naming convention. | +| `createDecoAdminRouteHandlers` from `@decocms/start/next` | One function per concern from `@decocms/next`: `metaGET`, `decofileGET`, `decofilePOST`, `invokePOST`, `renderGET`, `renderPOST` | The old function returned `{ GET, POST, PATCH, DELETE }` from one internal URL-sniffing dispatcher covering `/_healthcheck`, `/_ready`, `/live/_meta`, `/.decofile`, `/deco/render`, `/deco/invoke[/]`, `/live/previews/*`, `/_watch`, `/fs/file/*`. `@decocms/next` has no single dispatcher — each concern is its own function, and the site's admin-route wrapper becomes a set of thin per-route re-exports (each running the site's own `ensureSetup()` first). Note `renderGET`/`renderPOST` get mounted at **two** route paths in this migration — `/deco/render` and `/live/previews/*` — both re-exporting the same two functions; don't assume render handlers live at a single URL. See `templates/admin-routes.md`. | +| `/_watch` route (SSE file-watch channel), `/fs/file/*` routes (JSON-Patch file mutation) | **No equivalent — delete.** | Both served the live-editing dev tunnel (chokidar-based hot reload of `.deco/` for the admin UI). Deliberately out of scope for the current package split (not an oversight) — confirmed against the design doc the split was implemented from. | +| `/_healthcheck` route (returned the old dispatcher's `ADMIN_COMPAT_VERSION`) | **No equivalent — replace with a plain 200.** | There's no version-negotiation protocol in `@decocms/admin`/`@decocms/next`; a bare `Response('ok', { status: 200 })` is sufficient for k8s/Cloud Run liveness probes, which don't inspect the body. | +| `/_ready` route (via the old dispatcher's `handleDecoReadiness`) | Site-authored stub using `loadBlocks()` directly | `await ensureSetup(); const ready = Object.keys(loadBlocks()).length > 0` — 200 if populated, 503 otherwise. No shared "readiness" helper ships in the new packages; this logic now lives in the site. | + +## Package → tier cheat sheet + +| Old tier | New package | +|---|---| +| `@decocms/start/core` | `@decocms/live/cms` (plus `@decocms/live/cms/loadDecofileDirectory` for the one function that moved to its own subpath) | +| `@decocms/start/next` | `@decocms/admin` (protocol/schema types) + `@decocms/next` (the per-concern Next.js Route Handler functions) | +| `@decocms/start/node` | `@decocms/live/cms/loadDecofileDirectory` (partial — only the legacy-directory-loading use case has an equivalent) | diff --git a/.agents/skills/deco-next-package-migration/templates/admin-routes.md b/.agents/skills/deco-next-package-migration/templates/admin-routes.md new file mode 100644 index 0000000..8340a52 --- /dev/null +++ b/.agents/skills/deco-next-package-migration/templates/admin-routes.md @@ -0,0 +1,151 @@ +# Admin Routes Template + +Worked example derived directly from faststore-fila's `src/sdk/deco/adminRoute.ts` and the App Router route files that mount it. The old `@decocms/start/next` tier exported one function, `createDecoAdminRouteHandlers`, that returned `{ GET, POST, PATCH, DELETE }` from a single internal dispatcher routing on `req.url`. `@decocms/next` has no equivalent single dispatcher — it exports one function per admin concern instead, so the site's wrapper becomes a set of thin per-concern re-exports. + +## The wrapper: `src/sdk/deco/adminRoute.ts` + +```typescript +/** + * Per-route wrappers around @decocms/next's admin Route Handlers. + * Each runs ensureSetup() first (so the block registry is populated + * before handling the request), then delegates. Replaces the single + * dispatcher `createDecoAdminRouteHandlers` provided — that function has + * no equivalent on the current package split; @decocms/next's handlers + * are already split one per concern instead of URL-sniffed from one + * function. + */ +import { + decofileGET as decofileGETImpl, + decofilePOST as decofilePOSTImpl, + invokePOST as invokePOSTImpl, + metaGET as metaGETImpl, + renderGET as renderGETImpl, + renderPOST as renderPOSTImpl, +} from '@decocms/next' + +import { ensureSetup } from './setup' + +export async function metaGET(request: Request): Promise { + await ensureSetup() + return metaGETImpl(request) +} + +export async function decofileGET(): Promise { + await ensureSetup() + return decofileGETImpl() +} + +export async function decofilePOST(request: Request): Promise { + await ensureSetup() + return decofilePOSTImpl(request) +} + +export async function invokePOST(request: Request): Promise { + await ensureSetup() + return invokePOSTImpl(request) +} + +export async function renderGET(request: Request): Promise { + await ensureSetup() + return renderGETImpl(request) +} + +export async function renderPOST(request: Request): Promise { + await ensureSetup() + return renderPOSTImpl(request) +} +``` + +## Mounting it: App Router route files + +Each admin concern gets its own `route.ts` re-exporting the matching wrapper function under the HTTP method(s) it needs. Folder naming here is asymmetric and easy to get backwards: `_`-prefixed folders (e.g. `_meta`, `_healthcheck`) must be URL-encoded as `%5Ffoo`, because Next treats a literal `_folder` as private and excludes it from routing. Dot-prefixed folders (e.g. `.decofile`) must do the opposite — keep the literal dot; the encoded form (`%2E…`) is NOT decoded by Next App Router (verified against Next 16.2.6 / Turbopack) and resolves to the catchall route instead. This is a Next.js routing constraint, unrelated to the package split, but easy to trip over when porting route folders from an old site. + +```typescript +// src/app/live/%5Fmeta/route.ts +export const dynamic = 'force-dynamic' +export { metaGET as GET } from 'src/sdk/deco/adminRoute' +``` + +```typescript +// src/app/.decofile/route.ts +export const dynamic = 'force-dynamic' +export { decofileGET as GET, decofilePOST as POST } from 'src/sdk/deco/adminRoute' +``` + +```typescript +// src/app/deco/invoke/[[...path]]/route.ts — optional catchall covers both +// `/deco/invoke` and `/deco/invoke/`. Both HTTP methods map to +// the same underlying function. +export const dynamic = 'force-dynamic' +export { invokePOST as GET, invokePOST as POST } from 'src/sdk/deco/adminRoute' +``` + +```typescript +// src/app/deco/render/route.ts — renders an ad-hoc section by +// __resolveType + props, used by the deco admin UI for live previews. +export const dynamic = 'force-dynamic' +export { renderGET as GET, renderPOST as POST } from 'src/sdk/deco/adminRoute' +``` + +`renderGET`/`renderPOST` have a second real mount point beyond `/deco/render`: `/live/previews/*` (an optional catchall) is the path `@decocms/next`'s own `renderGET` doc comment calls out as its preview-mode route. Mount the same two functions there too — a future migrator should not assume render handlers live at exactly one URL: + +```typescript +// src/app/live/previews/[[...path]]/route.ts +export const dynamic = 'force-dynamic' +export { renderGET as GET, renderPOST as POST } from 'src/sdk/deco/adminRoute' +``` + +## Routes with no package equivalent + +The old dispatcher also served routes that have **no** `@decocms/admin`/`@decocms/next` equivalent — these were deliberately scoped out of the current package split (live-editing dev tunnel), not omitted by oversight. Delete them or replace with a simple non-daemon stub: + +```typescript +// src/app/%5Fwatch/route.ts — DELETE. This served an SSE channel +// streaming file-watch events under `.deco/` to a live-editing admin UI +// (chokidar-based hot reload). No equivalent in the new packages. + +// src/app/fs/file/[[...path]]/route.ts — DELETE. JSON-Patch file +// mutation for the same live-editing dev tunnel. No equivalent. +``` + +```typescript +// src/app/%5Fhealthcheck/route.ts — replace with a plain 200. +// The old dispatcher returned an ADMIN_COMPAT_VERSION string; there's no +// version-negotiation protocol in the new packages, and a bare 200 is +// sufficient for k8s/Cloud Run liveness probes, which don't inspect the +// body. +export const dynamic = 'force-dynamic' + +export async function GET() { + return new Response('ok', { status: 200 }) +} +``` + +```typescript +// src/app/%5Fready/route.ts — replace with a site-authored readiness +// check reading the block registry directly. No shared "readiness" +// helper ships in the new packages; this logic now lives in the site. +import { loadBlocks } from '@decocms/live/cms' + +import { ensureSetup } from 'src/sdk/deco/setup' + +export const dynamic = 'force-dynamic' + +export async function GET() { + try { + await ensureSetup() + const blocks = loadBlocks() + const ready = Object.keys(blocks).length > 0 + return new Response(ready ? 'ready' : 'not ready', { status: ready ? 200 : 503 }) + } catch { + return new Response('not ready', { status: 503 }) + } +} +``` + +## Key patterns + +1. **One `route.ts` per concern, not one dispatcher.** Each Next.js route file re-exports exactly the wrapper function(s) it needs under the HTTP method name(s) Next.js expects (`GET`, `POST`, etc.) — there's no single `{ GET, POST, PATCH, DELETE }` object to spread across every route anymore. +2. **`ensureSetup()` runs inside every wrapper function, not once at module scope.** The old dispatcher's `onRequest` hook ran it once per dispatched request; the replacement re-implements that per-function since there's no shared dispatcher to hook into. +3. **Don't try to force a package-provided replacement for the deleted dev-tunnel routes.** There isn't one — confirm the site doesn't depend on live in-admin editing before deleting `/_watch` and `/fs/file/*`, and if it does, that's a real capability gap to flag rather than something to work around silently. +4. **Verify route folder naming still resolves after the file changes, and don't apply the same rule to both prefixes.** Renaming/moving these files is a good moment to re-confirm: `_`-prefixed folders need `%5F` encoding, dot-prefixed folders need to stay literal — under the Next.js version in use, encoding a dot-prefixed folder silently reroutes the request to a catchall instead of erroring, so a wrong assumption here fails quietly rather than loudly. diff --git a/.agents/skills/deco-next-package-migration/templates/setup-ts.md b/.agents/skills/deco-next-package-migration/templates/setup-ts.md new file mode 100644 index 0000000..112b7b2 --- /dev/null +++ b/.agents/skills/deco-next-package-migration/templates/setup-ts.md @@ -0,0 +1,186 @@ +# `setup.ts` Template + +Worked example derived directly from faststore-fila's `src/sdk/deco/setup.ts`, genericized: site-specific names (product-fetch helpers, PDP prop types) are replaced with placeholder comments. The `ensureSetup` / `resolveCmsPage` / `resolveCmsPageByPath` structure is kept verbatim — that's the reusable part of the pattern, proven end-to-end against a real production site. + +The real file also contained a ~50-line workaround (`pageFacetsByPath` / `buildPageFacetsByPath` / `extractRawSelectedFacets`) for one specific commerce-search resolver that this runtime doesn't register, which silently resolved to `null` instead of erroring. That's not just VTEX-specific noise to discard — it's an instance of a reusable gotcha class (an unregistered `__resolveType` silently resolving to `null`) that's generalized into the section-loaders comment below, since any backend's migration can hit the same failure mode with a different resolver name. + +```typescript +/** + * Server-side Deco setup. Loads the site's `.deco/blocks/*.json` snapshot + * (or generated blocks — see `loadDecofileDirectory` vs. a static import + * in step 1 below) into the Deco runtime, then overrides specific page + * blocks with curated content if the site needs that. App Router server + * components call `resolveCmsPageByPath(path)` (cached) to resolve a page. + * + * Section components are registered as a module-load side effect of + * importing `./sections`, which runs on both server and client bundles — + * necessary so `getResolvedComponent` finds the same components on both + * sides of hydration. + */ + +import { cache } from 'react' +import { + extractSeoFromSections, + registerLayoutSections, + registerSectionLoaders, + registerSeoSections, + resolveDecoPage, + runSectionLoaders, + setBlocks, + setResolveErrorHandler, +} from '@decocms/live/cms' +// Only needed if the site loads a directory of pre-existing legacy block +// JSON files (e.g. a `.deco/blocks/` snapshot exported from an old admin). +// Sites with build-time-generated blocks (a single blocks.gen.ts/.json) +// import that module directly and pass it to `setBlocks` instead. +import { loadDecofileDirectory } from '@decocms/live/cms/loadDecofileDirectory' + +// --- site-specific: curated block overrides, if any ------------------ +// import { homeBlock, HOME_BLOCK_KEY } from './curated/home' +// import { pdpBlock, PDP_BLOCK_KEY } from './curated/pdp' + +// Named import (not side-effect-only) so bundlers keep the module under +// a project-level `sideEffects: false`. The registration loop inside +// `./sections` registers every component into the runtime's registry at +// module load. +import { SECTIONS as _SECTIONS } from './sections' +void _SECTIONS + +let setupPromise: Promise | null = null + +export function ensureSetup(): Promise { + if (setupPromise) return setupPromise + setupPromise = (async () => { + const allBlocks = await loadDecofileDirectory('.deco/blocks') + // --- site-specific: merge in curated block overrides, if any ------ + // allBlocks[HOME_BLOCK_KEY] = homeBlock as unknown as Record + // allBlocks[PDP_BLOCK_KEY] = pdpBlock as unknown as Record + setBlocks(allBlocks) + + setResolveErrorHandler((error, resolveType, context) => { + // eslint-disable-next-line no-console + console.error(`[deco] ${context} "${resolveType}" failed:`, error) + }) + + // Layout sections (e.g. header/footer) render on every page and + // should be cached across navigations instead of re-resolved per + // request. List every section key the site's layout uses, including + // any legacy key variants a pre-existing decofile snapshot might use + // for the same logical section. + registerLayoutSections([ + // 'site/sections/Header/Header.tsx', + // 'site/sections/Footer/Footer.tsx', + ]) + + // `extractSeoFromSections` (used in `resolveCmsPage` below) only pulls + // real title/description out of section types registered here — skip + // this and every page's SEO silently resolves to `{}`. Determine the + // real set by scanning the site's own `.deco/blocks/pages-*.json` (or + // equivalent) for the resolveType(s) used in each page's top-level + // `seo` field. + registerSeoSections([ + // 'commerce/sections/Seo/SeoV2.tsx', + ]) + + // Section loaders enrich CMS-resolved props with server-side data + // (product listings, PDP data, anything the section needs that isn't + // in the decofile). Each loader is `(props, req) => Promise`. + // --- site-specific: register one entry per section that needs + // server-side data. Example shape, not literal code: + // + // registerSectionLoaders({ + // 'site/sections/Product/SearchResult.tsx': async (props, req) => { + // // fetch product listing based on props.page.selectedFacets + // // (or whatever facet/filter state this site's search section + // // encodes), return { ...props, _server: { ...fetchedData } } + // }, + // 'site/sections/ProductDetails.tsx': async (props, req) => { + // // parse an identifier (slug/id) out of the matched request + // // path, fetch product data, return { ...props, _server: data } + // // — or return `props` unchanged if not found, so the page can + // // respond 404 itself. + // }, + // }) + // + // Gotcha class worth checking for regardless of backend: if a section's + // CMS-authored props reference a `__resolveType` for some upstream + // commerce/search resolver that this runtime doesn't have registered + // (e.g. it belonged to a different platform integration than the one + // this runtime ships loaders for), the runtime logs an "unhandled + // resolver" warning and silently resolves that prop to `null` instead + // of erroring. If a page depended on that prop's real value (e.g. a + // filter/facet selection baked into a decofile snapshot), the section + // loader above will see `null` and produce a page that "works" but + // renders unfiltered/default data — no exception, no stack trace, just + // wrong output. If wiring up the real resolver is out of scope for the + // migration, the section loader can read the same field straight out + // of the raw (pre-resolution) block JSON as a narrow bypass, rather + // than `null`-checking and giving up. Confirm this is still happening + // by grepping the dev server's console output for "[CMS] Unhandled + // resolver" while exercising a page that depends on such a field. + registerSectionLoaders({}) + })() + return setupPromise +} + +/** + * Resolve the CMS page for a given request. Returns + * { name, seo, resolvedSections } — the same shape the dead + * @decocms/start/next tier's loadCmsPage returned, so page files that + * already consume this function need no changes. + * + * Built directly on @decocms/live's resolveDecoPage rather than + * @decocms/next's createDecoPage helper: createDecoPage assumes a + * generic single-page-per-URL model, while a site with its own SEO + * merging (e.g. store-config fallback title/description) or curated + * block overrides may not fit that generic helper — kept as the site's + * own thin wrapper instead. If neither applies, prefer createDecoPage + * and skip this wrapper entirely. + * + * Crucially, `resolveDecoPage` only resolves the CMS block tree — it does + * NOT run the registered section loaders (verified against + * `packages/tanstack/src/routes/cmsRoute.ts` and + * `packages/next/src/createDecoPage.tsx`: both leave that to their own + * callers). Without an explicit `runSectionLoaders` call here, any + * loaders registered in `ensureSetup` above would never run, and + * `_server` (or whatever key the site's loaders populate) would never be + * set — silently breaking any section that depends on server-fetched + * data. + */ +export async function resolveCmsPage(req: Request) { + await ensureSetup() + const url = new URL(req.url) + const page = await resolveDecoPage(url.pathname, {}) + if (!page) return null + + const resolvedSections = await runSectionLoaders(page.resolvedSections, req) + + const seo = extractSeoFromSections( + page.seoSection ? [page.seoSection] : [], + ) + + return { + name: page.name, + seo, + resolvedSections, + } +} + +/** + * Pathname-keyed cached resolver. Multiple callers in the same render + * (layout + page + generateMetadata) share one fetch. `react.cache` keys + * on argument identity; a plain string keys cleanly. + */ +export const resolveCmsPageByPath = cache(async (pathname: string) => + resolveCmsPage(new Request(`http://localhost${pathname}`)) +) +``` + +## Key patterns + +1. **Import-path-only changes are the bulk of the work.** `registerSection`, `registerSectionsSync`, `getResolvedComponent`, `loadBlocks`, `listRegisteredSections`, `setBlocks`, `setResolveErrorHandler`, `registerLayoutSections`, `registerSectionLoaders` all kept their names and signatures across the package split — swap `@decocms/start/core` for `@decocms/live/cms` and move on. +2. **`registerSeoSections` is a new call, not a rename.** If the site never called it under the old package, it still needs to be added now — `extractSeoFromSections` silently returns `{}` without it. +3. **`resolveDecoPage` doesn't run section loaders.** The wrapper must call `runSectionLoaders` itself, or server-fetched data never reaches the page. +4. **Keep the wrapper's exported names and return shape stable.** This is what lets page-level code that already calls `resolveCmsPage`/`resolveCmsPageByPath` avoid any changes at all — the whole point of routing the migration through a site-owned wrapper rather than inlining the new package's calls at every call site. +5. **`loadDecofileDirectory` vs. a generated-blocks import** — only reach for `loadDecofileDirectory` if the site has an existing directory of legacy per-page block JSON files to load at runtime. If blocks are generated at build time into one file, import that file and pass it to `setBlocks` directly; no directory loader needed. +6. **An unregistered resolver fails silently, not loudly.** If a section's props reference a `__resolveType` this runtime has no loader for, it resolves to `null` with just a console warning — no thrown error. Any section loader consuming that prop needs to either treat `null` as "feature not wired up yet" explicitly, or (if wiring up the real resolver is out of scope) read the same value straight out of the raw block JSON as a narrow, well-commented bypass. Don't assume a page rendering without errors means its data is correct — check for "unhandled resolver" log lines during end-to-end validation (Step 5 in `SKILL.md`). diff --git a/.cursor/skills/deco-api-call-dedup/SKILL.md b/.cursor/skills/deco-api-call-dedup/SKILL.md index b5ce522..5cff5f3 100644 --- a/.cursor/skills/deco-api-call-dedup/SKILL.md +++ b/.cursor/skills/deco-api-call-dedup/SKILL.md @@ -1,34 +1,238 @@ --- name: deco-api-call-dedup -description: Detect and fix duplicate/N+1 API calls in Deco TanStack storefronts. Covers vtexCachedFetch SWR cache for all VTEX GET calls, slugCache via fetchWithCache, cross-selling SWR cache, usePriceSimulationBatch for batching simulation POSTs, PLP path filtering to avoid spurious pagetype calls, pageType dedup, site loader registration, cachedLoader inflight dedup in dev mode, and HAR analysis techniques. Use when server logs show repeated VTEX API calls, PDP/PLP loads trigger excessive calls, simulation calls happen one-by-one, or "Unhandled resolver" warnings appear. +description: Detect and fix N+1 / duplicate API call patterns in Deco storefront section loaders (VTEX Catalog, Intelligent Search, checkout simulation, Shopify). Covers detection (loops calling per-product APIs inside `.map()`/`await`, redundant fetches for data already on the Product object) and fixes (vtexCachedFetch SWR cache, slugCache/cross-selling dedup, usePriceSimulationBatch, PLP path filtering, pageType dedup, site loader registration, cachedLoader in-flight dedup in dev mode, HAR analysis). Use when investigating slow page loads (SSR > 3s), VTEX 429 rate limiting, server logs showing repeated calls to the same endpoint, PDP/PLP loads triggering 20+ API calls, simulation calls happening one-by-one, or "Unhandled resolver" warnings. --- -# API Call Deduplication & Batching +# API Call Deduplication & N+1 Detection -Patterns for eliminating redundant VTEX API calls in Deco storefronts on TanStack Start. These patterns reduced PDP API calls from 40+ to ~8 and PLP spurious calls from 15+ to near-zero on `espacosmart-storefront`. All VTEX GET calls now go through `vtexCachedFetch` with SWR (3 min TTL) and in-flight deduplication. +Finds and fixes N+1 / duplicate VTEX (and Shopify) API call patterns in Deco storefront section loaders — the #1 cause of slow SSR on e-commerce sites. These patterns reduced PDP API calls from 40+ to ~8 and PLP spurious calls from 15+ to near-zero on `espacosmart-storefront`. ## When to Use This Skill -- Server logs show duplicate `search/{slug}/p` calls for the same product -- Cross-selling endpoints (`similars`, `suggestions`, `showtogether`) called multiple times with the same ID -- `simulation` POST called once per product instead of batched -- PDP page load triggers 20+ VTEX API calls -- HAR analysis shows waterfall of sequential API calls +- Page loads are slow (SSR > 3s) +- Terminal logs show many sequential/parallel calls to the same endpoint, or duplicate `search/{slug}/p` calls for the same product +- VTEX returns 429 (Too Many Requests) errors +- Cross-selling endpoints (`similars`, `suggestions`, `showtogether`) are called multiple times with the same ID +- `simulation` POST is called once per product instead of batched +- PDP/PLP page load triggers 20+ VTEX API calls +- HAR analysis shows a waterfall of sequential API calls +- `[CMS] Unhandled resolver: site/loaders/...` warnings appear +- User reports "a troca de pagina ta demorando" +- After migrating loaders or adding new shelf/search sections + +## Workflow + +``` +1. Scan loaders → Find .map()/forEach + await + API call patterns +2. Classify the API → Catalog, IS, simulation, masterdata, crossselling, pagetype +3. Check if data already exists → Product.additionalProperty, hasVariant, offers, etc. +4. Fix: + - Redundant call → remove it, read from existing data + - Genuinely needed → batch, cache/dedup (vtexCachedFetch), or lazy-load client-side +5. Verify → terminal logs + HAR show the eliminated/deduped calls +``` + +--- + +## Step 1: Scan for N+1 Patterns + +Search for the telltale pattern: an API call inside a `.map()` or `forEach()` within a loader, or the same endpoint+params fetched from multiple loaders during one request. + +### What to Look For + +| Pattern | Severity | Example | +|---------|----------|---------| +| **API call inside `.map()`** | Critical | `products.map(p => getSpec(p.id))` | +| **Same slug/ID fetched by multiple loaders** | Critical | PDP main loader + related products + breadcrumb all call `search/{slug}/p` | +| **Missing batch alternative** | High | Individual calls where batch API exists | +| **Redundant data fetch** | High | Fetching data already in the Product object | +| **Sequential awaits in loop** | Medium | `for (p of products) { await fetch(p) }` | +| **Unbounded parallel calls** | Medium | `Promise.all(100items.map(fetch))` | +| **`simulation` called per product** | Medium | Shelf simulates price one product at a time | +| **`pagetype` for asset URLs** | Low-Medium | PLP loader resolving `/image/...`, `/.well-known/...` paths | + +### Search Commands + +```bash +# Find all loaders that call external APIs inside map/forEach +grep -rn "\.map(.*async" src/components/ src/sections/ --include="*.tsx" --include="*.ts" | grep -i "loader\|export const loader" + +# Find getProductSpecification calls (most common N+1) +grep -rn "getProductSpecification" src/ + +# Find any VTEX API call inside a map +grep -rn "vtexFetch\|vtex.*fetch\|catalog_system\|intelligent-search" src/ --include="*.tsx" --include="*.ts" + +# Find simulation calls per product +grep -rn "cartSimulation\|usePriceSimulation" src/ --include="*.tsx" --include="*.ts" +``` + +### Red Flag Pattern + +```typescript +// RED FLAG: API call per product in a map +export const loader = async (props: Props, _req: Request) => { + const results = props.products?.map(async (product) => { + const extra = await someApiCall(product.id); // N+1! + return { ...product, extra }; + }); + return { ...props, results: await Promise.all(results) }; +}; +``` + +## Step 2: Classify the API Call + +| API Endpoint | What It Returns | Already in Product? | +|--------------|-----------------|---------------------| +| `/api/catalog_system/pvt/products/{id}/Specification` | Product specs by numeric ID | Yes — `product.isVariantOf.additionalProperty` | +| `/api/catalog_system/pub/products/crossselling/{id}/*` | Related products | No — but should be 1 call per productId+type, not per section | +| `/api/checkout/pub/orderForms/simulation` | Price simulation | No — needs CEP; batch by SKU instead of one call per product | +| `/api/catalog_system/pub/products/variations/{id}` | SKU variations | Yes — `product.isVariantOf.hasVariant` | +| `/api/catalog_system/pub/portal/pagetype/{term}` | Page type for a path segment | No — but should be cached/deduped per segment, and skipped for asset URLs | +| `/api/dataentities/{entity}/search` | MasterData docs | No — check if can be batched with `_where=id=1 OR id=2` | + +## Step 3: Check If Data Already Exists + +### Product Specifications (Most Common N+1) + +The VTEX Intelligent Search API returns `specificationGroups`, which the `@decocms/apps` transform converts to `product.isVariantOf.additionalProperty`. + +**Catalog API format** (what `getProductSpecification` returns): +```json +[{ "Id": 208, "Name": "Rendimento", "Value": ["4.5"] }] +``` + +**Schema.org format** (already in `product.isVariantOf.additionalProperty`): +```json +[{ "name": "Rendimento", "value": "4.5", "propertyID": "groupName", "valueReference": "PROPERTY" }] +``` + +To use the existing data, create a bridge helper: + +```typescript +// src/sdk/productSpecs.ts +import type { Product } from "@decocms/apps/commerce/types"; + +const SPEC_NAME_TO_ID: Record = { + // Map exact IS spec names → legacy numeric IDs used by components + // IMPORTANT: verify exact names via IS API, some have double spaces +}; + +export function getSpecsFromProduct(product: Product) { + const props = product.isVariantOf?.additionalProperty ?? []; + const specs: Array<{ Id: number; Value: string[] }> = []; + for (const p of props) { + if (p.valueReference !== "PROPERTY") continue; + const id = SPEC_NAME_TO_ID[p.name]; + if (id == null) continue; + const existing = specs.find((s) => s.Id === id); + if (existing) existing.Value.push(p.value); + else specs.push({ Id: id, Value: [p.value] }); + } + return specs; +} +``` + +### How to Discover Spec Names + +```bash +# Hit the IS API directly and inspect specificationGroups +curl -s "https://{account}.vtexcommercestable.com.br/api/io/_v/api/intelligent-search/product_search/?count=3&query={product-type}&sc=1" \ + | python3 -c " +import json, sys +for p in json.load(sys.stdin).get('products', []): + print(p['productId'], '-', p['productName'][:60]) + for g in p.get('specificationGroups', []): + if g['name'] == 'allSpecifications': + for s in g['specifications']: + print(f' \"{s[\"name\"]}\": {[v[:40] for v in s[\"values\"]]}') + print('---') +" +``` + +### SKU Variations + +If calling `/api/catalog_system/pub/products/variations/{id}`: +- Already available in `product.isVariantOf.hasVariant` +- Each variant has `additionalProperty` with variation attributes + +### Product Reviews/Ratings + +If calling an external review API per product in shelves: +- Consider lazy-loading reviews only on PDP +- Or batch the API if it supports multiple product IDs + +--- + +## Step 4: General Fix Strategies + +### Strategy A: Use Existing Data (Best) + +Replace the API call with a synchronous read from the Product object. + +**Before** (N HTTP calls): +```typescript +const productAdditional = await getProductSpecification(element.inProductGroupWithID); +``` + +**After** (0 HTTP calls): +```typescript +const productAdditional = getSpecsFromProduct(element); +``` + +### Strategy B: Create Batch Endpoint + +When the data genuinely doesn't exist in the Product: + +```typescript +// apps-start/vtex/loaders/catalog.ts +export async function getProductSpecifications(productIds: string[]) { + return Promise.all( + productIds.map(id => vtexFetch(`/api/catalog_system/pvt/products/${id}/Specification`)) + ); +} +``` + +Even `Promise.all` with N calls is better than sequential awaits, but a true batch API is ideal. + +### Strategy C: Cache + Deduplicate + +For data that changes infrequently, or that multiple loaders request independently during the same request. See Step 5 for the concrete `vtexCachedFetch`-based patterns this project already uses — prefer those over a bespoke cache. + +```typescript +const specCache = new Map(); + +export async function getCachedSpec(productId: string) { + if (specCache.has(productId)) return specCache.get(productId)!; + const result = await getProductSpecification(productId); + specCache.set(productId, result); + return result; +} +``` + +### Strategy D: Lazy Load on Client + +Move enrichment to client-side for non-critical data: + +```typescript +// Component fetches specs only when visible +const [specs, setSpecs] = useState(null); +useEffect(() => { + if (inView) fetchSpecs(productId).then(setSpecs); +}, [inView]); +``` --- -## Pattern 1: Slug Search Deduplication (`slugCache`) via `vtexCachedFetch` +## Step 5: Apply Dedup & Batching Patterns -### Problem +These are the concrete, already-adopted implementations of Strategy C above — reach for these first before building a bespoke cache. -Multiple section loaders call `search/{slug}/p` for the same product: -- `productDetailsPage.ts` (main PDP loader) -- `relatedProducts.ts` (needs `productId` from slug) -- Any section that resolves a product by slug +### Pattern 1: Slug Search Deduplication (`slugCache`) via `vtexCachedFetch` -### Solution (Current) +**Problem:** Multiple section loaders call `search/{slug}/p` for the same product — `productDetailsPage.ts` (main PDP loader), `relatedProducts.ts` (needs `productId` from slug), and any section that resolves a product by slug. -`slugCache.ts` now delegates to `vtexCachedFetch`, which provides both in-flight deduplication AND SWR caching (3 min TTL for 200 responses). No manual inflight Map needed: +**Solution:** `slugCache.ts` delegates to `vtexCachedFetch`, which provides both in-flight deduplication AND SWR caching (3 min TTL for 200 responses). No manual inflight Map needed: ```typescript // vtex/utils/slugCache.ts @@ -54,12 +258,7 @@ export async function resolveProductIdBySlug(slug: string): Promise inflight.delete(...), 5_000)` -After: `vtexCachedFetch` handles dedup + SWR automatically via `fetchWithCache` (see `deco-vtex-fetch-cache` skill) - -### Usage +Previously this used a manual `inflight` Map with `setTimeout(() => inflight.delete(...), 5_000)`; `vtexCachedFetch` now handles dedup + SWR automatically via `fetchWithCache` (see `deco-vtex-fetch-cache` skill). ```typescript // In productDetailsPage.ts @@ -71,18 +270,11 @@ import { resolveProductIdBySlug } from "../utils/slugCache"; const productId = await resolveProductIdBySlug(slug); ``` -### Impact - -Before: 3-4 calls to `search/{slug}/p` per PDP load -After: 1 call, cached for 3 min across all loaders and subsequent page loads - ---- - -## Pattern 2: Cross-Selling via `vtexCachedFetch` +**Impact:** Before: 3-4 calls to `search/{slug}/p` per PDP load. After: 1 call, cached for 3 min across all loaders and subsequent page loads. -### Problem +### Pattern 2: Cross-Selling via `vtexCachedFetch` -Multiple loaders request cross-selling data for the same product: +**Problem:** Multiple loaders request cross-selling data for the same product: ``` GET /crossselling/similars/58 @@ -91,11 +283,9 @@ GET /crossselling/whoboughtalsobought/58 GET /crossselling/showtogether/58 ``` -When `relatedProducts.ts` runs multiple times (e.g., for "similars" shelf AND "suggestions" shelf), the same productId+type gets fetched twice. +When `relatedProducts.ts` runs multiple times (e.g., for a "similars" shelf AND a "suggestions" shelf), the same productId+type gets fetched twice. -### Solution (Current) - -`relatedProducts.ts` now uses `vtexCachedFetch` instead of a manual `crossSellingInflight` Map. The SWR cache handles both dedup and 3-min TTL: +**Solution:** `relatedProducts.ts` uses `vtexCachedFetch` instead of a manual `crossSellingInflight` Map. The SWR cache handles both dedup and 3-min TTL: ```typescript import { vtexCachedFetch, getVtexConfig } from "../client"; @@ -113,14 +303,7 @@ function fetchCrossSelling( } ``` -### Key Change from Previous Version - -Before: manual `crossSellingInflight` Map with `setTimeout` cleanup -After: `vtexCachedFetch` provides dedup + SWR. Subsequent calls within 3 min return cached data instantly. - -### Always `.catch(() => [])` on Cross-Selling - -VTEX returns 404 for products without cross-selling data. An unhandled 404 crashes the entire section loader: +Always `.catch(() => [])` on cross-selling — VTEX returns 404 for products without cross-selling data, and an unhandled 404 crashes the entire section loader: ```typescript // BAD — 404 kills the PDP @@ -131,13 +314,9 @@ const related = await fetchCrossSelling("showtogether", id); // vtexCachedFetch throws for non-ok responses, .catch returns [] ``` ---- +### Pattern 3: Price Simulation Batching -## Pattern 3: Price Simulation Batching - -### Problem - -Product shelves call `simulation` POST once per product (N+1): +**Problem:** Product shelves call `simulation` POST once per product (N+1): ``` POST /orderForms/simulation (item: sku-1) @@ -146,9 +325,7 @@ POST /orderForms/simulation (item: sku-3) ... ``` -### Solution - -Create a batch simulation function that sends all SKUs in one call: +**Solution:** A batch simulation function that sends all SKUs in one call: ```typescript // hooks/usePriceSimulationBatch.ts @@ -185,8 +362,6 @@ export async function usePriceSimulationBatch( } ``` -### Usage - ```typescript // In section loaders — batch all IDs const allIds = [mainProductId, ...relatedProductIds]; @@ -195,22 +370,13 @@ const mainSim = allSimulations[0]; const relatedSims = allSimulations.slice(1); ``` -### Impact +**Impact:** Before: N `simulation` POST calls (one per product in shelf). After: 1 `simulation` POST call with all items batched. -Before: N `simulation` POST calls (one per product in shelf) -After: 1 `simulation` POST call with all items batched +### Pattern 4: `cachedLoader` In-Flight Dedup in Dev Mode ---- - -## Pattern 4: `cachedLoader` In-Flight Dedup in Dev Mode - -### Problem - -`createCachedLoader` completely disables caching in dev mode. This means even concurrent calls for the same key hit the API independently. +**Problem:** `createCachedLoader` completely disables caching in dev mode. This means even concurrent calls for the same key hit the API independently — during SSR, multiple sections resolve concurrently, so the PDP loader can run 2-3 times for the same slug (ProductMain section, Related Products section, Breadcrumb all call `cachedPDP({ slug })`). -### Solution - -Keep SWR cache disabled in dev, but enable in-flight deduplication: +**Solution:** Keep SWR cache disabled in dev, but enable in-flight deduplication: ```typescript // In cachedLoader.ts @@ -236,26 +402,13 @@ export function createCachedLoader(name: string, loaderFn: LoaderFn, opts: } ``` -### Why In-Flight Dedup Matters in Dev - -During SSR, multiple sections resolve concurrently. Without dedup, the PDP loader runs 2-3 times for the same slug: -1. ProductMain section → `cachedPDP({ slug })` -2. Related Products section → `cachedPDP({ slug })` (to get productId) -3. Breadcrumb → `cachedPDP({ slug })` +With inflight dedup, only 1 actual API call happens; other callers await the same Promise. -With inflight dedup, only 1 actual API call, other callers await the same Promise. - ---- +### Pattern 5: PLP Path Filtering — Avoid Spurious `pageType` Calls -## Pattern 5: PLP Path Filtering — Avoid Spurious `pageType` Calls +**Problem:** The PLP loader's `pageTypesFromPath(__pagePath)` receives invalid paths like `/image/checked.png`, `/.well-known/appspecific/...`, `/assets/sprite.svg`. Each path segment triggers a VTEX `pagetype` API call, wasting 5+ calls on non-page URLs. -### Problem - -The PLP loader's `pageTypesFromPath(__pagePath)` receives invalid paths like `/image/checked.png`, `/.well-known/appspecific/...`, `/assets/sprite.svg`. Each path segment triggers a VTEX `pagetype` API call, wasting 5+ calls on non-page URLs. - -### Solution - -Filter invalid paths before calling `pageTypesFromPath`: +**Solution:** Filter invalid paths before calling `pageTypesFromPath`: ```typescript // In productListingPage.ts @@ -281,21 +434,13 @@ if (facets.length === 0 && __pagePath && __pagePath !== "/" && __pagePath !== "/ } ``` -### Impact +**Impact:** Eliminates 5+ spurious VTEX API calls on PLP pages that have asset URLs in the path resolution pipeline. -Eliminates 5+ spurious VTEX API calls on PLP pages that have asset URLs in the path resolution pipeline. - ---- +### Pattern 6: `pageTypesFromPath` Dedup via `vtexCachedFetch` -## Pattern 6: `pageTypesFromPath` Dedup via `vtexCachedFetch` +**Problem:** `pageTypesFromPath` calls VTEX's `pagetype` API for each path segment (cumulative). When multiple PLP sections resolve the same path, each segment gets fetched multiple times. -### Problem - -`pageTypesFromPath` calls VTEX's `pagetype` API for each path segment (cumulative). When multiple PLP sections resolve the same path, each segment gets fetched multiple times. - -### Solution - -Each individual `pagetype` call now goes through `vtexCachedFetch` with SWR: +**Solution:** Each individual `pagetype` call goes through `vtexCachedFetch` with SWR: ```typescript function cachedPageType(term: string): Promise { @@ -313,21 +458,13 @@ export async function pageTypesFromPath(pagePath: string): Promise { } ``` -### Impact +**Impact:** Page type results are cached for 3 min. Concurrent and subsequent calls for the same segment share the same cached response. -Page type results are cached for 3 min. Concurrent and subsequent calls for the same segment share the same cached response. +### Pattern 7: Register All Site Loaders ---- +**Problem:** Custom site loaders like `site/loaders/Layouts/ProductCard.tsx` and `site/loaders/Search/colors.ts` appear in CMS blocks but aren't registered in `setup.ts`. This causes `[CMS] Unhandled resolver: site/loaders/...` warnings and missing data — not itself an N+1, but a common companion issue found during the same audits. -## Pattern 7: Register All Site Loaders - -### Problem - -Custom site loaders like `site/loaders/Layouts/ProductCard.tsx` and `site/loaders/Search/colors.ts` appear in CMS blocks but aren't registered in `setup.ts`. This causes `[CMS] Unhandled resolver: site/loaders/...` warnings and missing data. - -### Solution - -Register passthrough loaders in `COMMERCE_LOADERS` in `setup.ts`: +**Solution:** Register passthrough loaders in `COMMERCE_LOADERS` in `setup.ts`: ```typescript const COMMERCE_LOADERS: Record Promise> = { @@ -337,8 +474,6 @@ const COMMERCE_LOADERS: Record Promise> = { }; ``` -### How to Find Missing Loaders - Search server logs for "Unhandled resolver": ```bash rg "Unhandled resolver" # in terminal output @@ -348,9 +483,9 @@ Then check if the referenced loader exists in `src/loaders/` and add a correspon --- -## Diagnosing API Call Issues +## Step 6: Verify the Fix -### Server Logs +### Instrument and Check Terminal Logs Add prefixed logging to VTEX fetch: @@ -360,6 +495,18 @@ const result = await fetch(url); console.log(`[vtex] ${result.status} GET ${url} ${Date.now() - start}ms`); ``` +After fixing, the terminal should show **zero** (or deduped) calls to the eliminated endpoint: + +```bash +# Before: dozens of these per page load +[vtex] GET .../api/catalog_system/pvt/products/123/Specification +[vtex] GET .../api/catalog_system/pvt/products/456/Specification +# ... 20+ more + +# After: none of these, only intelligent-search calls +[vtex] GET .../api/io/_v/api/intelligent-search/product_search/... +``` + ### HAR Analysis ```python @@ -382,20 +529,68 @@ for path, count in vtex_calls.most_common(20): print(f" {count}x {path}") ``` -### Common N+1 Patterns to Watch For +### Measure Response Time + +```bash +# Cold start +curl -s -o /dev/null -w "%{http_code} %{time_total}s" http://localhost:5173/ + +# Warm request +curl -s -o /dev/null -w "%{http_code} %{time_total}s" http://localhost:5173/ +``` + +Expected improvement: 2-15 seconds faster on pages with multiple shelves. + +### Impact Reference (N+1 Latency Cost) + +| Products on Page | N+1 Calls | Latency per Call | Total Added Latency | +|------------------|-----------|------------------|---------------------| +| 12 (1 shelf) | 12 | ~370ms | ~4.4s | +| 24 (PLP) | 24 | ~370ms | ~8.9s | +| 48 (PLP + 2 shelves) | 48 | ~370ms | ~17.8s | +| 100 (homepage) | 100 | ~370ms | ~37s | + +Even with parallelism, VTEX rate limits kick in after ~20 concurrent calls, serializing the rest. + +--- + +## Common N+1 Patterns to Watch For | Pattern | Symptom | Fix | |---------|---------|-----| -| `search/{slug}/p` called N times | Multiple section loaders resolve same product | `vtexCachedFetch` via `slugCache` | -| `crossselling/{type}/{id}` duplicated | Same product ID across multiple related-products sections | `vtexCachedFetch` in `relatedProducts.ts` | -| `simulation` called per product | Product shelves simulate one-by-one | `usePriceSimulationBatch` | +| `search/{slug}/p` called N times | Multiple section loaders resolve same product | `vtexCachedFetch` via `slugCache` (Pattern 1) | +| `crossselling/{type}/{id}` duplicated | Same product ID across multiple related-products sections | `vtexCachedFetch` in `relatedProducts.ts` (Pattern 2) | +| `simulation` called per product | Product shelves simulate one-by-one | `usePriceSimulationBatch` (Pattern 3) | | `intelligent-search` for Header shelves | Header re-resolved on every navigation | Layout caching + `fetchWithCache` for IS | | `orderForm` called multiple times | Multiple components check cart state | `useCart` singleton | -| `pagetype` for asset URLs | PLP loader resolving `/image/...` paths | `isValidPLPPath` filter | -| `pagetype` called N times for same segment | Multiple PLP sections resolve same path | `vtexCachedFetch` in `cachedPageType` | -| `Unhandled resolver: site/loaders/...` | Custom site loaders not registered | Register in `setup.ts` COMMERCE_LOADERS | - ---- +| `pagetype` for asset URLs | PLP loader resolving `/image/...` paths | `isValidPLPPath` filter (Pattern 5) | +| `pagetype` called N times for same segment | Multiple PLP sections resolve same path | `vtexCachedFetch` in `cachedPageType` (Pattern 6) | +| `getProductSpecification` per product | Shelf/search result enrichment loop | Read from `product.isVariantOf.additionalProperty` (Step 3) | +| `Unhandled resolver: site/loaders/...` | Custom site loaders not registered | Register in `setup.ts` COMMERCE_LOADERS (Pattern 7) | + +## Common N+1 Locations in Deco Sites + +| Component | File Pattern | Typical N+1 | +|-----------|-------------|-------------| +| ProductShelf | `components/product/ProductShelf.tsx` | `getProductSpecification` per product | +| SearchResult | `components/search/SearchResult.tsx` | `getProductSpecification` per product | +| ProductTabbedShelf | `components/product/ProductTabbedShelf/` | Specs per product per tab | +| BuyTogether | `components/product/BuyTogether/` | Cross-selling + specs per suggestion | +| HouseCatalog | `components/search/HouseCatalog/` | Specs + simulation per product | +| ProductShelfDinamica | `components/product/ProductShelfDinamica.tsx` | Specs per product in dynamic shelf | + +## Quick Audit Checklist + +- [ ] Search for `getProductSpecification` — replace with `getSpecsFromProduct` in shelf loaders +- [ ] Search for `.map(async` inside `export const loader` — each is a potential N+1 +- [ ] Check for `usePriceSimulation` in loops — legitimate but verify it's parallelized or batched via `usePriceSimulationBatch` +- [ ] Check for `getCrossSelling` in loops — should only be on PDP, not shelves, and go through `vtexCachedFetch` +- [ ] Verify `Promise.all` wraps parallel calls — not sequential `await` in `for` loop +- [ ] Confirm slug lookups go through `slugCache` (`searchBySlug` / `resolveProductIdBySlug`), not raw `vtexFetch` +- [ ] Confirm PLP path resolution filters asset URLs via `isValidPLPPath` before calling `pageTypesFromPath` +- [ ] Check terminal logs for repeated API patterns during page load +- [ ] Check server logs for `Unhandled resolver: site/loaders/...` and register missing loaders in `setup.ts` +- [ ] Measure SSR time before and after changes ## Common Errors @@ -430,8 +625,6 @@ const sc = config.salesChannel; // Use sc in API URLs: `?sc=${sc}` ``` ---- - ## Related Skills | Skill | Purpose | @@ -439,5 +632,7 @@ const sc = config.salesChannel; | `deco-vtex-fetch-cache` | SWR fetch cache for VTEX APIs (`fetchWithCache`, `vtexCachedFetch`) | | `deco-variant-selection-perf` | Eliminate server calls for variant selection | | `deco-cms-layout-caching` | Cache layout sections to prevent Header API calls | -| `deco-loader-n-plus-1-detector` | Automated N+1 detection in Deco loaders | | `deco-tanstack-storefront-patterns` | General runtime patterns + loader `cache`/`cacheKey` exports | +| `deco-performance-audit` | CDN-level metrics and cache analysis | +| `deco-full-analysis` | Full site architecture analysis | +| `deco-edge-caching` | Cache headers and edge configuration | diff --git a/.cursor/skills/deco-apps-architecture/SKILL.md b/.cursor/skills/deco-apps-architecture/SKILL.md deleted file mode 100644 index 6d32137..0000000 --- a/.cursor/skills/deco-apps-architecture/SKILL.md +++ /dev/null @@ -1,255 +0,0 @@ ---- -name: deco-apps-architecture -description: Architecture reference for deco-cx/apps monorepo — the canonical Deco commerce integration library. Covers the monorepo structure with 90+ apps (VTEX, Shopify, Nuvemshop, Wake, etc.), shared utils (HTTP client, GraphQL, fetch, cookies, LRU cache), commerce types (schema.org), the app pattern (mod.ts, manifest.gen.ts, actions/, loaders/, hooks/, utils/), and the relationship between deco-cx/apps (Fresh/Deno) and @decocms/apps-start (TanStack/Node). Use when exploring the apps repo, understanding how a Deco app is structured, creating new integrations, or porting apps to TanStack Start. -globs: - - "**/deco.ts" - - "**/manifest.gen.ts" - - "**/mod.ts" - - "**/runtime.ts" ---- - -## Sub-documents - -| Document | Topic | -|----------|-------| -| [commerce-types.md](./commerce-types.md) | Schema.org types reference — Product, Offer, PDP, PLP, Analytics | -| [shared-utils.md](./shared-utils.md) | Shared utilities — HTTP client, GraphQL, fetch, cookie, normalize | -| [app-pattern.md](./app-pattern.md) | The Deco App Pattern — mod.ts, manifest, runtime, hooks, context | -| [vtex-deep-structure.md](./vtex-deep-structure.md) | VTEX app deep dive — all 141 files, endpoints, data flow | -| [website-app.md](./website-app.md) | Website app — routing, SEO, handlers, matchers, A/B testing | -| [scripts-codegen.md](./scripts-codegen.md) | Scripts & codegen — OpenAPI, GraphQL, templates, CI/CD | -| [new-app-guide.md](./new-app-guide.md) | Creating a new app — step-by-step guide with commerce template | - -# deco-cx/apps Architecture - -Reference for the `deco-cx/apps` monorepo — the canonical library of Deco integrations. - -## Monorepo Overview - -``` -apps/ -├── deco.ts # App registry — lists all ~90 apps -├── deno.json # Deno config, import map, tasks -├── scripts/ # Codegen and project scaffolding -│ ├── start.ts # OpenAPI/GraphQL codegen + bundle -│ └── new.ts # App/MCP template generator -├── utils/ # Shared utilities (all apps can import) -├── commerce/ # Schema.org commerce types + shared loaders -├── website/ # Base website app (routing, SEO, analytics, image) -├── admin/ # Admin widget types -├── compat/ # Legacy compatibility ($live, std) -├── workflows/ # Deco workflow engine -├── vtex/ # VTEX integration (141 files) -├── shopify/ # Shopify integration -├── nuvemshop/ # Nuvemshop integration -├── wake/ # Wake integration -├── wap/ # Wap integration -└── (80+ more apps) # AI, analytics, CRM, payments, etc. -``` - -## The App Pattern - -Every app follows the same structure: - -``` -{app-name}/ -├── mod.ts # App factory — exports Props, AppContext, state -├── manifest.gen.ts # Auto-generated — registers all blocks -├── runtime.ts # Client-side invoke proxy (optional) -├── middleware.ts # Request middleware (optional) -├── actions/ # Write operations (mutations) -├── loaders/ # Read operations (data fetching) -├── hooks/ # Client-side Preact hooks (optional) -├── sections/ # CMS-renderable UI sections (optional) -├── handlers/ # HTTP request handlers (optional) -├── components/ # Shared Preact components (optional) -├── utils/ # Internal utilities and types -│ ├── types.ts # API response/request types -│ ├── client.ts # Typed HTTP client definitions -│ ├── transform.ts # API → schema.org mapping (commerce apps) -│ └── openapi/ # Auto-generated OpenAPI types (optional) -├── workflows/ # Background workflow definitions (optional) -└── preview/ # Admin preview UI (optional) -``` - -### mod.ts Pattern - -```typescript -import manifest, { Manifest } from "./manifest.gen.ts"; -import { type App, type AppContext as AC } from "@deco/deco"; - -export type AppContext = AC>; - -export interface Props { - account: string; - apiKey?: Secret; - // ... -} - -export default function MyApp(props: Props) { - const state = { /* clients, config */ }; - const app: App = { manifest, state }; - return app; -} -``` - -### manifest.gen.ts - -Auto-generated by Deco. Registers all actions, loaders, handlers, sections, workflows as blocks. - -### runtime.ts - -```typescript -import { Manifest } from "./manifest.gen.ts"; -import { proxy } from "@deco/deco/web"; -export const invoke = proxy(); -``` - -## Shared Utils (`/utils/`) - -| File | Purpose | -|------|---------| -| `http.ts` | `createHttpClient` — typed HTTP client from OpenAPI specs | -| `graphql.ts` | `createGraphqlClient` — typed GraphQL client | -| `fetch.ts` | `fetchSafe`, `fetchAPI`, retry with exponential backoff, `STALE` cache headers | -| `cookie.ts` | `proxySetCookie`, `getFlagsFromCookies` | -| `normalize.ts` | `removeDirtyCookies`, `removeNonLatin1Chars` | -| `lru.ts` | LRU cache implementation | -| `shortHash.ts` | SHA-256 string hashing | -| `pool.ts` | Resource pool with acquire/release | -| `worker.ts` | Web Worker abstraction (Comlink-style) | -| `dataURI.ts` | Script-to-data-URI conversion | -| `capitalize.ts` | String capitalization | - -## Commerce Module (`/commerce/`) - -The shared commerce layer — platform-agnostic types and utilities. - -``` -commerce/ -├── types.ts # Schema.org types (Product, Offer, BreadcrumbList, etc.) -├── mod.ts # Composes website + platform (vtex/shopify/wake/vnda) -├── manifest.gen.ts -├── loaders/ -│ ├── extensions/ # Product page/list/PLP enrichment -│ ├── navbar.ts # Navigation loader -│ └── product/ # Product query orchestration -├── sections/Seo/ # SEO sections for PDP/PLP -└── utils/ - ├── filters.ts # parseRange, formatRange - ├── constants.ts # DEFAULT_IMAGE placeholder - ├── canonical.ts # Canonical URL from breadcrumb - ├── productToAnalyticsItem.ts # Product → GA4 AnalyticsItem - └── stateByZip.ts # Brazilian state from ZIP code -``` - -### Key Types (types.ts) - -| Type | Purpose | -|------|---------| -| `Product` | Schema.org Product with offers, images, variants | -| `ProductGroup` | Product with hasVariant[] | -| `Offer` / `AggregateOffer` | Pricing and availability | -| `ProductDetailsPage` | PDP data (product + breadcrumb + SEO) | -| `ProductListingPage` | PLP data (products + filters + pagination + SEO) | -| `BreadcrumbList` | Navigation path | -| `Filter` / `FilterToggle` / `FilterRange` | Faceted search filters | -| `Suggestion` | Autocomplete results | -| `AnalyticsItem` | GA4 event item format | -| `SiteNavigationElement` | Menu/navbar structure | - -## VTEX App Structure (`/vtex/`) - -The largest integration (141 files). For detailed VTEX-specific docs, see the `deco-apps-vtex-porting` and `deco-apps-vtex-review` skills. - -``` -vtex/ -├── actions/ # 43 files across 11 subdirs -│ ├── cart/ # 16 actions (addItems, updateItems, updateCoupons, etc.) -│ ├── authentication/# 8 actions (signIn, logout, recovery, etc.) -│ ├── address/ # 3 (create, update, delete) -│ ├── session/ # 3 (create, edit, delete) -│ ├── wishlist/ # 2 (add, remove) -│ ├── newsletter/ # 2 (subscribe, updateOptIn) -│ ├── masterdata/ # 2 (create, update document) -│ └── (orders, payment, profile, review, analytics) -├── loaders/ # 50+ files across 15 subdirs -│ ├── intelligentSearch/ # 6 (PDP, PLP, productList, suggestions, topSearches, validator) -│ ├── legacy/ # 7 (PDP, PLP, productList, suggestions, brands, pageType, related) -│ ├── logistics/ # 5 (salesChannel, pickupPoints, stock) -│ ├── workflow/ # 2 (product, products) -│ └── (address, cart, categories, collections, orders, payment, profile, session, etc.) -├── hooks/ # 5 (context, useCart, useUser, useWishlist, useAutocomplete) -├── utils/ # 31 files -│ ├── transform.ts # Canonical VTEX → schema.org mapping (THE key file) -│ ├── types.ts # 1320 lines of VTEX API types -│ ├── client.ts # SP, VTEXCommerceStable client definitions -│ ├── openapi/ # 12 files — auto-generated from VTEX OpenAPI specs -│ └── (cookies, segment, intelligentSearch, legacy, vtexId, etc.) -└── (middleware, handlers/sitemap, sections/Analytics, workflows, components, preview) -``` - -## Shopify App Structure (`/shopify/`) - -``` -shopify/ -├── actions/cart/ # addItems, updateCoupons, updateItems -├── actions/order/ # draftOrderCalculate -├── actions/user/ # signIn, signUp -├── hooks/ # context, useCart, useUser -├── loaders/ # PDP, PLP, ProductList, RelatedProducts, cart, shop, user, proxy -└── utils/ - ├── admin/ # Admin API queries - ├── storefront/ # Storefront API (GraphQL schema + generated types) - └── transform.ts # Shopify → schema.org mapping -``` - -## Website App (`/website/`) - -Base app that all storefronts use. Handles routing, SEO, analytics, image optimization, and theme. - -Key areas: -- `handlers/` — Fresh router, proxy, redirect, sitemap -- `loaders/` — Pages, fonts, images, redirects, secrets, environment -- `sections/` — Analytics (GA, GTM, Pixel), Rendering, SEO -- `matchers/` — Audience targeting (device, cookie, date, location, etc.) -- `flags/` — A/B testing, multivariate, audience segmentation - -## Scripts (`/scripts/`) - -| Script | Purpose | -|--------|---------| -| `start.ts` | Runs on `deno task start` — generates OpenAPI types, GraphQL types, bundles | -| `new.ts` | Scaffolds new app from template — `deno task new` | - -## CI/CD (`.github/workflows/`) - -| Workflow | Trigger | Purpose | -|----------|---------|---------| -| `ci.yaml` | Push/PR | Bundle, fmt check, lint, test, bench | -| `release.yaml` | Tag push | Publish release | -| `releaser.yaml` | PR/comment | Version bump, tag creation | - -## Relationship: apps → apps-start - -``` -deco-cx/apps (Fresh/Deno) @decocms/apps-start (TanStack/Node) -──────────────────────── ───────────────────────────────── -vtex/utils/transform.ts → vtex/utils/transform.ts (ported) -vtex/utils/types.ts → vtex/utils/types.ts (ported) -vtex/loaders/** → vtex/loaders/** + vtex/inline-loaders/** -vtex/actions/** → vtex/actions/** (consolidated) -vtex/hooks/** → vtex/hooks/** (React + TanStack Query) -commerce/types.ts → commerce/types/commerce.ts -commerce/utils/** → commerce/utils/** + commerce/sdk/** -utils/fetch.ts → Replaced by vtexFetch/vtexFetchWithCookies -utils/http.ts → Not needed (no OpenAPI codegen in apps-start) -utils/graphql.ts → vtexIOGraphQL in client.ts -``` - -Key differences: -- apps-start has no `mod.ts` / `manifest.gen.ts` / `runtime.ts` (no Deco framework) -- apps-start uses `configureVtex()` instead of app factory pattern -- apps-start loaders are pure async functions, not Deco blocks -- apps-start hooks use `@tanstack/react-query` instead of `@preact/signals` -- apps-start has no OpenAPI codegen — uses manual `vtexFetch` calls diff --git a/.cursor/skills/deco-apps-architecture/app-pattern.md b/.cursor/skills/deco-apps-architecture/app-pattern.md deleted file mode 100644 index 142b2ff..0000000 --- a/.cursor/skills/deco-apps-architecture/app-pattern.md +++ /dev/null @@ -1,288 +0,0 @@ -# The Deco App Pattern - -Every Deco app follows the same structural pattern. This document describes each component. - -## Directory Layout - -``` -{app-name}/ -├── mod.ts # App factory function -├── manifest.gen.ts # Auto-generated block registry -├── runtime.ts # Client-side invoke proxy -├── middleware.ts # Per-request middleware (optional) -├── actions/ # Server-side mutations -├── loaders/ # Server-side data fetching -├── hooks/ # Client-side hooks (Preact signals) -│ └── context.ts # Shared reactive state -├── sections/ # CMS-editable UI sections -├── handlers/ # HTTP request handlers -├── components/ # Shared components -├── utils/ # Internal utilities -│ ├── types.ts # API types -│ ├── client.ts # Typed HTTP client interface -│ └── transform.ts # API → schema.org mapping -├── workflows/ # Background jobs -└── preview/ # Admin preview UI -``` - -## `mod.ts` — App Factory - -The entry point. Exports a function that receives `Props` and returns an `App`. - -```typescript -import manifest, { Manifest } from "./manifest.gen.ts"; -import { middleware } from "./middleware.ts"; -import { type App, type AppContext as AC } from "@deco/deco"; - -export type AppContext = AC>; - -export interface Props { - account: string; - apiKey?: Secret; - salesChannel?: string; - platform: "myplatform"; -} - -export default function MyApp(props: Props) { - // Create typed HTTP clients - const api = createHttpClient({ base: `https://${props.account}.api.com` }); - const gql = createGraphqlClient({ endpoint: `https://${props.account}.api.com/graphql` }); - - const state = { ...props, api, gql }; - - const app: App = { - state, - manifest, - middleware, // Optional - dependencies: [], // Other apps this depends on - }; - - return app; -} -``` - -### Key Concepts: -- **`state`** is available to all loaders/actions via `ctx` (AppContext) -- **`manifest`** registers all blocks (auto-generated) -- **`middleware`** runs before every request -- **`dependencies`** compose other apps (e.g., `website`, `workflows`) - -## `manifest.gen.ts` — Block Registry - -Auto-generated by `deno task start`. Registers all discoverable blocks: - -```typescript -// DO NOT EDIT -import * as $$$0 from "./actions/cart/addItems.ts"; -import * as $$$1 from "./loaders/productDetailsPage.ts"; -import * as $$$$0 from "./sections/Analytics/Vtex.tsx"; - -const manifest = { - "actions": { "myapp/actions/cart/addItems.ts": $$$0 }, - "loaders": { "myapp/loaders/productDetailsPage.ts": $$$1 }, - "sections": { "myapp/sections/Analytics/Vtex.tsx": $$$$0 }, - "name": "myapp", - "baseUrl": import.meta.url, -}; -export type Manifest = typeof manifest; -export default manifest; -``` - -## `runtime.ts` — Client Invoke Proxy - -Provides typed client-side invocations: - -```typescript -import { Manifest } from "./manifest.gen.ts"; -import { proxy } from "@deco/deco/web"; -export const invoke = proxy(); - -// Usage (client-side): -const cart = await invoke.myapp.loaders.cart(); -const result = await invoke.myapp.actions.cart.addItems({ items: [...] }); -``` - -## Actions Pattern - -Server-side write operations. Each action is a single function: - -```typescript -// actions/cart/addItems.ts -import { AppContext } from "../../mod.ts"; - -export interface Props { - items: Array<{ id: string; quantity: number; seller: string }>; -} - -const action = async (props: Props, req: Request, ctx: AppContext): Promise => { - const { vcsDeprecated } = ctx; - // ... call VTEX API - return orderForm; -}; - -export default action; -``` - -### Action conventions: -- Receives `(props, req, ctx)` — Props are CMS-configurable -- Returns the modified resource -- Placed in `actions/{domain}/{operation}.ts` -- Cart actions return `OrderForm` -- Auth actions return `AuthResponse` - -## Loaders Pattern - -Server-side read operations: - -```typescript -// loaders/productDetailsPage.ts -import { AppContext } from "../mod.ts"; -import { ProductDetailsPage } from "../../commerce/types.ts"; - -export interface Props { - slug: string; -} - -const loader = async (props: Props, req: Request, ctx: AppContext): Promise => { - // Fetch from API, transform to schema.org - return { "@type": "ProductDetailsPage", product, breadcrumbList, seo }; -}; - -export default loader; -``` - -### Loader conventions: -- Receives `(props, req, ctx)` -- Returns `null` for 404 cases -- Commerce loaders return `schema.org` types -- Placed in `loaders/{domain}/{operation}.ts` - -## Hooks Pattern (Preact/Signals) - -Client-side reactive state using `@preact/signals`: - -### `context.ts` — Central State - -```typescript -import { IS_BROWSER } from "$fresh/runtime.ts"; -import { signal } from "@preact/signals"; -import { invoke } from "../runtime.ts"; - -const loading = signal(true); -const context = { - cart: IS_BROWSER ? signal(null) : { value: null }, - user: IS_BROWSER ? signal(null) : { value: null }, -}; - -// Serial queue with abort support -let queue = Promise.resolve(); -let abort = () => {}; -const enqueue = (cb: (signal: AbortSignal) => Promise>) => { - abort(); - loading.value = true; - const controller = new AbortController(); - queue = queue.then(async () => { - const result = await cb(controller.signal); - context.cart.value = result.cart || context.cart.value; - loading.value = false; - }); - abort = () => controller.abort(); - return queue; -}; - -// Initial load -if (IS_BROWSER) { - enqueue((signal) => invoke({ cart: invoke.myapp.loaders.cart() }, { signal })); -} - -export const state = { ...context, loading, enqueue }; -``` - -### `useCart.ts` — Domain Hook - -```typescript -import { state as storeState } from "./context.ts"; -import { invoke } from "../runtime.ts"; - -const { cart, loading } = storeState; - -const enqueue = (key) => (props) => - storeState.enqueue((signal) => - invoke({ cart: { key, props } }, { signal }) - ); - -const state = { - cart, - loading, - addItems: enqueue("myapp/actions/cart/addItems.ts"), - updateItems: enqueue("myapp/actions/cart/updateItems.ts"), - // ... -}; - -export const useCart = () => state; -``` - -### Key Hook Concepts: -- **Serial queue** — mutations execute one at a time, latest aborts previous -- **`enqueue`** pattern — wraps `invoke` with abort control -- **Signals** — reactive primitives from `@preact/signals` -- All state flows through `context.ts` - -## Middleware Pattern - -Runs before every request. Sets up shared context: - -```typescript -// middleware.ts -import { AppMiddlewareContext } from "./mod.ts"; - -export const middleware = (_props: unknown, req: Request, ctx: AppMiddlewareContext) => { - // Parse cookies, set segment in bag, etc. - const cookies = getCookies(req.headers); - setSegmentBag(cookies, req, ctx); - setISCookiesBag(cookies, ctx); - return ctx.next!(); -}; -``` - -Uses `ctx.bag` for per-request shared state (segment, cookies, orderFormId). - -## Sections Pattern - -CMS-renderable Preact components: - -```typescript -// sections/Analytics/Vtex.tsx -import { AppContext } from "../../mod.ts"; - -export interface Props { - trackingId?: string; -} - -export default function VtexAnalytics(props: Props) { - return &keep=ok"); + + const callArg = fetchMock.mock.calls[0]?.[0] as string; + expect(callArg).toContain("utm_source=script"); + expect(callArg).not.toContain("<"); + expect(callArg).not.toContain("café"); + expect(callArg).toContain("keep=ok"); + }); + + it("forwards init options to fetch", async () => { + const fetchMock = globalThis.fetch as ReturnType; + fetchMock.mockResolvedValue(mockResponse({})); + + await fetchSafe("https://example.com/api", { + method: "POST", + headers: { "x-test": "1" }, + }); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.method).toBe("POST"); + expect((init.headers as Record)["x-test"]).toBe("1"); + }); +}); + +describe("fetchAPI", () => { + beforeEach(() => { + globalThis.fetch = vi.fn() as unknown as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + it("parses JSON on success", async () => { + (globalThis.fetch as ReturnType).mockResolvedValue( + mockResponse({ hello: "world" }), + ); + const data = await fetchAPI<{ hello: string }>("https://example.com/api"); + expect(data).toEqual({ hello: "world" }); + }); +}); diff --git a/packages/apps-vtex/src/utils/__tests__/fetchCache.test.ts b/packages/apps-vtex/src/utils/__tests__/fetchCache.test.ts new file mode 100644 index 0000000..be989e1 --- /dev/null +++ b/packages/apps-vtex/src/utils/__tests__/fetchCache.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clearFetchCache, fetchWithCache, getFetchCacheStats } from "../fetchCache"; + +function mockResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + json: () => Promise.resolve(body), + } as Response; +} + +describe("fetchWithCache", () => { + beforeEach(() => { + clearFetchCache(); + }); + + it("returns fetched data on cache miss", async () => { + const data = { id: 1, name: "product" }; + const doFetch = vi.fn(() => Promise.resolve(mockResponse(data))); + + const result = await fetchWithCache("key1", doFetch); + expect(result).toEqual(data); + expect(doFetch).toHaveBeenCalledOnce(); + }); + + it("returns cached data on subsequent calls", async () => { + const data = { id: 1 }; + const doFetch = vi.fn(() => Promise.resolve(mockResponse(data))); + + await fetchWithCache("key2", doFetch); + const result = await fetchWithCache("key2", doFetch); + + expect(result).toEqual(data); + expect(doFetch).toHaveBeenCalledOnce(); + }); + + it("deduplicates in-flight requests", async () => { + const data = { id: 1 }; + const doFetch = vi.fn( + () => new Promise((resolve) => setTimeout(() => resolve(mockResponse(data)), 10)), + ); + + const [r1, r2] = await Promise.all([ + fetchWithCache("key3", doFetch), + fetchWithCache("key3", doFetch), + ]); + + expect(r1).toEqual(data); + expect(r2).toEqual(data); + expect(doFetch).toHaveBeenCalledOnce(); + }); + + it("throws on 5xx responses", async () => { + const doFetch = vi.fn(() => Promise.resolve(mockResponse(null, 500))); + + await expect(fetchWithCache("key4", doFetch)).rejects.toThrow("500"); + }); + + it("returns null for 404 responses", async () => { + const doFetch = vi.fn(() => Promise.resolve(mockResponse(null, 404))); + + const result = await fetchWithCache("key5", doFetch); + expect(result).toBeNull(); + }); + + it("tracks cache stats", async () => { + const doFetch = () => Promise.resolve(mockResponse({ ok: true })); + + expect(getFetchCacheStats()).toEqual({ entries: 0, inflight: 0 }); + + await fetchWithCache("stats1", doFetch); + await fetchWithCache("stats2", doFetch); + + expect(getFetchCacheStats()).toEqual({ entries: 2, inflight: 0 }); + }); + + it("clears cache", async () => { + const doFetch = () => Promise.resolve(mockResponse({ ok: true })); + await fetchWithCache("clear1", doFetch); + + clearFetchCache(); + expect(getFetchCacheStats()).toEqual({ entries: 0, inflight: 0 }); + }); + + it("evicts the inflight slot when the fetch never settles", async () => { + vi.useFakeTimers(); + try { + // `doFetch` returns a Promise that never resolves — simulates a hung + // VTEX subrequest (TCP open, no FIN, no response). Without the + // timeout guard, the inflight Map entry would leak forever and + // subsequent callers would `await` a zombie Promise — the prod + // memory-leak this fix addresses. + const doFetch = vi.fn(() => new Promise(() => {})); + + const pending = fetchWithCache("hung-key", doFetch); + // Swallow the eventual rejection so the unhandled rejection doesn't + // fail the test runner. + pending.catch(() => {}); + + expect(getFetchCacheStats().inflight).toBe(1); + + // Fast-forward past the 10s fetch timeout. + await vi.advanceTimersByTimeAsync(11_000); + + await expect(pending).rejects.toThrow(/timed out/); + expect(getFetchCacheStats().inflight).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/apps-vtex/src/utils/__tests__/instrumentedFetch.test.ts b/packages/apps-vtex/src/utils/__tests__/instrumentedFetch.test.ts new file mode 100644 index 0000000..70c886a --- /dev/null +++ b/packages/apps-vtex/src/utils/__tests__/instrumentedFetch.test.ts @@ -0,0 +1,158 @@ +/** + * Smoke tests for the pre-wired VTEX fetch factory. + * + * The deep coverage of `createInstrumentedFetch` lives in @decocms/start; + * here we only verify the apps-start wiring decisions: + * + * - The URL router is plumbed through so unannotated callsites get + * semantic span operations + histogram labels. + * - The canonical `http.client.request.duration` histogram is recorded + * with the right labels on every call (via the framework's + * `recordCommerceMetric` helper). + * - `disableHistogram: true` opts out cleanly. + * - A caller's explicit `init.operation` wins over the URL router + * (delegating to the framework, but worth asserting at this seam). + */ + +import { configureMeter, type MeterAdapter } from "@decocms/blocks/sdk/observability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createVtexFetch } from "../instrumentedFetch"; + +type Labels = Record; +type HistogramCall = { + name: string; + value: number; + attrs: Labels; +}; + +function captureHistogram(): { calls: HistogramCall[]; meter: MeterAdapter } { + const calls: HistogramCall[] = []; + const meter: MeterAdapter = { + counterInc: vi.fn(), + gaugeSet: vi.fn(), + histogramRecord: (name, value, attrs) => { + calls.push({ name, value, attrs: attrs ?? {} }); + }, + }; + return { calls, meter }; +} + +function mockOkResponse(status = 200): Response { + return new Response(JSON.stringify({}), { status }); +} + +describe("createVtexFetch", () => { + afterEach(() => { + vi.restoreAllMocks(); + configureMeter({ + counterInc: () => {}, + gaugeSet: () => {}, + histogramRecord: () => {}, + }); + }); + + it("records http.client.request.duration with provider/operation/status labels on success", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => mockOkResponse(200)); + const fetchFn = createVtexFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn("https://store.vtexcommercestable.com.br/api/sessions"); + + expect(calls).toHaveLength(1); + expect(calls[0].name).toBe("http.client.request.duration"); + expect(calls[0].attrs).toMatchObject({ + provider: "vtex", + operation: "sessions.get", + status_class: "2xx", + cached: false, + }); + expect(calls[0].value).toBeGreaterThanOrEqual(0); + }); + + it("uses the URL router for unannotated calls", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => mockOkResponse(200)); + const fetchFn = createVtexFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn( + "https://store.vtexcommercestable.com.br/api/io/_v/api/intelligent-search/product_search/foo", + ); + + expect(calls[0].attrs.operation).toBe("intelligent-search.product_search"); + }); + + it("honors init.operation over the URL router", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => mockOkResponse(200)); + const fetchFn = createVtexFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn("https://store.vtexcommercestable.com.br/api/sessions", { + operation: "explicit.custom_op", + }); + + expect(calls[0].attrs.operation).toBe("explicit.custom_op"); + }); + + it("records cached=true when the response carries x-cache: HIT", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn( + async () => new Response("{}", { status: 200, headers: { "x-cache": "HIT" } }), + ); + const fetchFn = createVtexFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn("https://store.vtexcommercestable.com.br/api/sessions"); + + expect(calls[0].attrs.cached).toBe(true); + }); + + it("emits status_class derived from the actual response status", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => mockOkResponse(503)); + const fetchFn = createVtexFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn("https://store.vtexcommercestable.com.br/api/sessions"); + + expect(calls[0].attrs.status_class).toBe("5xx"); + }); + + it("skips histogram emission when disableHistogram is true", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => mockOkResponse(200)); + const fetchFn = createVtexFetch({ + baseFetch: baseFetch as typeof fetch, + disableHistogram: true, + }); + + await fetchFn("https://store.vtexcommercestable.com.br/api/sessions"); + + expect(calls).toHaveLength(0); + }); + + it("does not surface the operation field to the underlying fetch", async () => { + const { meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async (_input: unknown, _init?: RequestInit) => mockOkResponse(200)); + const fetchFn = createVtexFetch({ baseFetch: baseFetch as unknown as typeof fetch }); + + await fetchFn("https://store.vtexcommercestable.com.br/api/sessions", { + operation: "explicit.op", + }); + + expect(baseFetch).toHaveBeenCalledOnce(); + const init = baseFetch.mock.calls[0]?.[1] as Record | undefined; + expect(init?.operation).toBeUndefined(); + }); +}); diff --git a/packages/apps-vtex/src/utils/__tests__/intelligentSearch.test.ts b/packages/apps-vtex/src/utils/__tests__/intelligentSearch.test.ts new file mode 100644 index 0000000..ba587b8 --- /dev/null +++ b/packages/apps-vtex/src/utils/__tests__/intelligentSearch.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { isFilterParam, toPath, withDefaultFacets, withDefaultParams } from "../intelligentSearch"; + +describe("withDefaultFacets", () => { + it("returns a copy of the facets array", () => { + const facets = [{ key: "category", value: "shoes" }]; + const result = withDefaultFacets(facets); + expect(result).toEqual(facets); + expect(result).not.toBe(facets); + }); + + it("returns empty array for empty input", () => { + expect(withDefaultFacets([])).toEqual([]); + }); +}); + +describe("toPath", () => { + it("builds path from facets", () => { + const facets = [ + { key: "category", value: "shoes" }, + { key: "brand", value: "nike" }, + ]; + expect(toPath(facets)).toBe("category/shoes/brand/nike"); + }); + + it("handles facets with empty key", () => { + const facets = [{ key: "", value: "shoes" }]; + expect(toPath(facets)).toBe("shoes"); + }); + + it("returns empty string for empty facets", () => { + expect(toPath([])).toBe(""); + }); +}); + +describe("withDefaultParams", () => { + it("fills in defaults", () => { + const result = withDefaultParams({}); + expect(result).toEqual({ + page: 1, + count: 12, + query: "", + sort: "", + fuzzy: "auto", + locale: "pt-BR", + hideUnavailableItems: false, + simulationBehavior: "default", + }); + }); + + it("increments page by 1", () => { + expect(withDefaultParams({ page: 0 }).page).toBe(1); + expect(withDefaultParams({ page: 2 }).page).toBe(3); + }); + + it("preserves provided values", () => { + const result = withDefaultParams({ + query: "shoes", + count: 24, + sort: "price:asc", + fuzzy: "0", + hideUnavailableItems: true, + }); + expect(result.query).toBe("shoes"); + expect(result.count).toBe(24); + expect(result.sort).toBe("price:asc"); + expect(result.fuzzy).toBe("0"); + expect(result.hideUnavailableItems).toBe(true); + }); + + it("omits fuzzy when empty string", () => { + const result = withDefaultParams({ fuzzy: "" }); + expect(result).not.toHaveProperty("fuzzy"); + }); +}); + +describe("isFilterParam", () => { + it("returns true for filter params", () => { + expect(isFilterParam("filter.category")).toBe(true); + expect(isFilterParam("filter.brand")).toBe(true); + }); + + it("returns false for non-filter params", () => { + expect(isFilterParam("page")).toBe(false); + expect(isFilterParam("query")).toBe(false); + expect(isFilterParam("filterNot")).toBe(false); + }); +}); diff --git a/packages/apps-vtex/src/utils/__tests__/minicart.test.ts b/packages/apps-vtex/src/utils/__tests__/minicart.test.ts new file mode 100644 index 0000000..3f07bbe --- /dev/null +++ b/packages/apps-vtex/src/utils/__tests__/minicart.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import type { OrderForm, OrderFormItem, Totalizer } from "../../types"; +import { vtexOrderFormToMinicart } from "../minicart"; + +const baseStorePreferences = { + countryCode: "BRA", + currencyCode: "BRL", + currencyLocale: 1046, + currencySymbol: "R$", + saveUserData: true, + timeZone: "E. South America Standard Time", + currencyFormatInfo: { + currencyDecimalDigits: 2, + currencyDecimalSeparator: ",", + currencyGroupSeparator: ".", + currencyGroupSize: 3, + startsWithCurrencySymbol: true, + }, +}; + +function makeOrderForm(overrides: Partial = {}): OrderForm { + return { + orderFormId: "of-1", + salesChannel: "1", + loggedIn: false, + isCheckedIn: false, + allowManualPrice: false, + canEditData: false, + ignoreProfileData: false, + value: 0, + messages: [], + items: [], + totalizers: [], + shippingData: null, + clientProfileData: null, + paymentData: null, + marketingData: null, + sellers: [], + clientPreferencesData: { locale: "pt-BR" }, + storePreferencesData: baseStorePreferences, + ...overrides, + }; +} + +function makeItem(overrides: Partial = {}): OrderFormItem { + return { + uniqueId: "uid-1", + id: "sku-1", + productId: "p-1", + name: "T-Shirt", + skuName: "T-Shirt M", + price: 9990, + listPrice: 12990, + sellingPrice: 9990, + quantity: 2, + seller: "1", + imageUrl: "http://example.com/img.jpg", + detailUrl: "/p/t-shirt", + additionalInfo: { brandName: "Brand", brandId: "b-1" }, + productCategoryIds: "/123/", + productCategories: { "123": "Clothing" }, + availability: "available", + measurementUnit: "un", + unitMultiplier: 1, + ...overrides, + }; +} + +function tot(id: string, value: number, name = id): Totalizer { + return { id, name, value }; +} + +describe("vtexOrderFormToMinicart", () => { + it("returns an empty cart shape for an empty OrderForm", () => { + const minicart = vtexOrderFormToMinicart(makeOrderForm()); + expect(minicart.storefront.items).toEqual([]); + expect(minicart.storefront.subtotal).toBe(0); + expect(minicart.storefront.discounts).toBe(0); + expect(minicart.storefront.total).toBe(0); + expect(minicart.storefront.shipping).toBeUndefined(); + }); + + it("converts cents to major units across totalizers", () => { + const orderForm = makeOrderForm({ + value: 19980, + totalizers: [tot("Items", 25980), tot("Discounts", -6000), tot("Shipping", 1500)], + items: [makeItem()], + }); + const minicart = vtexOrderFormToMinicart(orderForm); + expect(minicart.storefront.subtotal).toBeCloseTo(259.8, 2); + expect(minicart.storefront.discounts).toBeCloseTo(60, 2); + expect(minicart.storefront.shipping).toBeCloseTo(15, 2); + expect(minicart.storefront.total).toBeCloseTo(199.8, 2); + }); + + it("Discounts are always non-negative even when VTEX returns a negative totalizer", () => { + const orderForm = makeOrderForm({ + value: 9990, + totalizers: [tot("Items", 9990), tot("Discounts", -1000)], + items: [makeItem()], + }); + const minicart = vtexOrderFormToMinicart(orderForm); + expect(minicart.storefront.discounts).toBe(10); + }); + + it("omits shipping when no Shipping totalizer is present", () => { + const orderForm = makeOrderForm({ + value: 9990, + totalizers: [tot("Items", 9990)], + items: [makeItem()], + }); + const minicart = vtexOrderFormToMinicart(orderForm); + expect(minicart.storefront.shipping).toBeUndefined(); + }); + + it("maps OrderFormItem to MinicartItem with major-unit prices and analytics fields", () => { + const orderForm = makeOrderForm({ + value: 19980, + totalizers: [tot("Items", 19980)], + items: [makeItem()], + }); + const minicart = vtexOrderFormToMinicart(orderForm); + const item = minicart.storefront.items[0]; + expect(item.item_id).toBe("sku-1"); + expect(item.item_group_id).toBe("p-1"); + expect(item.item_name).toBe("T-Shirt"); + expect(item.item_brand).toBe("Brand"); + expect(item.item_url).toBe("/p/t-shirt"); + expect(item.price).toBeCloseTo(99.9, 2); + expect(item.listPrice).toBeCloseTo(129.9, 2); + expect(item.discount).toBeCloseTo(30, 2); + expect(item.quantity).toBe(2); + expect(item.seller).toBe("1"); + expect(item.affiliation).toBe("1"); + }); + + it("forces https on item images", () => { + const orderForm = makeOrderForm({ + items: [makeItem({ imageUrl: "http://cdn.example.com/img.jpg" })], + }); + const minicart = vtexOrderFormToMinicart(orderForm); + expect(minicart.storefront.items[0].image).toBe("https://cdn.example.com/img.jpg"); + }); + + it("propagates the marketing coupon onto every item and the storefront root", () => { + const orderForm = makeOrderForm({ + items: [makeItem(), makeItem({ uniqueId: "uid-2", id: "sku-2" })], + marketingData: { coupon: "SAVE10" }, + }); + const minicart = vtexOrderFormToMinicart(orderForm); + expect(minicart.storefront.coupon).toBe("SAVE10"); + expect(minicart.storefront.items[0].coupon).toBe("SAVE10"); + expect(minicart.storefront.items[1].coupon).toBe("SAVE10"); + }); + + it("uses opts overrides for free-shipping target, locale, checkout href and coupon toggle", () => { + const minicart = vtexOrderFormToMinicart(makeOrderForm(), { + freeShippingTarget: 250, + locale: "en-US", + checkoutHref: "/cart/go", + enableCoupon: false, + }); + expect(minicart.storefront.freeShippingTarget).toBe(250); + expect(minicart.storefront.locale).toBe("en-US"); + expect(minicart.storefront.checkoutHref).toBe("/cart/go"); + expect(minicart.storefront.enableCoupon).toBe(false); + }); + + it("infers pt-BR locale from BRA countryCode when no override is given", () => { + const orderForm = makeOrderForm({ + clientPreferencesData: { locale: "" }, + storePreferencesData: { ...baseStorePreferences, countryCode: "BRA" }, + }); + const minicart = vtexOrderFormToMinicart(orderForm); + expect(minicart.storefront.locale).toBe("pt-BR"); + }); + + it("preserves the raw OrderForm under .original for site escape hatches", () => { + const orderForm = makeOrderForm({ orderFormId: "of-original" }); + const minicart = vtexOrderFormToMinicart(orderForm); + expect(minicart.original).toBe(orderForm); + expect(minicart.original.orderFormId).toBe("of-original"); + }); +}); diff --git a/packages/apps-vtex/src/utils/__tests__/operationRouter.test.ts b/packages/apps-vtex/src/utils/__tests__/operationRouter.test.ts new file mode 100644 index 0000000..d89c8c7 --- /dev/null +++ b/packages/apps-vtex/src/utils/__tests__/operationRouter.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; +import { vtexOperationRouter } from "../operationRouter"; + +describe("vtexOperationRouter", () => { + const acct = "https://store.vtexcommercestable.com.br"; + + describe("Intelligent Search", () => { + it("captures the IS endpoint as the operation suffix", () => { + expect( + vtexOperationRouter( + `${acct}/api/io/_v/api/intelligent-search/product_search/electronics`, + "GET", + ), + ).toBe("intelligent-search.product_search"); + expect( + vtexOperationRouter(`${acct}/api/io/_v/api/intelligent-search/top_searches`, "GET"), + ).toBe("intelligent-search.top_searches"); + expect(vtexOperationRouter(`${acct}/api/io/_v/api/intelligent-search/facets`, "GET")).toBe( + "intelligent-search.facets", + ); + expect( + vtexOperationRouter(`${acct}/api/io/_v/api/intelligent-search/search_suggestions`, "GET"), + ).toBe("intelligent-search.search_suggestions"); + }); + + it("strips query string before matching", () => { + expect( + vtexOperationRouter( + `${acct}/api/io/_v/api/intelligent-search/product_search?query=foo&sort=price`, + "GET", + ), + ).toBe("intelligent-search.product_search"); + }); + }); + + describe("Checkout / orderForm", () => { + it("differentiates create vs get on orderForm", () => { + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForm`, "POST")).toBe( + "checkout.orderform.create", + ); + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForm`, "GET")).toBe( + "checkout.orderform.get", + ); + }); + + it("differentiates items add/update/remove by HTTP method", () => { + const url = `${acct}/api/checkout/pub/orderForm/abc123/items`; + expect(vtexOperationRouter(url, "POST")).toBe("checkout.orderform.items.add"); + expect(vtexOperationRouter(url, "PATCH")).toBe("checkout.orderform.items.update"); + expect(vtexOperationRouter(url, "PUT")).toBe("checkout.orderform.items.update"); + expect(vtexOperationRouter(url, "DELETE")).toBe("checkout.orderform.items.remove"); + }); + + it("recognizes the /items/update legacy mass-update endpoint", () => { + expect( + vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/abc123/items/update`, "POST"), + ).toBe("checkout.orderform.items.update"); + }); + + it("handles coupons, profile, shippingData, paymentData", () => { + const id = "abc123"; + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/${id}/coupons`, "POST")).toBe( + "checkout.orderform.coupons", + ); + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/${id}/profile`, "POST")).toBe( + "checkout.orderform.profile", + ); + expect( + vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/${id}/shippingData/...`, "POST"), + ).toBe("checkout.orderform.shipping"); + expect( + vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/${id}/paymentData/...`, "POST"), + ).toBe("checkout.orderform.payment"); + }); + + it("matches the singleton orderForm/{id} root with the right method", () => { + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/abc`, "GET")).toBe( + "checkout.orderform.get", + ); + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/abc`, "PATCH")).toBe( + "checkout.orderform.update", + ); + }); + + it("maps simulation, regions, postal-code", () => { + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForms/simulation`, "POST")).toBe( + "checkout.simulation", + ); + expect(vtexOperationRouter(`${acct}/api/checkout/pub/regions`, "GET")).toBe( + "checkout.regions", + ); + expect(vtexOperationRouter(`${acct}/api/checkout/pub/postal-code/BRA/01310`, "GET")).toBe( + "checkout.postal-code", + ); + }); + }); + + describe("Sessions + segments", () => { + it("differentiates sessions GET vs POST", () => { + expect(vtexOperationRouter(`${acct}/api/sessions`, "GET")).toBe("sessions.get"); + expect(vtexOperationRouter(`${acct}/api/sessions`, "POST")).toBe("sessions.update"); + }); + + it("matches segments", () => { + expect(vtexOperationRouter(`${acct}/api/segments/abc-123`, "GET")).toBe("segments.get"); + }); + }); + + describe("Catalog System", () => { + it("captures the most-specific catalog endpoints first", () => { + expect( + vtexOperationRouter(`${acct}/api/catalog_system/pub/portal/pagetype/eletronicos`, "GET"), + ).toBe("catalog.pagetype"); + expect( + vtexOperationRouter( + `${acct}/api/catalog_system/pub/products/crossselling/whoboughtalsobought/123`, + "GET", + ), + ).toBe("catalog.crossselling.whoboughtalsobought"); + expect( + vtexOperationRouter(`${acct}/api/catalog_system/pub/products/variations/123`, "GET"), + ).toBe("catalog.products.variations"); + expect( + vtexOperationRouter(`${acct}/api/catalog_system/pub/products/search/?fq=x`, "GET"), + ).toBe("catalog.products.search"); + expect(vtexOperationRouter(`${acct}/api/catalog_system/pub/facets/search/x`, "GET")).toBe( + "catalog.facets.search", + ); + expect(vtexOperationRouter(`${acct}/api/catalog_system/pub/category/tree/3`, "GET")).toBe( + "catalog.category.tree", + ); + expect( + vtexOperationRouter(`${acct}/api/catalog_system/pvt/sku/stockkeepingunitbyid/123`, "GET"), + ).toBe("catalog.sku"); + }); + + it("falls back to catalog.other for unrecognized catalog paths", () => { + expect( + vtexOperationRouter(`${acct}/api/catalog_system/pvt/specification/groupGet/123`, "GET"), + ).toBe("catalog.specification"); + expect(vtexOperationRouter(`${acct}/api/catalog_system/pub/brand/list`, "GET")).toBe( + "catalog.brand", + ); + }); + }); + + describe("Masterdata", () => { + it("encodes the entity name as the operation suffix", () => { + expect(vtexOperationRouter(`${acct}/api/dataentities/AD/search`, "GET")).toBe( + "masterdata.AD", + ); + expect(vtexOperationRouter(`${acct}/api/dataentities/wishlist_lists/documents`, "POST")).toBe( + "masterdata.wishlist_lists", + ); + }); + }); + + describe("OMS", () => { + it("differentiates orders list vs cancel", () => { + expect(vtexOperationRouter(`${acct}/api/oms/user/orders`, "GET")).toBe("oms.orders"); + expect(vtexOperationRouter(`${acct}/api/oms/user/orders/v999-01/cancel`, "POST")).toBe( + "oms.orders.cancel", + ); + expect(vtexOperationRouter(`${acct}/api/oms/pvt/orders/v999-01`, "GET")).toBe( + "oms.orders.pvt", + ); + }); + }); + + describe("VTEX ID", () => { + it("maps the auth surface", () => { + expect(vtexOperationRouter(`${acct}/api/vtexid/pub/logout?scope=x`, "GET")).toBe( + "vtexid.logout", + ); + expect(vtexOperationRouter(`${acct}/api/vtexid/pub/authentication/start`, "GET")).toBe( + "vtexid.authentication.start", + ); + expect( + vtexOperationRouter(`${acct}/api/vtexid/pub/authentication/classic/validate`, "POST"), + ).toBe("vtexid.authentication.validate"); + expect(vtexOperationRouter(`${acct}/api/vtexid/pub/authenticated/user`, "GET")).toBe( + "vtexid.user", + ); + }); + + it("falls back to vtexid.other for unmapped vtexid paths", () => { + expect(vtexOperationRouter(`${acct}/api/vtexid/pub/refreshtoken`, "POST")).toBe( + "vtexid.other", + ); + }); + }); + + describe("VTEX IO + GraphQL", () => { + it("matches the IO private graphql endpoint", () => { + expect(vtexOperationRouter(`https://store.myvtex.com/_v/private/graphql/v1`, "POST")).toBe( + "io.graphql", + ); + }); + + it("matches the IO segment endpoint", () => { + expect( + vtexOperationRouter(`https://store.myvtex.com/_v/segment/admin-pvt/whatever`, "GET"), + ).toBe("io.segment"); + }); + }); + + describe("Edge cases", () => { + it("returns undefined for fully unrecognized URLs", () => { + expect(vtexOperationRouter(`${acct}/somethingelse/random`, "GET")).toBeUndefined(); + }); + + it("does not throw on unparseable URLs and still tries to match", () => { + expect(vtexOperationRouter("not-a-real-url", "GET")).toBeUndefined(); + expect(vtexOperationRouter("/api/sessions?x=1", "GET")).toBe("sessions.get"); + }); + + it("is case-insensitive on the method", () => { + expect(vtexOperationRouter(`${acct}/api/sessions`, "post")).toBe("sessions.update"); + expect(vtexOperationRouter(`${acct}/api/sessions`, "Get")).toBe("sessions.get"); + }); + + it("recognizes sitemap.xml + sitemap-products-0.xml", () => { + expect(vtexOperationRouter(`${acct}/sitemap.xml`, "GET")).toBe("sitemap"); + expect(vtexOperationRouter(`${acct}/sitemap-products-0.xml`, "GET")).toBe("sitemap"); + }); + }); +}); diff --git a/packages/apps-vtex/src/utils/__tests__/resourceRange.test.ts b/packages/apps-vtex/src/utils/__tests__/resourceRange.test.ts new file mode 100644 index 0000000..ce0ac13 --- /dev/null +++ b/packages/apps-vtex/src/utils/__tests__/resourceRange.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { resourceRange } from "../resourceRange"; + +describe("resourceRange", () => { + it("returns from=0 and to=take when skip is 0", () => { + expect(resourceRange(0, 50)).toEqual({ from: 0, to: 50 }); + }); + + it("caps take at 100", () => { + expect(resourceRange(0, 200)).toEqual({ from: 0, to: 100 }); + }); + + it("applies skip offset", () => { + expect(resourceRange(10, 50)).toEqual({ from: 10, to: 60 }); + }); + + it("handles skip + take > 100 by capping take", () => { + expect(resourceRange(50, 200)).toEqual({ from: 50, to: 150 }); + }); + + it("treats negative skip as 0", () => { + expect(resourceRange(-5, 10)).toEqual({ from: 0, to: 10 }); + }); + + it("handles zero take", () => { + expect(resourceRange(0, 0)).toEqual({ from: 0, to: 0 }); + }); + + it("handles take of exactly 100", () => { + expect(resourceRange(0, 100)).toEqual({ from: 0, to: 100 }); + }); +}); diff --git a/packages/apps-vtex/src/utils/__tests__/sitemap.test.ts b/packages/apps-vtex/src/utils/__tests__/sitemap.test.ts new file mode 100644 index 0000000..3d5be61 --- /dev/null +++ b/packages/apps-vtex/src/utils/__tests__/sitemap.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { configureVtex } from "../../client"; +import { createVtexSitemapProxy, isVtexSitemapPath } from "../sitemap"; + +const ACCOUNT = "myaccount"; +const VTEX_HOST = `${ACCOUNT}.vtexcommercestable.com.br`; + +beforeEach(() => { + configureVtex({ account: ACCOUNT }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("isVtexSitemapPath", () => { + it.each([ + ["/sitemap.xml", true], + ["/sitemap/products-1.xml", true], + ["/sitemap/category-3.xml", true], + ["/sitemap/", true], + ["/sitemap", false], + ["/", false], + ["/checkout", false], + ["/sitemap-busca.xml", false], + ])("%s → %s", (pathname, expected) => { + expect(isVtexSitemapPath(pathname)).toBe(expected); + }); +}); + +describe("createVtexSitemapProxy", () => { + function makeFetch( + responseBody: string, + init: { status?: number; ok?: boolean } = {}, + ): typeof fetch { + const status = init.status ?? 200; + return vi.fn( + async () => + new Response(responseBody, { + status, + headers: { "content-type": "application/xml" }, + }), + ) as unknown as typeof fetch; + } + + const SITEMAP_INDEX = ` + + https://${VTEX_HOST}/sitemap/products-1.xml + https://${VTEX_HOST}/sitemap/category-1.xml +`; + + const PRODUCT_SUB_SITEMAP = ` + + https://${VTEX_HOST}/p/some-product/p +`; + + it("returns null for non-sitemap paths", async () => { + const proxy = createVtexSitemapProxy({ fetchImpl: makeFetch("") }); + const url = new URL("https://www.mystore.com/checkout"); + await expect(proxy(new Request(url), url)).resolves.toBeNull(); + }); + + it("proxies /sitemap.xml and rewrites VTEX hostname to storefront origin", async () => { + const fetchImpl = makeFetch(SITEMAP_INDEX); + const proxy = createVtexSitemapProxy({ fetchImpl }); + const url = new URL("https://www.mystore.com/sitemap.xml"); + + const res = await proxy(new Request(url), url); + + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + expect(res!.headers.get("content-type")).toBe("application/xml; charset=utf-8"); + expect(fetchImpl).toHaveBeenCalledWith(`https://${VTEX_HOST}/sitemap.xml`); + + const xml = await res!.text(); + expect(xml).not.toContain(VTEX_HOST); + expect(xml).toContain("https://www.mystore.com/sitemap/products-1.xml"); + expect(xml).toContain("https://www.mystore.com/sitemap/category-1.xml"); + }); + + it("proxies /sitemap/* sub-sitemaps with hostname rewrite", async () => { + const fetchImpl = makeFetch(PRODUCT_SUB_SITEMAP); + const proxy = createVtexSitemapProxy({ fetchImpl }); + const url = new URL("https://www.mystore.com/sitemap/products-1.xml"); + + const res = await proxy(new Request(url), url); + + expect(res).not.toBeNull(); + expect(fetchImpl).toHaveBeenCalledWith(`https://${VTEX_HOST}/sitemap/products-1.xml`); + const xml = await res!.text(); + expect(xml).toContain("https://www.mystore.com/p/some-product/p"); + expect(xml).not.toContain(VTEX_HOST); + }); + + it("injects extraSitemaps entries into /sitemap.xml only", async () => { + const fetchImpl = makeFetch(SITEMAP_INDEX); + const proxy = createVtexSitemapProxy({ + fetchImpl, + extraSitemaps: ["/sitemap-busca.xml", "extra-bare", "https://cdn.example.com/static.xml"], + }); + const url = new URL("https://www.mystore.com/sitemap.xml"); + const xml = await (await proxy(new Request(url), url))!.text(); + + expect(xml).toContain("https://www.mystore.com/sitemap-busca.xml"); + expect(xml).toContain("https://www.mystore.com/extra-bare"); + expect(xml).toContain("https://cdn.example.com/static.xml"); + // Extra entries are inserted before the closing tag (i.e. inside the index). + expect(xml.indexOf("sitemap-busca.xml")).toBeLessThan(xml.indexOf("")); + }); + + it("does not inject extraSitemaps into sub-sitemaps", async () => { + const fetchImpl = makeFetch(PRODUCT_SUB_SITEMAP); + const proxy = createVtexSitemapProxy({ + fetchImpl, + extraSitemaps: ["/sitemap-busca.xml"], + }); + const url = new URL("https://www.mystore.com/sitemap/products-1.xml"); + const xml = await (await proxy(new Request(url), url))!.text(); + + expect(xml).not.toContain("sitemap-busca.xml"); + }); + + it("returns 502 when VTEX origin returns non-OK", async () => { + const fetchImpl = makeFetch("upstream is down", { status: 503 }); + const proxy = createVtexSitemapProxy({ fetchImpl }); + const url = new URL("https://www.mystore.com/sitemap.xml"); + const res = await proxy(new Request(url), url); + expect(res!.status).toBe(502); + }); + + it("returns 502 when fetch throws", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("network down"); + }) as unknown as typeof fetch; + const proxy = createVtexSitemapProxy({ fetchImpl }); + const url = new URL("https://www.mystore.com/sitemap.xml"); + const res = await proxy(new Request(url), url); + expect(res!.status).toBe(502); + }); + + it("uses the configured environment", async () => { + const fetchImpl = makeFetch(SITEMAP_INDEX); + const proxy = createVtexSitemapProxy({ + fetchImpl, + environment: "vtexcommercebeta", + }); + const url = new URL("https://www.mystore.com/sitemap.xml"); + await proxy(new Request(url), url); + + expect(fetchImpl).toHaveBeenCalledWith( + `https://${ACCOUNT}.vtexcommercebeta.com.br/sitemap.xml`, + ); + }); + + it("honors a custom Cache-Control header", async () => { + const fetchImpl = makeFetch(SITEMAP_INDEX); + const proxy = createVtexSitemapProxy({ + fetchImpl, + cacheControl: "public, max-age=60", + }); + const url = new URL("https://www.mystore.com/sitemap.xml"); + const res = await proxy(new Request(url), url); + expect(res!.headers.get("cache-control")).toBe("public, max-age=60"); + }); + + it("emits the default Cache-Control by default", async () => { + const fetchImpl = makeFetch(SITEMAP_INDEX); + const proxy = createVtexSitemapProxy({ fetchImpl }); + const url = new URL("https://www.mystore.com/sitemap.xml"); + const res = await proxy(new Request(url), url); + expect(res!.headers.get("cache-control")).toBe( + "public, s-maxage=3600, stale-while-revalidate=86400", + ); + }); + + it("respects non-default VTEX domain (e.g. .com)", async () => { + configureVtex({ account: ACCOUNT, domain: "com" }); + const fetchImpl = makeFetch(SITEMAP_INDEX); + const proxy = createVtexSitemapProxy({ fetchImpl }); + const url = new URL("https://www.mystore.com/sitemap.xml"); + await proxy(new Request(url), url); + + expect(fetchImpl).toHaveBeenCalledWith(`https://${ACCOUNT}.vtexcommercestable.com/sitemap.xml`); + }); +}); diff --git a/packages/apps-vtex/src/utils/__tests__/slugify.test.ts b/packages/apps-vtex/src/utils/__tests__/slugify.test.ts new file mode 100644 index 0000000..ba020fe --- /dev/null +++ b/packages/apps-vtex/src/utils/__tests__/slugify.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { slugify } from "../slugify"; + +describe("slugify", () => { + it("lowercases and removes spaces", () => { + expect(slugify("Hello World")).toBe("hello-world"); + }); + + it("replaces accented characters", () => { + expect(slugify("Calçados")).toBe("calcados"); + expect(slugify("São Paulo")).toBe("sao-paulo"); + expect(slugify("Café")).toBe("cafe"); + }); + + it("replaces special characters with hyphens", () => { + expect(slugify("shoes/running")).toBe("shoes-running"); + expect(slugify("men's wear")).toBe("men-s-wear"); + }); + + it("removes commas", () => { + expect(slugify("shoes,sandals")).toBe("shoessandals"); + }); + + it("handles already slugified strings", () => { + expect(slugify("already-slugified")).toBe("already-slugified"); + }); + + it("handles empty string", () => { + expect(slugify("")).toBe(""); + }); +}); diff --git a/packages/apps-vtex/src/utils/__tests__/transform.test.ts b/packages/apps-vtex/src/utils/__tests__/transform.test.ts new file mode 100644 index 0000000..1e0f2e1 --- /dev/null +++ b/packages/apps-vtex/src/utils/__tests__/transform.test.ts @@ -0,0 +1,698 @@ +import { describe, expect, it } from "vitest"; +import type { Offer, Product } from "@decocms/apps-commerce/types"; +import { + aggregateOffers, + bestOfferFirst, + categoryTreeToNavbar, + filtersFromURL, + filtersToSearchParams, + forceHttpsOnAssets, + inStock, + legacyFacetsFromURL, + legacyFacetsNormalize, + mergeFacets, + normalizeFacet, + parsePageType, + pickSku, + SCHEMA_IN_STOCK, + SCHEMA_OUT_OF_STOCK, + sortProducts, + toAdditionalPropertyCategory, + toAdditionalPropertyCluster, + toAdditionalPropertyReferenceId, + toAdditionalPropertySpecification, + toBrand, + toPostalAddress, + toProductVariant, +} from "../transform"; + +const makeOffer = (price: number, availability: string): Offer => ({ + "@type": "Offer", + price, + availability: availability as Offer["availability"], + priceSpecification: [], + inventoryLevel: { value: 1 }, +}); + +describe("inStock", () => { + it("returns true when in stock", () => { + expect(inStock(makeOffer(10, SCHEMA_IN_STOCK))).toBe(true); + }); + + it("returns false when out of stock", () => { + expect(inStock(makeOffer(10, SCHEMA_OUT_OF_STOCK))).toBe(false); + }); +}); + +describe("bestOfferFirst", () => { + it("sorts in-stock before out-of-stock", () => { + const inStockOffer = makeOffer(100, SCHEMA_IN_STOCK); + const outOfStockOffer = makeOffer(10, SCHEMA_OUT_OF_STOCK); + expect(bestOfferFirst(inStockOffer, outOfStockOffer)).toBe(-1); + expect(bestOfferFirst(outOfStockOffer, inStockOffer)).toBe(1); + }); + + it("sorts by price when both in stock", () => { + const cheap = makeOffer(10, SCHEMA_IN_STOCK); + const expensive = makeOffer(100, SCHEMA_IN_STOCK); + expect(bestOfferFirst(cheap, expensive)).toBeLessThan(0); + expect(bestOfferFirst(expensive, cheap)).toBeGreaterThan(0); + }); + + it("returns 0 for equal offers", () => { + const a = makeOffer(50, SCHEMA_IN_STOCK); + const b = makeOffer(50, SCHEMA_IN_STOCK); + expect(bestOfferFirst(a, b)).toBe(0); + }); +}); + +describe("aggregateOffers", () => { + it("returns undefined for empty array", () => { + expect(aggregateOffers([])).toBeUndefined(); + }); + + it("aggregates single offer", () => { + const offer = makeOffer(100, SCHEMA_IN_STOCK); + const result = aggregateOffers([offer], "BRL"); + expect(result).toEqual({ + "@type": "AggregateOffer", + priceCurrency: "BRL", + highPrice: 100, + lowPrice: 100, + offerCount: 1, + offers: [offer], + }); + }); + + it("finds low and high prices across multiple offers", () => { + const offers = [ + makeOffer(50, SCHEMA_IN_STOCK), + makeOffer(100, SCHEMA_IN_STOCK), + makeOffer(25, SCHEMA_IN_STOCK), + ]; + const result = aggregateOffers(offers, "BRL"); + expect(result?.lowPrice).toBe(25); + expect(result?.highPrice).toBe(100); + expect(result?.offerCount).toBe(3); + }); + + it("ignores out-of-stock offers for high price", () => { + const offers = [makeOffer(25, SCHEMA_IN_STOCK), makeOffer(200, SCHEMA_OUT_OF_STOCK)]; + const result = aggregateOffers(offers, "BRL"); + expect(result?.highPrice).toBe(25); + }); +}); + +describe("pickSku", () => { + const makeProduct = ( + items: Array<{ + itemId: string; + sellers: Array<{ commertialOffer: { AvailableQuantity: number } }>; + }>, + ) => ({ items, origin: "intelligent-search" }) as any; + + it("returns specified SKU", () => { + const product = makeProduct([ + { itemId: "1", sellers: [{ commertialOffer: { AvailableQuantity: 0 } }] }, + { itemId: "2", sellers: [{ commertialOffer: { AvailableQuantity: 5 } }] }, + ]); + expect(pickSku(product, "2").itemId).toBe("2"); + }); + + it("returns first available SKU when no ID specified", () => { + const product = makeProduct([ + { itemId: "1", sellers: [{ commertialOffer: { AvailableQuantity: 0 } }] }, + { itemId: "2", sellers: [{ commertialOffer: { AvailableQuantity: 5 } }] }, + ]); + expect(pickSku(product).itemId).toBe("2"); + }); + + it("falls back to first SKU when none available", () => { + const product = makeProduct([ + { itemId: "1", sellers: [{ commertialOffer: { AvailableQuantity: 0 } }] }, + { itemId: "2", sellers: [{ commertialOffer: { AvailableQuantity: 0 } }] }, + ]); + expect(pickSku(product).itemId).toBe("1"); + }); + + it("falls back to first SKU when specified ID not found", () => { + const product = makeProduct([ + { itemId: "1", sellers: [{ commertialOffer: { AvailableQuantity: 5 } }] }, + ]); + expect(pickSku(product, "999").itemId).toBe("1"); + }); +}); + +describe("toAdditionalPropertyCategory", () => { + it("creates category property value", () => { + const result = toAdditionalPropertyCategory({ propertyID: "123", value: "Shoes" }); + expect(result).toEqual({ + "@type": "PropertyValue", + name: "category", + propertyID: "123", + value: "Shoes", + }); + }); +}); + +describe("toAdditionalPropertyCluster", () => { + it("creates cluster property value", () => { + const result = toAdditionalPropertyCluster({ propertyID: "456", value: "Sale" }); + expect(result).toEqual({ + "@type": "PropertyValue", + name: "cluster", + propertyID: "456", + value: "Sale", + description: undefined, + }); + }); + + it("marks as highlight when in set", () => { + const highlights = new Set(["456"]); + const result = toAdditionalPropertyCluster({ propertyID: "456", value: "Sale" }, highlights); + expect(result.description).toBe("highlight"); + }); + + it("does not mark as highlight when not in set", () => { + const highlights = new Set(["789"]); + const result = toAdditionalPropertyCluster({ propertyID: "456", value: "Sale" }, highlights); + expect(result.description).toBeUndefined(); + }); +}); + +describe("toAdditionalPropertyReferenceId", () => { + it("creates reference ID property value", () => { + const result = toAdditionalPropertyReferenceId({ name: "RefId", value: "ABC123" }); + expect(result).toEqual({ + "@type": "PropertyValue", + name: "RefId", + value: "ABC123", + valueReference: "ReferenceID", + }); + }); +}); + +describe("toAdditionalPropertySpecification", () => { + it("creates specification property value", () => { + const result = toAdditionalPropertySpecification({ name: "Color", value: "Red" }); + expect(result).toEqual({ + "@type": "PropertyValue", + name: "Color", + value: "Red", + propertyID: undefined, + valueReference: "SPECIFICATION", + }); + }); + + it("includes propertyID when provided", () => { + const result = toAdditionalPropertySpecification({ + name: "Color", + value: "Red", + propertyID: "group1", + }); + expect(result.propertyID).toBe("group1"); + }); +}); + +describe("filtersToSearchParams", () => { + it("converts facets to search params", () => { + const facets = [ + { key: "category", value: "shoes" }, + { key: "brand", value: "nike" }, + ]; + const params = filtersToSearchParams(facets); + expect(params.get("filter.category")).toBe("shoes"); + expect(params.get("filter.brand")).toBe("nike"); + }); + + it("preserves existing params", () => { + const existing = new URLSearchParams("page=1"); + const params = filtersToSearchParams([{ key: "brand", value: "nike" }], existing); + expect(params.get("page")).toBe("1"); + expect(params.get("filter.brand")).toBe("nike"); + }); +}); + +describe("legacyFacetsNormalize", () => { + it("normalizes legacy price format", () => { + const result = legacyFacetsNormalize("priceFrom", "de-34,90-a-56,90"); + expect(result).toEqual({ key: "price", value: "34.90:56.90" }); + }); + + it("maps legacy key names", () => { + const result = legacyFacetsNormalize("productClusterSearchableIds", "123"); + expect(result).toEqual({ key: "productClusterIds", value: "123" }); + }); + + it("passes through unknown keys", () => { + const result = legacyFacetsNormalize("brand", "nike"); + expect(result).toEqual({ key: "brand", value: "nike" }); + }); +}); + +describe("legacyFacetsFromURL", () => { + it("extracts facets from URL with map param", () => { + const url = new URL("https://example.com/shoes/nike?map=c,brand"); + const result = legacyFacetsFromURL(url); + expect(result).toEqual([ + { key: "c", value: "shoes" }, + { key: "brand", value: "nike" }, + ]); + }); + + it("returns empty array when no map param", () => { + const url = new URL("https://example.com/shoes"); + const result = legacyFacetsFromURL(url); + expect(result).toEqual([]); + }); + + it("handles mismatched lengths", () => { + const url = new URL("https://example.com/shoes?map=c,brand,extra"); + const result = legacyFacetsFromURL(url); + expect(result).toHaveLength(1); + }); +}); + +describe("filtersFromURL", () => { + it("extracts both legacy and filter params", () => { + const url = new URL("https://example.com/shoes?map=c&filter.brand=nike"); + const result = filtersFromURL(url); + expect(result).toEqual([ + { key: "c", value: "shoes" }, + { key: "brand", value: "nike" }, + ]); + }); + + it("extracts only filter params when no map", () => { + const url = new URL("https://example.com/?filter.brand=nike&filter.category=shoes"); + const result = filtersFromURL(url); + expect(result).toEqual([ + { key: "brand", value: "nike" }, + { key: "category", value: "shoes" }, + ]); + }); +}); + +describe("mergeFacets", () => { + it("merges two facet arrays", () => { + const f1 = [{ key: "brand", value: "nike" }]; + const f2 = [{ key: "category", value: "shoes" }]; + const result = mergeFacets(f1, f2); + expect(result).toHaveLength(2); + }); + + it("deduplicates facets", () => { + const f1 = [{ key: "brand", value: "nike" }]; + const f2 = [{ key: "brand", value: "nike" }]; + const result = mergeFacets(f1, f2); + expect(result).toHaveLength(1); + }); + + it("keeps both when same key different value", () => { + const f1 = [{ key: "brand", value: "nike" }]; + const f2 = [{ key: "brand", value: "adidas" }]; + const result = mergeFacets(f1, f2); + expect(result).toHaveLength(2); + }); +}); + +describe("categoryTreeToNavbar", () => { + it("transforms tree to navbar elements", () => { + const tree = [ + { + id: 1, + name: "Shoes", + hasChildren: true, + url: "https://example.com/shoes", + children: [ + { + id: 2, + name: "Running", + hasChildren: false, + url: "https://example.com/shoes/running", + children: [], + }, + ], + }, + ]; + const result = categoryTreeToNavbar(tree); + expect(result).toEqual([ + { + "@type": "SiteNavigationElement", + url: "/shoes", + name: "Shoes", + children: [ + { + "@type": "SiteNavigationElement", + url: "/shoes/running", + name: "Running", + children: [], + }, + ], + }, + ]); + }); + + it("returns empty array for empty tree", () => { + expect(categoryTreeToNavbar([])).toEqual([]); + }); +}); + +describe("toBrand", () => { + it("transforms VTEX brand", () => { + const brand = { + id: 1, + name: "Nike", + imageUrl: "/brands/nike.png", + metaTagDescription: "Nike brand", + }; + const result = toBrand(brand as any, "https://example.com"); + expect(result).toEqual({ + "@type": "Brand", + "@id": "1", + name: "Nike", + logo: "https://example.com/brands/nike.png", + description: "Nike brand", + }); + }); + + it("keeps absolute URLs as-is", () => { + const brand = { + id: 1, + name: "Nike", + imageUrl: "https://cdn.example.com/nike.png", + metaTagDescription: "", + }; + const result = toBrand(brand as any, "https://example.com"); + expect(result.logo).toBe("https://cdn.example.com/nike.png"); + }); +}); + +describe("normalizeFacet", () => { + it("sets Map to priceFrom and Value to Slug", () => { + const facet = { Map: "c", Value: "shoes", Slug: "de-10-a-50", Name: "Price", Quantity: 5 }; + const result = normalizeFacet(facet as any); + expect(result.Map).toBe("priceFrom"); + expect(result.Value).toBe("de-10-a-50"); + }); +}); + +describe("sortProducts", () => { + it("sorts products by specified order", () => { + const products = [ + { "@type": "Product", sku: "3" }, + { "@type": "Product", sku: "1" }, + { "@type": "Product", sku: "2" }, + ] as unknown as Product[]; + const result = sortProducts(products, ["1", "2", "3"], "sku"); + expect(result.map((p) => p.sku)).toEqual(["1", "2", "3"]); + }); + + it("returns undefined for missing IDs", () => { + const products = [{ "@type": "Product", sku: "1" }] as unknown as Product[]; + const result = sortProducts(products, ["1", "999"], "sku"); + expect(result[0].sku).toBe("1"); + expect(result[1]).toBeUndefined(); + }); +}); + +describe("parsePageType", () => { + it("maps FullText to Search", () => { + expect(parsePageType({ pageType: "FullText" } as any)).toBe("Search"); + }); + + it("maps NotFound to Unknown", () => { + expect(parsePageType({ pageType: "NotFound" } as any)).toBe("Unknown"); + }); + + it("passes through other types", () => { + expect(parsePageType({ pageType: "Department" } as any)).toBe("Department"); + expect(parsePageType({ pageType: "Brand" } as any)).toBe("Brand"); + }); +}); + +describe("forceHttpsOnAssets", () => { + it("converts http to https on item images", () => { + const orderForm = { + items: [ + { imageUrl: "http://example.com/img.jpg" }, + { imageUrl: "https://example.com/img2.jpg" }, + ], + }; + const result = forceHttpsOnAssets(orderForm as any); + expect(result.items[0].imageUrl).toBe("https://example.com/img.jpg"); + expect(result.items[1].imageUrl).toBe("https://example.com/img2.jpg"); + }); + + it("handles items without imageUrl", () => { + const orderForm = { items: [{ imageUrl: undefined }] }; + const result = forceHttpsOnAssets(orderForm as any); + expect(result.items[0].imageUrl).toBeUndefined(); + }); +}); + +describe("toPostalAddress", () => { + it("transforms VTEX address to PostalAddress", () => { + const address = { + addressId: "addr1", + country: "BRA", + city: "São Paulo", + state: "SP", + neighborhood: "Pinheiros", + postalCode: "05422-000", + street: "Rua dos Pinheiros", + number: "123", + addressName: "Home", + receiverName: "John Doe", + complement: "Apt 1", + reference: "Near the park", + geoCoordinates: [-23.5668, -46.6901], + }; + const result = toPostalAddress(address as any); + expect(result["@type"]).toBe("PostalAddress"); + expect(result["@id"]).toBe("addr1"); + expect(result.addressCountry).toBe("BRA"); + expect(result.addressLocality).toBe("São Paulo"); + expect(result.addressRegion).toBe("SP"); + expect(result.postalCode).toBe("05422-000"); + expect(result.streetAddress).toBe("Rua dos Pinheiros"); + expect(result.identifier).toBe("123"); + expect(result.name).toBe("Home"); + expect(result.alternateName).toBe("John Doe"); + }); + + it("returns undefined for empty optional fields", () => { + const address = { + addressId: "addr1", + country: "BRA", + city: "SP", + state: "SP", + postalCode: "05422-000", + street: "Rua X", + }; + const result = toPostalAddress(address as any); + expect(result.areaServed).toBeUndefined(); + expect(result.identifier).toBeUndefined(); + expect(result.name).toBeUndefined(); + }); +}); + +describe("toProductVariant", () => { + const makeISProduct = (overrides: Record = {}) => + ({ + origin: "intelligent-search", + productId: "PROD1", + productName: "Test Product", + brand: "TestBrand", + brandId: 1, + brandImageUrl: null, + productReference: "REF1", + description: "Full description HTML", + releaseDate: "2024-01-01", + linkText: "test-product", + categories: ["/Electronics/TVs/"], + categoriesIds: ["/1/2/"], + categoryId: "2", + productClusters: { "100": "Sale" }, + clusterHighlights: {}, + items: [], + ...overrides, + }) as any; + + const makeISSku = (overrides: Record = {}) => + ({ + itemId: "SKU1", + name: "Test SKU", + ean: "1234567890123", + referenceId: [{ Key: "RefId", Value: "REF-SKU1" }], + images: [ + { imageUrl: "https://img.com/1.jpg", imageText: "Front", imageLabel: "front" }, + { imageUrl: "https://img.com/2.jpg", imageText: "Back", imageLabel: "back" }, + { imageUrl: "https://img.com/3.jpg", imageText: "Side", imageLabel: "side" }, + ], + videos: ["https://video.com/1.mp4"], + sellers: [ + { + sellerId: "1", + sellerName: "Seller One", + commertialOffer: { + AvailableQuantity: 10, + Price: 99.9, + ListPrice: 129.9, + spotPrice: 89.9, + PriceValidUntil: "2025-12-31", + Installments: [ + { + Value: 33.3, + NumberOfInstallments: 3, + Name: "Visa", + InterestRate: 0, + TotalValuePlusInterestRate: 99.9, + PaymentSystemName: "Visa", + }, + ], + GiftSkuIds: [], + teasers: [], + }, + }, + ], + variations: [ + { name: "Cor", values: ["Preto"] }, + { name: "Voltagem", values: ["220V"] }, + { name: "Tamanho", values: ["G"] }, + ], + kitItems: [], + complementName: "Complement", + estimatedDateArrival: null, + modalType: null, + ...overrides, + }) as any; + + const baseOptions = { + baseUrl: "https://example.com", + priceCurrency: "BRL", + }; + + it("returns minimal product shape", () => { + const product = makeISProduct({ items: [makeISSku()] }); + const sku = makeISSku(); + const result = toProductVariant(product, sku, baseOptions); + + expect(result["@type"]).toBe("Product"); + expect(result.productID).toBe("SKU1"); + expect(result.sku).toBe("SKU1"); + expect(result.name).toBe("Test SKU"); + expect(result.url).toContain("/test-product/p"); + expect(result.inProductGroupWithID).toBe("PROD1"); + }); + + it("drops description, video, brand, gtin, releaseDate, isVariantOf", () => { + const product = makeISProduct({ items: [makeISSku()] }); + const sku = makeISSku(); + const result = toProductVariant(product, sku, baseOptions); + + expect(result.video).toBeUndefined(); + expect(result.description).toBeUndefined(); + expect(result.brand).toBeUndefined(); + expect(result.gtin).toBeUndefined(); + expect(result.releaseDate).toBeUndefined(); + expect(result.alternateName).toBeUndefined(); + expect(result.isVariantOf).toBeUndefined(); + expect(result.isAccessoryOrSparePartFor).toBeUndefined(); + expect(result.category).toBeUndefined(); + }); + + it("includes image[0] by default — selectors render thumbnails from it", () => { + const product = makeISProduct({ items: [makeISSku()] }); + const sku = makeISSku(); + const result = toProductVariant(product, sku, baseOptions); + + expect(result.image).toHaveLength(1); + expect(result.image?.[0]).toMatchObject({ + "@type": "ImageObject", + url: "https://img.com/1.jpg", + encodingFormat: "image", + }); + }); + + it("includes real inventoryLevel by default — selectors gate stock state on it", () => { + const product = makeISProduct({ items: [makeISSku()] }); + const sku = makeISSku(); + const result = toProductVariant(product, sku, baseOptions); + + const offer = result.offers!.offers[0]; + expect(offer.inventoryLevel?.value).toBe(10); + }); + + it("drops image when variantIncludeImage: false", () => { + const product = makeISProduct({ items: [makeISSku()] }); + const sku = makeISSku(); + const result = toProductVariant(product, sku, { + ...baseOptions, + variantIncludeImage: false, + }); + + expect(result.image).toBeUndefined(); + }); + + it("zeros inventoryLevel when variantIncludeInventory: false (legacy lean behavior)", () => { + const product = makeISProduct({ items: [makeISSku()] }); + const sku = makeISSku(); + const result = toProductVariant(product, sku, { + ...baseOptions, + variantIncludeInventory: false, + }); + + const offer = result.offers!.offers[0]; + expect(offer.inventoryLevel?.value).toBe(0); + }); + + it("filters additionalProperty to variant-differentiating names only", () => { + const product = makeISProduct({ items: [makeISSku()] }); + const sku = makeISSku(); + const result = toProductVariant(product, sku, baseOptions); + + const propNames = result.additionalProperty?.map((p) => p.name) ?? []; + // Should only contain Cor, Voltagem, Tamanho (from VARIANT_PROPERTY_NAMES) + for (const name of propNames) { + expect(["Cor", "Voltagem", "Tamanho"]).toContain(name); + } + expect(propNames.length).toBeGreaterThan(0); + }); + + it("respects custom variantPropertyNames", () => { + const product = makeISProduct({ items: [makeISSku()] }); + const sku = makeISSku(); + const result = toProductVariant(product, sku, { + ...baseOptions, + variantPropertyNames: new Set(["Cor"]), + }); + + const propNames = result.additionalProperty?.map((p) => p.name) ?? []; + expect(propNames).toEqual(["Cor"]); + }); + + it("produces lean offers with availability but no priceSpecification details", () => { + const product = makeISProduct({ items: [makeISSku()] }); + const sku = makeISSku(); + const result = toProductVariant(product, sku, baseOptions); + + expect(result.offers).toBeDefined(); + expect(result.offers?.offers).toHaveLength(1); + + const offer = result.offers!.offers[0]; + expect(offer.availability).toBe("https://schema.org/InStock"); + expect(offer.seller).toBe("1"); + expect(offer.priceSpecification).toEqual([]); + }); + + it("handles SKU with no sellers", () => { + const product = makeISProduct({ items: [makeISSku({ sellers: [] })] }); + const sku = makeISSku({ sellers: [] }); + const result = toProductVariant(product, sku, baseOptions); + + // Should still return a valid product, offers may be undefined (no sellers) + expect(result["@type"]).toBe("Product"); + expect(result.productID).toBe("SKU1"); + }); +}); diff --git a/packages/apps-vtex/src/utils/accountLoaders.ts b/packages/apps-vtex/src/utils/accountLoaders.ts new file mode 100644 index 0000000..520e7b7 --- /dev/null +++ b/packages/apps-vtex/src/utils/accountLoaders.ts @@ -0,0 +1,203 @@ +/** + * Pre-built section loaders for VTEX account pages. + * + * Every VTEX site with account pages repeats the same pattern: + * 1. Extract VTEX cookies from request + * 2. Call the VTEX user/profile/address/payment API + * 3. Return enriched props with { device, logged, ...data } + * 4. Catch errors gracefully (return logged: false) + * + * These factories encapsulate that boilerplate. + * + * @example + * ```ts + * import { vtexAccountLoaders } from "@decocms/apps/vtex/utils/accountLoaders"; + * + * registerSectionLoaders({ + * "site/sections/Account/PersonalData.tsx": vtexAccountLoaders.personalData(), + * "site/sections/Account/MyOrders.tsx": vtexAccountLoaders.orders(), + * "site/sections/Account/Cards.tsx": vtexAccountLoaders.cards(), + * "site/sections/Account/Addresses.tsx": vtexAccountLoaders.addresses(), + * "site/sections/Account/Auth.tsx": vtexAccountLoaders.authentication(), + * "site/sections/Account/Other.tsx": vtexAccountLoaders.loggedIn(), + * }); + * ``` + */ + +import { detectDevice } from "@decocms/blocks/sdk/useDevice"; +import { getUserAddresses, type VtexAddress } from "../loaders/address"; +import { getUserPayments, type Payment } from "../loaders/payment"; +import { getCurrentProfile, type Profile } from "../loaders/profile"; +import { getUser } from "../loaders/user"; +import { getVtexCookies } from "./cookies"; + +type Device = "mobile" | "tablet" | "desktop"; + +type SectionLoaderFn = ( + props: Record, + req: Request, +) => Promise> | Record; + +function getDevice(req: Request): Device { + return detectDevice(req.headers.get("user-agent") ?? ""); +} + +// --------------------------------------------------------------------------- +// personalData — fetches full VTEX profile for personal data sections +// --------------------------------------------------------------------------- + +export interface PersonalDataOptions { + /** Extra custom profile fields to request beyond the standard set. */ + extraProfileFields?: string[]; + + /** + * Transform the raw VTEX Profile into the shape your component expects. + * When omitted, the raw Profile object is returned as `profile`. + * + * @example + * ```ts + * vtexAccountLoaders.personalData({ + * mapProfile: (p) => ({ + * "@id": p.userId ?? p.id, + * email: p.email, + * givenName: p.firstName ?? null, + * familyName: p.lastName ?? null, + * taxID: p.document, + * }), + * }) + * ``` + */ + mapProfile?: (profile: Profile) => Record; +} + +function personalData(options?: PersonalDataOptions): SectionLoaderFn { + const { extraProfileFields, mapProfile } = options ?? {}; + return async (props, req) => { + const cookie = getVtexCookies(req); + try { + const profile = await getCurrentProfile(cookie, extraProfileFields); + const data = mapProfile ? mapProfile(profile) : profile; + return { + ...props, + device: getDevice(req), + logged: !!profile, + loading: false, + userData: data, + }; + } catch (error) { + console.error("[accountLoaders.personalData]", error); + return { ...props, device: getDevice(req), logged: false, loading: false, userData: null }; + } + }; +} + +// --------------------------------------------------------------------------- +// orders — checks login status for order listing sections +// --------------------------------------------------------------------------- + +function orders(): SectionLoaderFn { + return async (props, req) => { + const cookie = getVtexCookies(req); + try { + const userData = await getUser(cookie); + return { ...props, device: getDevice(req), logged: !!userData }; + } catch { + return { ...props, device: getDevice(req), logged: false }; + } + }; +} + +// --------------------------------------------------------------------------- +// cards — fetches saved payment tokens for card management sections +// --------------------------------------------------------------------------- + +function cards(): SectionLoaderFn { + return async (props, req) => { + const cookie = getVtexCookies(req); + try { + const user = await getUser(cookie); + const logged = !!user; + let payments: Payment[] = []; + if (logged) { + try { + payments = (await getUserPayments(cookie)) ?? []; + } catch { + payments = []; + } + } + return { ...props, logged, payments }; + } catch { + return { ...props, logged: false, payments: [] }; + } + }; +} + +// --------------------------------------------------------------------------- +// addresses — fetches address list for address management sections +// --------------------------------------------------------------------------- + +function addresses(): SectionLoaderFn { + return async (props, req) => { + const cookie = getVtexCookies(req); + try { + const userData = await getUser(cookie); + const logged = !!userData; + let userAddressData: VtexAddress[] | null = null; + if (logged) { + try { + userAddressData = await getUserAddresses(cookie); + } catch { + userAddressData = null; + } + } + return { ...props, device: getDevice(req), logged, userAddressData }; + } catch { + return { ...props, device: getDevice(req), logged: false, userAddressData: null }; + } + }; +} + +// --------------------------------------------------------------------------- +// authentication — checks login + returns user data for auth pages +// --------------------------------------------------------------------------- + +function authentication(): SectionLoaderFn { + return async (props, req) => { + const cookie = getVtexCookies(req); + try { + const userData = await getUser(cookie); + return { ...props, device: getDevice(req), logged: !!userData, userData }; + } catch { + return { ...props, device: getDevice(req), logged: false, userData: null }; + } + }; +} + +// --------------------------------------------------------------------------- +// loggedIn — generic "is the user logged in?" loader +// --------------------------------------------------------------------------- + +function loggedIn(): SectionLoaderFn { + return async (props, req) => { + const cookie = getVtexCookies(req); + try { + const userData = await getUser(cookie); + return { ...props, device: getDevice(req), logged: !!userData }; + } catch { + return { ...props, device: getDevice(req), logged: false }; + } + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export const vtexAccountLoaders = { + personalData, + orders, + cards, + addresses, + authentication, + loggedIn, +} as const; diff --git a/packages/apps-vtex/src/utils/authHelpers.ts b/packages/apps-vtex/src/utils/authHelpers.ts new file mode 100644 index 0000000..bc6d3da --- /dev/null +++ b/packages/apps-vtex/src/utils/authHelpers.ts @@ -0,0 +1,85 @@ +/** + * VTEX auth helpers — pure functions for cookie extraction, JWT parsing, + * Set-Cookie forwarding, and logout. + * + * These are consumed by site-level createServerFn wrappers in invoke.ts. + * createServerFn itself must live in site source (not node_modules) because + * TanStack Start's Vite plugin only transforms source files. + */ +import { getVtexConfig, getVtexFetch } from "../client"; +import { extractVtexCookies } from "./cookieSanitizer"; + +const DOMAIN_RE = /;\s*domain=[^;]*/gi; + +/** + * Extract VTEX-relevant cookies from a raw Cookie header string. + * + * Strict allowlist: drops any cookie not on `VTEX_COOKIE_PREFIXES`, plus + * any cookie whose value contains non-ASCII bytes (which would otherwise + * make VTEX's janus gateway return 503 Service Unavailable). Both filters + * live in `./cookieSanitizer` — this is a thin compatibility wrapper. + */ +export function extractVtexCookiesFromHeader(raw: string): string { + return extractVtexCookies(raw); +} + +/** + * Strip Domain= from Set-Cookie headers so cookies are associated + * with the storefront domain instead of the VTEX domain. + */ +export function stripCookieDomain(cookies: string[]): string[] { + return cookies.map((c) => c.replace(DOMAIN_RE, "")); +} + +/** Standard VTEX cookies to expire on logout. */ +export const VTEX_LOGOUT_COOKIES = [ + "checkout.vtex.com=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax", + "CheckoutOrderFormOwnership=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax", + "checkout.vtex.com__orderFormId=; Path=/; Max-Age=0", + "vtex_session=; Path=/; Max-Age=0", + "vtex_segment=; Path=/; Max-Age=0", +]; + +/** + * Perform VTEX logout — calls the VTEX ID logout endpoint and returns + * the Set-Cookie headers (with domain stripped) to expire auth cookies. + */ +export async function performVtexLogout(cookies: string): Promise<{ setCookies: string[] }> { + const config = getVtexConfig(); + const domain = config.domain ?? "com.br"; + const logoutUrl = `https://${config.account}.vtexcommercestable.${domain}/api/vtexid/pub/logout?scope=${config.account}&returnUrl=/`; + + const res = await getVtexFetch()(logoutUrl, { + method: "GET", + headers: { cookie: cookies }, + redirect: "manual", + operation: "vtexid.logout", + }); + + const upstreamCookies = res.headers.getSetCookie?.() ?? []; + + return { + setCookies: [...stripCookieDomain(upstreamCookies), ...VTEX_LOGOUT_COOKIES], + }; +} + +/** + * Parse VTEX auth JWT to extract email and userId. + * Reads the VtexIdclientAutCookie_* cookie from a raw Cookie header. + */ +export function parseVtexAuthJwt(rawCookies: string): { email: string; userId: string } | null { + try { + const match = rawCookies.match(/VtexIdclientAutCookie_[^=]+=([^;]+)/); + if (!match) return null; + const token = match[1]; + const parts = token.split("."); + if (parts.length < 2) return null; + const payload = JSON.parse( + Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"), + ); + if (!payload.sub) return null; + return { email: payload.sub, userId: payload.userId ?? "" }; + } catch { + return null; + } +} diff --git a/packages/apps-vtex/src/utils/batch.ts b/packages/apps-vtex/src/utils/batch.ts new file mode 100644 index 0000000..83623bf --- /dev/null +++ b/packages/apps-vtex/src/utils/batch.ts @@ -0,0 +1,18 @@ +export const batch = (iterable: IterableIterator | T[], size: number): T[][] => { + const batches: T[][] = []; + + let current = 0; + for (const item of iterable) { + if (batches[current]?.length === size) { + current++; + } + + if (!batches[current]) { + batches[current] = []; + } + + batches[current].push(item); + } + + return batches; +}; diff --git a/packages/apps-vtex/src/utils/cookieSanitizer.ts b/packages/apps-vtex/src/utils/cookieSanitizer.ts new file mode 100644 index 0000000..b7a4a12 --- /dev/null +++ b/packages/apps-vtex/src/utils/cookieSanitizer.ts @@ -0,0 +1,173 @@ +/** + * Outbound cookie sanitization for VTEX API calls. + * + * VTEX's janus gateway strictly enforces RFC 6265: any cookie value containing + * non-ASCII bytes causes the gateway to return `503 Service Unavailable` with + * an empty body, before the request reaches the backing service. This is + * deterministic — a single poisoned cookie (e.g. an analytics tag writing a + * category name with accents into `document.cookie` without encoding) can + * break every checkout call for a user. + * + * This module provides two filters: + * + * - `sanitizeOutboundCookieHeader()` — drops cookies whose value contains + * non-ASCII bytes or that look malformed. Default for cookie forwarding + * in `vtexFetchWithCookies`. Safe to apply to any cookie payload. + * + * - `extractVtexCookies()` — allowlist mode. Drops anything that isn't on + * `VTEX_COOKIE_PREFIXES`. Use when calling endpoints that have no business + * seeing the user's full cookie soup (e.g. logout, masterdata, profile). + * + * The allowlist is the canonical list of cookie name prefixes that VTEX APIs + * actually consume. It lives here so it's the single source of truth for the + * package — both `getVtexCookies()` (cookies.ts) and + * `extractVtexCookiesFromHeader()` (authHelpers.ts) delegate to this module. + */ + +/** + * Cookie name prefixes that are VTEX-relevant and safe to forward to + * `*.vtexcommercestable.com.br` / `*.myvtex.com` / `secure.` APIs. + */ +export const VTEX_COOKIE_PREFIXES: readonly string[] = [ + "VtexIdclientAutCookie", + "checkout.vtex.com", + "CheckoutOrderFormOwnership", + "vtex_session", + "vtex_segment", + "vtex_is_", + "janus_sid", +]; + +export type DropReason = "non_ascii" | "not_in_allowlist" | "malformed"; + +export interface DroppedCookie { + name: string; + reason: DropReason; +} + +export interface CookieSanitizeResult { + /** Cookie header string ready to forward (may be empty). */ + cookies: string; + /** Cookies that were filtered out, with the reason per cookie. */ + dropped: DroppedCookie[]; +} + +export interface CookieSanitizeOptions { + /** + * When true, additionally enforces an allowlist: only cookies whose name + * starts with one of `VTEX_COOKIE_PREFIXES` are kept. + * @default false + */ + allowlist?: boolean; +} + +/** + * Cookie pairs are `name=value`, where `name` (token) must be visible ASCII + * with no separators, and `value` must be ASCII (`0x20–0x7E`) per RFC 6265. + * We intentionally allow `=` inside the value (some VTEX cookies are + * URL-encoded JWTs). + */ +const TOKEN_RE = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/; +const ASCII_VALUE_RE = /^[\x20-\x7E]*$/; + +/** + * Filter a `Cookie:` request header so it's safe to forward to a VTEX origin. + * + * Drops: + * - pairs without `=` (malformed) + * - pairs whose name isn't a valid HTTP token (malformed) + * - pairs whose value contains non-ASCII bytes (`non_ascii`) + * - when `opts.allowlist` is true: pairs not on `VTEX_COOKIE_PREFIXES` + * (`not_in_allowlist`) + * + * Returns the cleaned header plus a per-cookie drop report so callers can + * log/observe which cookies were removed. + * + * @example + * ```ts + * const { cookies, dropped } = sanitizeOutboundCookieHeader( + * request.headers.get("cookie") ?? "", + * ); + * if (dropped.length) console.warn("[vtex] dropped cookies", dropped); + * fetch(vtexUrl, { headers: { cookie: cookies } }); + * ``` + */ +export function sanitizeOutboundCookieHeader( + raw: string, + opts: CookieSanitizeOptions = {}, +): CookieSanitizeResult { + if (!raw) return { cookies: "", dropped: [] }; + + const kept: string[] = []; + const dropped: DroppedCookie[] = []; + const allowlist = opts.allowlist === true; + + for (const segment of raw.split(";")) { + const pair = segment.trim(); + if (!pair) continue; + + const eq = pair.indexOf("="); + if (eq <= 0) { + dropped.push({ name: pair.slice(0, 32), reason: "malformed" }); + continue; + } + + const name = pair.slice(0, eq); + const value = pair.slice(eq + 1); + + if (!TOKEN_RE.test(name)) { + dropped.push({ name, reason: "malformed" }); + continue; + } + + if (!ASCII_VALUE_RE.test(value)) { + dropped.push({ name, reason: "non_ascii" }); + continue; + } + + if (allowlist && !VTEX_COOKIE_PREFIXES.some((p) => name.startsWith(p))) { + dropped.push({ name, reason: "not_in_allowlist" }); + continue; + } + + kept.push(`${name}=${value}`); + } + + return { cookies: kept.join("; "), dropped }; +} + +/** + * Allowlist convenience wrapper — keep only VTEX-prefixed, ASCII-clean cookies. + * + * Equivalent to `sanitizeOutboundCookieHeader(raw, { allowlist: true }).cookies`. + */ +export function extractVtexCookies(raw: string): string { + return sanitizeOutboundCookieHeader(raw, { allowlist: true }).cookies; +} + +/** + * Track which cookie names we've already warned about to avoid spamming + * the worker logs. Process-scoped — Cloudflare Workers reset this on each + * isolate restart, which is exactly the cadence we want. + */ +const _warned = new Set(); + +/** + * Emit a structured `console.warn` for each dropped cookie, deduped by + * `name+reason` for the lifetime of the worker isolate. Call sites pass an + * arbitrary `host` label so we can correlate in logs. + */ +export function warnDroppedCookies(dropped: DroppedCookie[], host: string): void { + if (dropped.length === 0) return; + for (const d of dropped) { + const key = `${host}::${d.name}::${d.reason}`; + if (_warned.has(key)) continue; + _warned.add(key); + console.warn(`[vtex.cookie.dropped] host=${host} name=${d.name} reason=${d.reason}`); + } +} + +/** Reset the dedup set — exposed for tests. */ +export function _resetCookieWarnDedupForTests(): void { + _warned.clear(); +} diff --git a/packages/apps-vtex/src/utils/cookies.ts b/packages/apps-vtex/src/utils/cookies.ts new file mode 100644 index 0000000..c39ae89 --- /dev/null +++ b/packages/apps-vtex/src/utils/cookies.ts @@ -0,0 +1,167 @@ +interface Cookie { + name: string; + value: string; + domain?: string; + path?: string; + expires?: Date; + maxAge?: number; + secure?: boolean; + httpOnly?: boolean; + sameSite?: "Strict" | "Lax" | "None"; +} + +function parseSingleSetCookie(raw: string): Cookie | null { + const parts = raw.split(";").map((p) => p.trim()); + const [nameValue, ...attrs] = parts; + const eqIdx = nameValue.indexOf("="); + if (eqIdx < 0) return null; + const cookie: Cookie = { + name: nameValue.slice(0, eqIdx), + value: nameValue.slice(eqIdx + 1), + }; + for (const attr of attrs) { + const eqi = attr.indexOf("="); + const k = (eqi >= 0 ? attr.slice(0, eqi) : attr).trim(); + const v = eqi >= 0 ? attr.slice(eqi + 1).trim() : ""; + const lower = k.toLowerCase(); + if (lower === "domain") cookie.domain = v; + else if (lower === "path") cookie.path = v; + else if (lower === "secure") cookie.secure = true; + else if (lower === "httponly") cookie.httpOnly = true; + else if (lower === "samesite") cookie.sameSite = v as Cookie["sameSite"]; + else if (lower === "max-age") { + const n = Number(v); + if (!Number.isNaN(n)) cookie.maxAge = n; + } else if (lower === "expires") { + const d = new Date(v); + if (!Number.isNaN(d.getTime())) cookie.expires = d; + } + } + return cookie; +} + +/** + * Extract individual Set-Cookie values from a Headers object. + * + * Uses Headers.getSetCookie() (available in Cloudflare Workers and Node 18+) + * which returns each Set-Cookie as a separate string — unlike Headers.get() + * or Headers.forEach() which join multiple values with ", " and corrupt + * cookie strings that contain commas in Expires dates. + */ +function getSetCookies(headers: Headers): Cookie[] { + const rawCookies: string[] = + typeof headers.getSetCookie === "function" + ? headers.getSetCookie() + : getRawSetCookiesFallback(headers); + + const cookies: Cookie[] = []; + for (const raw of rawCookies) { + const cookie = parseSingleSetCookie(raw); + if (cookie) cookies.push(cookie); + } + return cookies; +} + +/** + * Fallback for runtimes without Headers.getSetCookie(). + * Splits the comma-joined string heuristically — not perfect for cookies + * with Expires containing commas, but better than the old approach. + */ +function getRawSetCookiesFallback(headers: Headers): string[] { + const joined = headers.get("set-cookie"); + if (!joined) return []; + const results: string[] = []; + let current = ""; + for (const segment of joined.split(",")) { + const trimmed = segment.trimStart(); + const looksLikeNewCookie = /^[^=;]+=[^;]/.test(trimmed) && current.length > 0; + if (looksLikeNewCookie) { + results.push(current.trim()); + current = trimmed; + } else { + current += (current ? "," : "") + segment; + } + } + if (current.trim()) results.push(current.trim()); + return results; +} + +function setCookie(headers: Headers, cookie: Cookie): void { + let str = `${cookie.name}=${cookie.value}`; + if (cookie.domain) str += `; Domain=${cookie.domain}`; + if (cookie.path) str += `; Path=${cookie.path}`; + if (cookie.secure) str += "; Secure"; + if (cookie.httpOnly) str += "; HttpOnly"; + if (cookie.sameSite) str += `; SameSite=${cookie.sameSite}`; + if (cookie.maxAge != null) str += `; Max-Age=${cookie.maxAge}`; + if (cookie.expires) str += `; Expires=${cookie.expires.toUTCString()}`; + headers.append("Set-Cookie", str); +} + +export const stringify = (cookies: Record) => + Object.entries(cookies) + .map(([key, value]) => `${key}=${value}`) + .join("; "); + +export const proxySetCookie = (from: Headers, to: Headers, toDomain?: URL | string) => { + const newDomain = toDomain && new URL(toDomain); + + for (const cookie of getSetCookies(from)) { + const newCookie = newDomain + ? { + ...cookie, + domain: newDomain.hostname, + } + : cookie; + + setCookie(to, newCookie); + } +}; + +export const CHECKOUT_DATA_ACCESS_COOKIE = "CheckoutDataAccess"; +export const VTEX_CHKO_AUTH = "Vtex_CHKO_Auth"; + +// Re-export the canonical allowlist from cookieSanitizer so consumers that +// previously imported it from this module keep working. The single source +// of truth lives in cookieSanitizer.ts. +export { VTEX_COOKIE_PREFIXES } from "./cookieSanitizer"; + +import { extractVtexCookies } from "./cookieSanitizer"; + +/** + * Filter a request's cookies to only VTEX-relevant ones. + * + * Strict allowlist: drops any cookie not on `VTEX_COOKIE_PREFIXES` plus any + * cookie whose value contains non-ASCII bytes (which would make VTEX's + * janus gateway return 503). + */ +export function getVtexCookies(request: Request): string { + return extractVtexCookies(request.headers.get("cookie") ?? ""); +} + +/** + * Ensure the unsuffixed VtexIdclientAutCookie is present alongside the + * account-suffixed variant (e.g. VtexIdclientAutCookie_myaccount). + * + * VTEX GraphQL requires both the suffixed AND unsuffixed cookie for + * authenticated mutations. The browser only stores the suffixed variant, + * so server-side code must synthesize the unsuffixed one. + */ +export function ensureUnsuffixedAuthCookie(cookieStr: string): string { + if (!cookieStr) return cookieStr; + const cookies = cookieStr.split(";").map((c) => c.trim()); + let hasUnsuffixed = false; + let suffixedToken: string | null = null; + for (const c of cookies) { + const [name, ...rest] = c.split("="); + if (name === "VtexIdclientAutCookie") { + hasUnsuffixed = true; + } else if (name?.startsWith("VtexIdclientAutCookie_") && !suffixedToken) { + suffixedToken = rest.join("="); + } + } + if (!hasUnsuffixed && suffixedToken) { + return `VtexIdclientAutCookie=${suffixedToken}; ${cookieStr}`; + } + return cookieStr; +} diff --git a/packages/apps-vtex/src/utils/enrichment.ts b/packages/apps-vtex/src/utils/enrichment.ts new file mode 100644 index 0000000..4877fff --- /dev/null +++ b/packages/apps-vtex/src/utils/enrichment.ts @@ -0,0 +1,560 @@ +/** + * Product Extension Pipeline. + * + * Composable middleware-style pipeline to enrich products after the + * initial search/catalog fetch. Covers real-time price simulation + * (for B2B/promotional pricing) and wishlist annotation. + * + * @example + * ```ts + * import { + * createProductPipeline, + * withSimulation, + * withWishlist, + * } from "@decocms/apps/vtex/utils/enrichment"; + * + * const enrich = createProductPipeline( + * withSimulation(), + * withWishlist(), + * ); + * + * const products = await vtexProductList(props); + * const enriched = await enrich(products, { request }); + * ``` + */ + +import type { Product, ProductLeaf } from "@decocms/apps-commerce/types"; +import { getVtexConfig, vtexFetch, vtexIOGraphQL } from "../client"; +import { listBrands } from "../loaders/brands"; +import { batch } from "./batch"; +import { withIsSimilarTo } from "./similars"; +import { pickSku, toInventories, toProduct, toReview } from "./transform"; +import type { LegacyProduct } from "./types"; +import { buildAuthCookieHeader, VTEX_AUTH_COOKIE } from "./vtexId"; + +// ------------------------------------------------------------------------- +// Constants +// ------------------------------------------------------------------------- + +/** VTEX prices come in cents — divide by this to get the currency value. */ +const CENTS_DIVISOR = 100; + +/** Default number of products per simulation API call. */ +const DEFAULT_SIMULATION_BATCH_SIZE = 50; + +/** Maximum wishlist items to fetch in a single query. */ +const WISHLIST_MAX_ITEMS = 500; + +/** Batch size for kit-item product lookups. */ +const KIT_ITEMS_BATCH_SIZE = 10; + +/** Batch size for variant product lookups. */ +const VARIANTS_BATCH_SIZE = 15; + +/** Number of reviews to fetch per product. */ +const REVIEWS_PAGE_SIZE = 10; + +// ------------------------------------------------------------------------- +// Types +// ------------------------------------------------------------------------- + +export interface EnrichmentContext { + /** The incoming HTTP request (for cookies, auth tokens). */ + request?: Request; + /** Sales channel override. */ + salesChannel?: string; +} + +/** + * A product enricher takes a list of products and returns an enriched list. + * Enrichers are composed via `createProductPipeline`. + */ +export type ProductEnricher = (products: Product[], ctx: EnrichmentContext) => Promise; + +// ------------------------------------------------------------------------- +// Pipeline +// ------------------------------------------------------------------------- + +/** + * Compose multiple enrichers into a single pipeline. + * + * Enrichers run sequentially -- each receives the output of the previous. + * This is intentional: some enrichers depend on previous enrichments + * (e.g., wishlist may need SKU IDs added by simulation). + */ +export function createProductPipeline(...enrichers: ProductEnricher[]): ProductEnricher { + return async (products, ctx) => { + if (!products.length) return products; + + let result = products; + for (const enricher of enrichers) { + try { + result = await enricher(result, ctx); + } catch (error) { + console.error( + `[ProductPipeline] Enricher failed, continuing with unenriched data:`, + error instanceof Error ? error.message : error, + ); + } + } + return result; + }; +} + +// ------------------------------------------------------------------------- +// Simulation Enricher +// ------------------------------------------------------------------------- + +interface SimulationItem { + itemIndex: number; + id: string; + quantity: number; + seller: string; +} + +interface SimulationResult { + items: Array<{ + itemIndex: number; + listPrice: number; + sellingPrice: number; + price: number; + availability: string; + quantity: number; + }>; +} + +/** + * Enrich products with real-time prices from VTEX simulation API. + * + * The search index may have stale prices. Simulation returns the + * actual price the user would pay, accounting for promotions, + * trade policies, price tables, and regional pricing. + * + * @param options.batchSize - Max products per simulation call. @default 50 + */ +export function withSimulation(options?: { batchSize?: number }): ProductEnricher { + const batchSize = options?.batchSize ?? DEFAULT_SIMULATION_BATCH_SIZE; + + return async (products, ctx) => { + const config = getVtexConfig(); + const sc = ctx.salesChannel ?? config.salesChannel ?? "1"; + + const skuItems: SimulationItem[] = []; + const skuToProductIndex = new Map(); + + for (let pi = 0; pi < products.length; pi++) { + const product = products[pi]; + const aggOffer = product.offers; + if (!aggOffer?.offers) continue; + + for (let oi = 0; oi < aggOffer.offers.length; oi++) { + const offer = aggOffer.offers[oi]; + const skuId = product.sku ?? product.productID; + const seller = offer.seller ?? "1"; + + if (skuId) { + skuItems.push({ + itemIndex: skuItems.length, + id: skuId, + quantity: 1, + seller, + }); + skuToProductIndex.set(`${skuId}-${seller}`, { + productIdx: pi, + offerIdx: oi, + }); + } + } + } + + if (!skuItems.length) return products; + + const result = [...products]; + const batches: SimulationItem[][] = []; + for (let i = 0; i < skuItems.length; i += batchSize) { + batches.push(skuItems.slice(i, i + batchSize)); + } + + for (const batch of batches) { + try { + const sim = await vtexFetch( + `/api/checkout/pub/orderForms/simulation?sc=${sc}&RnbBehavior=1`, + { + method: "POST", + body: JSON.stringify({ + items: batch, + country: config.country ?? "BRA", + }), + }, + ); + + for (const simItem of sim.items) { + const original = batch[simItem.itemIndex]; + if (!original) continue; + + const key = `${original.id}-${original.seller}`; + const mapping = skuToProductIndex.get(key); + if (!mapping) continue; + + const product = { ...result[mapping.productIdx] }; + const aggOffer = product.offers; + if (!aggOffer) continue; + + const offers = [...aggOffer.offers]; + const offer = { ...offers[mapping.offerIdx] }; + + offer.price = simItem.sellingPrice / CENTS_DIVISOR; + if (simItem.listPrice) { + (offer as any).priceSpecification = [ + ...(Array.isArray((offer as any).priceSpecification) + ? (offer as any).priceSpecification + : []), + ].map((spec: any) => { + if (spec?.priceType === "https://schema.org/ListPrice") { + return { ...spec, price: simItem.listPrice / CENTS_DIVISOR }; + } + if (spec?.priceType === "https://schema.org/SalePrice") { + return { ...spec, price: simItem.sellingPrice / CENTS_DIVISOR }; + } + return spec; + }); + } + offer.availability = + simItem.availability === "available" + ? "https://schema.org/InStock" + : "https://schema.org/OutOfStock"; + + offers[mapping.offerIdx] = offer; + product.offers = { ...aggOffer, offers }; + result[mapping.productIdx] = product; + } + } catch (error) { + console.error("[Simulation] Batch failed:", error instanceof Error ? error.message : error); + } + } + + return result; + }; +} + +// ------------------------------------------------------------------------- +// Wishlist Enricher +// ------------------------------------------------------------------------- + +const WISHLIST_QUERY = `query GetWishlist($shopperId: String!, $name: String!, $from: Int!, $to: Int!) { + viewList(shopperId: $shopperId, name: $name, from: $from, to: $to) + @context(provider: "vtex.wish-list@1.x") { + data { + id + productId + sku + } + } +}`; + +interface WishlistData { + viewList: { + data: Array<{ id: string; productId: string; sku: string }> | null; + }; +} + +function getCookieValue(cookieHeader: string, name: string): string | null { + const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)); + return match?.[1] ?? null; +} + +/** + * Enrich products with wishlist status. + * + * Reads the user's wishlist and adds `isInWishlist: true` as an + * additionalProperty on products that are wishlisted. + * + * Requires the user to be logged in (reads VtexIdclientAutCookie). + * For anonymous users, this is a no-op. + */ +export function withWishlist(): ProductEnricher { + return async (products, ctx) => { + if (!ctx.request) return products; + + const cookies = ctx.request.headers.get("cookie") ?? ""; + const authCookie = getCookieValue(cookies, VTEX_AUTH_COOKIE); + if (!authCookie) return products; + + let email: string | undefined; + try { + const parts = authCookie.split("."); + if (parts.length === 3) { + const payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"))); + email = payload.sub ?? payload.userId; + } + } catch { + return products; + } + + if (!email) return products; + + try { + const data = await vtexIOGraphQL( + { + query: WISHLIST_QUERY, + variables: { shopperId: email, name: "Wishlist", from: 0, to: WISHLIST_MAX_ITEMS }, + }, + { Cookie: buildAuthCookieHeader(authCookie, getVtexConfig().account) }, + ); + + const wishlistItems = data.viewList?.data ?? []; + const wishlistSkus = new Set(wishlistItems.map((i) => i.sku)); + const wishlistProductIds = new Set(wishlistItems.map((i) => i.productId)); + + return products.map((product) => { + const isWishlisted = + (product.sku && wishlistSkus.has(product.sku)) || + (product.productID && wishlistProductIds.has(product.productID)); + + if (!isWishlisted) return product; + + return { + ...product, + additionalProperty: [ + ...(product.additionalProperty ?? []), + { + "@type": "PropertyValue" as const, + name: "isInWishlist", + value: "true", + propertyID: "WISHLIST", + }, + ], + }; + }); + } catch (error) { + console.error( + "[Wishlist] Failed to fetch wishlist:", + error instanceof Error ? error.message : error, + ); + return products; + } + }; +} + +// ------------------------------------------------------------------------- +// Similars Enricher +// ------------------------------------------------------------------------- + +/** + * Enrich products with similar product data from Legacy Catalog API. + * Ported from deco-cx/apps vtex/loaders/product/extend.ts (similarsExt) + */ +export function withSimilars(): ProductEnricher { + return async (products) => { + return Promise.all(products.map((p) => withIsSimilarTo(p))); + }; +} + +// ------------------------------------------------------------------------- +// Kit Items Enricher +// ------------------------------------------------------------------------- + +/** + * Enrich products with kit item details (isAccessoryOrSparePartFor). + * Fetches full product data for referenced accessories via Legacy Catalog. + * Ported from deco-cx/apps vtex/loaders/product/extend.ts (kitItemsExt) + */ +export function withKitItems(): ProductEnricher { + return async (products) => { + const productIDs = new Set(); + + for (const product of products) { + for (const item of product.isAccessoryOrSparePartFor ?? []) { + if (item.productID) productIDs.add(item.productID); + } + } + + if (!productIDs.size) return products; + + const config = getVtexConfig(); + const baseUrl = config.publicUrl + ? `https://${config.publicUrl}` + : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`; + + const batches = batch([...productIDs], KIT_ITEMS_BATCH_SIZE); + const productsById = new Map(); + + for (const ids of batches) { + try { + const fq = ids.map((id) => `productId:${id}`); + const raw = await vtexFetch( + `/api/catalog_system/pub/products/search/?${fq.map((f) => `fq=${f}`).join("&")}&_from=0&_to=${ids.length - 1}`, + ); + for (const p of raw) { + const sku = pickSku(p); + const product = toProduct(p, sku, 0, { + baseUrl, + priceCurrency: "BRL", + }); + for (const leaf of product.isVariantOf?.hasVariant ?? []) { + productsById.set(leaf.productID, leaf); + } + } + } catch (e) { + console.error("[KitItems] Batch failed:", e instanceof Error ? e.message : e); + } + } + + return products.map((p) => ({ + ...p, + isAccessoryOrSparePartFor: p.isAccessoryOrSparePartFor + ?.map((item) => productsById.get(item.productID)) + .filter((item): item is ProductLeaf => Boolean(item)), + })); + }; +} + +// ------------------------------------------------------------------------- +// Variants Enricher +// ------------------------------------------------------------------------- + +/** + * Enrich products with full variant data from Legacy Catalog. + * When products come from IS, they may lack variant details. + * Ported from deco-cx/apps vtex/loaders/product/extend.ts (variantsExt) + */ +export function withVariants(): ProductEnricher { + return async (products) => { + const productIDs = new Set(); + for (const product of products) { + if (product.productID) productIDs.add(product.productID); + } + + if (!productIDs.size) return products; + + const config = getVtexConfig(); + const baseUrl = config.publicUrl + ? `https://${config.publicUrl}` + : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`; + + const batches = batch([...productIDs], VARIANTS_BATCH_SIZE); + const productsById = new Map(); + + for (const ids of batches) { + try { + const fq = ids.map((id) => `productId:${id}`); + const raw = await vtexFetch( + `/api/catalog_system/pub/products/search/?${fq.map((f) => `fq=${f}`).join("&")}&_from=0&_to=${ids.length - 1}`, + ); + for (const p of raw) { + const sku = pickSku(p); + const product = toProduct(p, sku, 0, { + baseUrl, + priceCurrency: "BRL", + }); + productsById.set(product.productID, product); + } + } catch (e) { + console.error("[Variants] Batch failed:", e instanceof Error ? e.message : e); + } + } + + return products.map((p) => ({ + ...productsById.get(p.productID), + ...p, + isVariantOf: productsById.get(p.productID)?.isVariantOf, + })); + }; +} + +// ------------------------------------------------------------------------- +// Reviews Enricher +// ------------------------------------------------------------------------- + +/** + * Enrich products with reviews and ratings from VTEX Reviews & Ratings app. + * Ported from deco-cx/apps vtex/loaders/product/extend.ts (reviewsExt) + */ +export function withReviews(): ProductEnricher { + return async (products) => { + const config = getVtexConfig(); + const myHost = `${config.account}.myvtex.com`; + + const reviewPromises = products.map((product) => + vtexFetch( + `https://${myHost}/reviews-and-ratings/api/reviews?product_id=${product.inProductGroupWithID ?? ""}&from=0&to=${REVIEWS_PAGE_SIZE}&status=true`, + ).catch((error) => { + console.error( + "[Reviews] Failed for product", + product.inProductGroupWithID, + error instanceof Error ? error.message : error, + ); + return {}; + }), + ); + + const ratingPromises = products.map((product) => + vtexFetch( + `https://${myHost}/reviews-and-ratings/api/rating/${product.inProductGroupWithID ?? ""}`, + ).catch((error) => { + console.error( + "[Ratings] Failed for product", + product.inProductGroupWithID, + error instanceof Error ? error.message : error, + ); + return {}; + }), + ); + + const [reviews, ratings] = await Promise.all([ + Promise.all(reviewPromises), + Promise.all(ratingPromises), + ]); + + return toReview(products, ratings, reviews); + }; +} + +// ------------------------------------------------------------------------- +// Inventory Enricher +// ------------------------------------------------------------------------- + +/** + * Enrich products with inventory/stock data from VTEX Logistics API. + * Ported from deco-cx/apps vtex/loaders/product/extend.ts (inventoryExt) + */ +export function withInventory(): ProductEnricher { + return async (products) => { + const inventories = await Promise.all( + products.map((product) => { + if (!product.sku) return Promise.resolve({}); + return vtexFetch(`/api/logistics/pvt/inventory/skus/${product.sku}`).catch((error) => { + console.error( + "[Inventory] Failed for SKU", + product.sku, + error instanceof Error ? error.message : error, + ); + return {}; + }); + }), + ); + + return toInventories(products, inventories); + }; +} + +// ------------------------------------------------------------------------- +// Brands Enricher +// ------------------------------------------------------------------------- + +/** + * Enrich products with brand information from Legacy Catalog. + * Useful for Intelligent Search results that may lack brand details. + * Ported from deco-cx/apps vtex/loaders/product/extend.ts (brandsExt) + */ +export function withBrands(): ProductEnricher { + return async (products) => { + const brands = await listBrands(); + if (!brands?.length) return products; + + return products.map((p) => { + const match = brands.find((b) => b["@id"] === p.brand?.["@id"]); + return match ? { ...p, brand: match } : p; + }); + }; +} diff --git a/packages/apps-vtex/src/utils/fetch.ts b/packages/apps-vtex/src/utils/fetch.ts new file mode 100644 index 0000000..257cafa --- /dev/null +++ b/packages/apps-vtex/src/utils/fetch.ts @@ -0,0 +1,107 @@ +/** + * Generic VTEX fetch helpers. + * + * These are the canonical primitives for ad-hoc VTEX API calls in apps-start + * (custom path-resolution loaders, sitemap loaders, custom analytics, etc.). + * For typed catalog/IS/checkout calls prefer the {@link import("../client").vtexFetch} + * client which is wired into the configured account. + * + * Provides: + * - {@link fetchSafe} — fetch wrapper that throws {@link HttpError} on non-2xx + * - {@link fetchAPI} — same, parsed to JSON + * + * URLs are sanitized for known XSS-prone query params (utm_*, ft, map) before + * dispatch. This mirrors the security posture VTEX storefronts carry across + * the platform. + */ + +type CachingMode = "stale-while-revalidate"; + +type DecoInit = { + cache: CachingMode; + cacheTtlByStatus?: Array<{ from: number; to: number; ttl: number }>; +}; + +export type DecoRequestInit = RequestInit & { deco?: DecoInit }; + +export class HttpError extends Error { + readonly status: number; + readonly response: Response; + + constructor(response: Response) { + super(`HTTP ${response.status} ${response.statusText} — ${response.url}`); + this.name = "HttpError"; + this.status = response.status; + this.response = response; + } +} + +const removeNonLatin1Chars = (str: string): string => + // eslint-disable-next-line no-control-regex + str.replace(/[^\x00-\xFF]/g, ""); + +const removeScriptChars = (str: string): string => str.replace(/[<>]/g, ""); + +const QS_TO_REMOVE_PLUS = ["utm_campaign", "utm_medium", "utm_source", "map"]; +const QS_TO_REPLACE_PLUS = ["ft"]; + +const sanitizeUrl = (input: string | URL | Request): string | Request | URL => { + let url: URL; + + if (typeof input === "string") { + try { + url = new URL(input); + } catch { + return input; + } + } else if (input instanceof URL) { + url = input; + } else { + return input; + } + + for (const key of QS_TO_REMOVE_PLUS) { + if (!url.searchParams.has(key)) continue; + const values = url.searchParams.getAll(key); + const cleaned = values.map((v) => removeScriptChars(removeNonLatin1Chars(v))).filter(Boolean); + url.searchParams.delete(key); + for (const v of cleaned) url.searchParams.append(key, v); + } + + for (const key of QS_TO_REPLACE_PLUS) { + if (!url.searchParams.has(key)) continue; + const values = url.searchParams.getAll(key); + const cleaned = values.map((v) => encodeURIComponent(v.trim())); + url.searchParams.delete(key); + for (const v of cleaned) url.searchParams.append(key, v); + } + + return url.toString(); +}; + +/** + * Fetch wrapper that throws {@link HttpError} on non-2xx responses and + * sanitizes URL query strings. + */ +export async function fetchSafe( + input: string | URL | Request, + init?: DecoRequestInit, +): Promise { + const sanitized = sanitizeUrl(input); + const response = await fetch(sanitized as RequestInfo, init); + if (!response.ok) { + throw new HttpError(response); + } + return response; +} + +/** + * Fetch wrapper that parses the response as JSON. Throws on non-2xx. + */ +export async function fetchAPI( + input: string | URL | Request, + init?: DecoRequestInit, +): Promise { + const response = await fetchSafe(input, init); + return response.json() as Promise; +} diff --git a/packages/apps-vtex/src/utils/fetchCache.ts b/packages/apps-vtex/src/utils/fetchCache.ts new file mode 100644 index 0000000..40ba52a --- /dev/null +++ b/packages/apps-vtex/src/utils/fetchCache.ts @@ -0,0 +1,222 @@ +/** + * SWR in-memory fetch cache for VTEX API responses. + * + * Inspired by deco-cx/deco runtime/fetch/fetchCache.ts. + * Provides in-flight deduplication + stale-while-revalidate for GET requests. + * + * Only caches on the server side. Keyed by full URL string. + */ + +const DEFAULT_MAX_ENTRIES = 500; +const MAX_RETRIES = 2; +const RETRY_DELAYS = [200, 400]; +// Per-attempt timeout. Bounds how long a single hung `fetch()` can hold an +// inflight entry alive. Without this, a VTEX subrequest that never settles +// leaks the inflight Map slot forever and every subsequent request for the +// same cache key joins the zombie Promise, pinning memory until +// `exceededMemory` (observed in prod: 514 hard crashes / 24h on a PLP route). +const FETCH_TIMEOUT_MS = 10_000; + +interface CacheEntry { + body: unknown; + status: number; + createdAt: number; + refreshing: boolean; +} + +const TTL_BY_STATUS: Record = { + "2xx": 180_000, // 3 min for success + "404": 10_000, // 10s for not found + "5xx": 0, // never cache server errors +}; + +function ttlForStatus(status: number): number { + if (status >= 200 && status < 300) return TTL_BY_STATUS["2xx"]; + if (status === 404) return TTL_BY_STATUS["404"]; + if (status >= 500) return TTL_BY_STATUS["5xx"]; + return 0; +} + +const store = new Map(); +const inflight = new Map>(); + +function evictIfNeeded() { + if (store.size <= DEFAULT_MAX_ENTRIES) return; + const sorted = [...store.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt); + const toRemove = sorted.slice(0, store.size - DEFAULT_MAX_ENTRIES); + for (const [key] of toRemove) store.delete(key); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryable(response: Response): boolean { + return response.status >= 500 || response.status === 429; +} + +/** + * Race a Promise against a timeout so callers' `.finally()` always runs. + * Critical for evicting the inflight Map entry when a `fetch()` hangs — + * without this, a never-settling Promise leaks the Map slot forever and + * every subsequent request for the same key joins the zombie Promise. + */ +function withTimeout(work: Promise, ms: number, label: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`${label} timed out after ${ms}ms`)); + }, ms); + }); + return Promise.race([work, timeout]).finally(() => { + clearTimeout(timer); + }); +} + +async function executeFetch( + url: string, + doFetch: () => Promise, + retry = true, +): Promise { + let lastError: Error | undefined; + + const attempts = retry ? MAX_RETRIES + 1 : 1; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + const response = await doFetch(); + + if (isRetryable(response) && attempt < attempts - 1) { + console.warn( + `[vtex-fetch] ${response.status} on attempt ${attempt + 1}/${attempts} — ${url}`, + ); + await sleep(RETRY_DELAYS[attempt] ?? 400); + continue; + } + + if (response.status >= 500) { + throw new Error( + `fetchWithCache: ${response.status} ${response.statusText} after ${attempt + 1} attempt(s) — ${url}`, + ); + } + + const body = response.ok ? await response.json() : null; + return { + body, + status: response.status, + createdAt: Date.now(), + refreshing: false, + }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt < attempts - 1) { + console.warn( + `[vtex-fetch] attempt ${attempt + 1}/${attempts} failed — ${url}: ${lastError.message}`, + ); + await sleep(RETRY_DELAYS[attempt] ?? 400); + } + } + } + + throw lastError ?? new Error(`fetchWithCache: all ${attempts} attempts failed — ${url}`); +} + +export interface FetchCacheOptions { + /** + * Custom TTL in ms. If provided, overrides status-based TTL. + */ + ttl?: number; +} + +/** + * Wrap a GET fetch call with SWR caching and in-flight dedup. + * + * Returns `null` for non-2xx responses that are cached (e.g. 404). + * 5xx responses throw so the caller can handle them explicitly. + * + * @param cacheKey - Unique key (typically the full URL) + * @param doFetch - The actual fetch call to execute + * @param opts - Optional overrides + * @returns Parsed JSON body, or null for cacheable error responses (e.g. 404) + */ +export function fetchWithCache( + cacheKey: string, + doFetch: () => Promise, + opts?: FetchCacheOptions, +): Promise { + const now = Date.now(); + const entry = store.get(cacheKey); + + if (entry) { + const maxAge = opts?.ttl ?? ttlForStatus(entry.status); + const isStale = now - entry.createdAt > maxAge; + + if (!isStale) return Promise.resolve(entry.body as T | null); + + if (isStale && !entry.refreshing) { + entry.refreshing = true; + // Background refresh: no retry — stale data is already being served. + // Timeout guards against a hung VTEX response leaving `refreshing` + // stuck true forever (which would silently disable revalidation). + withTimeout( + executeFetch(cacheKey, doFetch, false), + FETCH_TIMEOUT_MS, + `fetchCache stale-refresh ${cacheKey}`, + ) + .then((fresh) => { + const ttl = opts?.ttl ?? ttlForStatus(fresh.status); + const existingWasSuccess = entry.status >= 200 && entry.status < 300; + const freshIsError = fresh.status >= 400; + const wouldDowngrade = existingWasSuccess && freshIsError; + if (ttl > 0 && !wouldDowngrade) { + store.set(cacheKey, fresh); + } else { + entry.refreshing = false; + } + }) + .catch(() => { + entry.refreshing = false; + }); + return Promise.resolve(entry.body as T | null); + } + + return Promise.resolve(entry.body as T | null); + } + + const existing = inflight.get(cacheKey); + if (existing) return existing.then((e) => e.body as T | null); + + // Wrap with a timeout so the `.finally()` below always runs and evicts + // the inflight slot — even if `executeFetch` never settles. See the + // FETCH_TIMEOUT_MS comment at the top of this file for the leak this + // guards against. + const promise = withTimeout( + executeFetch(cacheKey, doFetch), + FETCH_TIMEOUT_MS, + `fetchCache ${cacheKey}`, + ) + .then((fresh) => { + const ttl = opts?.ttl ?? ttlForStatus(fresh.status); + if (ttl > 0) { + store.set(cacheKey, fresh); + evictIfNeeded(); + } + return fresh; + }) + .finally(() => inflight.delete(cacheKey)); + + inflight.set(cacheKey, promise); + return promise.then((e) => e.body as T | null); +} + +export function clearFetchCache() { + store.clear(); + inflight.clear(); +} + +export function getFetchCacheStats() { + return { + entries: store.size, + inflight: inflight.size, + }; +} diff --git a/packages/apps-vtex/src/utils/index.ts b/packages/apps-vtex/src/utils/index.ts new file mode 100644 index 0000000..c4f115d --- /dev/null +++ b/packages/apps-vtex/src/utils/index.ts @@ -0,0 +1,19 @@ +export type { PersonalDataOptions } from "./accountLoaders"; +export { vtexAccountLoaders } from "./accountLoaders"; +export * from "./batch"; +export * from "./cookies"; +export * from "./enrichment"; +export * from "./fetchCache"; +export * from "./intelligentSearch"; +export * from "./legacy"; +export * from "./pickAndOmit"; +export * from "./proxy"; +export * from "./resourceRange"; +export * from "./segment"; +export * from "./similars"; +export * from "./sitemap"; +export * from "./slugCache"; +export * from "./slugify"; +export * from "./transform"; +export * from "./types"; +export * from "./vtexId"; diff --git a/packages/apps-vtex/src/utils/instrumentedFetch.ts b/packages/apps-vtex/src/utils/instrumentedFetch.ts new file mode 100644 index 0000000..7b0ee6e --- /dev/null +++ b/packages/apps-vtex/src/utils/instrumentedFetch.ts @@ -0,0 +1,78 @@ +/** + * Pre-wired instrumented fetch factory for VTEX. + * + * Bundles the three pieces a storefront would otherwise have to wire + * by hand: + * + * 1. The `createInstrumentedFetch` boundary from `@decocms/start` + * (spans, traceparent injection, URL redaction, cache-header + * span attributes). + * 2. The `vtexOperationRouter` URL→operation mapping so unannotated + * callsites still get semantic span names + histogram labels. + * 3. An `onComplete` callback that records every call into the + * canonical `http.client.request.duration` histogram via the + * framework's `recordCommerceMetric(...)` helper in + * `@decocms/start/sdk/observability` — `provider`, `operation`, + * `status_class`, and `cached` labels. + * + * Sites opt in once at startup: + * + * ```ts + * import { setVtexFetch } from "@decocms/apps/vtex"; + * import { createVtexFetch } from "@decocms/apps/vtex"; + * + * setVtexFetch(createVtexFetch()); + * ``` + * + * Sites that need to wrap a custom underlying fetch (cookie passthrough, + * proxy, retry, etc.) pass it as `baseFetch`. The instrumentation + * still applies — `createInstrumentedFetch` preserves the wrapped + * behavior. + */ + +import { + createInstrumentedFetch, + type InstrumentedFetch, +} from "@decocms/blocks/sdk/instrumentedFetch"; +import { recordCommerceMetric } from "@decocms/blocks/sdk/observability"; +import { vtexOperationRouter } from "./operationRouter"; + +export interface CreateVtexFetchOptions { + /** + * Underlying fetch to wrap. Defaults to `globalThis.fetch`. + * Pass an existing custom fetch (e.g. one that injects auth cookies + * or routes through a proxy) to preserve its behavior while adding + * the VTEX instrumentation layer on top. + */ + baseFetch?: typeof fetch; + /** + * Disable the `http.client.request.duration` histogram emission for + * VTEX calls. The framework's span and structured logs still emit. + * Useful when the consumer wants to record its own histogram with a + * custom shape. Default: false. + */ + disableHistogram?: boolean; +} + +/** + * Construct a pre-wired VTEX `InstrumentedFetch`. Pass the result to + * `setVtexFetch(...)`. See module docstring for details. + */ +export function createVtexFetch(options: CreateVtexFetchOptions = {}): InstrumentedFetch { + const { baseFetch, disableHistogram = false } = options; + return createInstrumentedFetch({ + name: "vtex", + baseFetch, + resolveOperation: vtexOperationRouter, + onComplete: disableHistogram + ? undefined + : ({ operation, status, durationMs, cached }) => { + recordCommerceMetric(durationMs, { + provider: "vtex", + operation, + status_class: `${Math.floor(status / 100)}xx`, + cached, + }); + }, + }); +} diff --git a/packages/apps-vtex/src/utils/intelligentSearch.ts b/packages/apps-vtex/src/utils/intelligentSearch.ts new file mode 100644 index 0000000..7492a38 --- /dev/null +++ b/packages/apps-vtex/src/utils/intelligentSearch.ts @@ -0,0 +1,96 @@ +import { vtexFetch } from "../client"; +import type { PageType, SelectedFacet, SimulationBehavior, Sort } from "./types"; + +export const SESSION_COOKIE = "vtex_is_session"; +export const ANONYMOUS_COOKIE = "vtex_is_anonymous"; + +export const withDefaultFacets = (allFacets: readonly SelectedFacet[]) => { + return [...allFacets]; +}; + +export const toPath = (facets: SelectedFacet[]) => + facets.map(({ key, value }) => (key ? `${key}/${value}` : value)).join("/"); + +interface Params { + query: string; + page: number; + count: number; + sort: Sort; + fuzzy: string; + locale: string; + hideUnavailableItems: boolean; + simulationBehavior: SimulationBehavior; +} + +export const withDefaultParams = ({ + query = "", + page = 0, + count = 12, + sort = "", + fuzzy = "auto", + locale = "pt-BR", + hideUnavailableItems, + simulationBehavior = "default", +}: Partial) => ({ + page: page + 1, + count, + query, + sort, + ...(fuzzy ? { fuzzy } : {}), + locale, + hideUnavailableItems: hideUnavailableItems ?? false, + simulationBehavior, +}); + +export const isFilterParam = (keyFilter: string): boolean => keyFilter.startsWith("filter."); + +/** + * Valid VTEX Intelligent Search sort values. + * Anything else (e.g. "orders:desc)" with a trailing paren from legacy URLs) + * causes IS API to return 400. + */ +export const VALID_IS_SORTS = new Set([ + "", + "orders:desc", + "price:asc", + "price:desc", + "name:asc", + "name:desc", + "release:desc", + "discount:desc", +]); + +/** Sanitize an IS sort parameter — returns empty string for invalid values. */ +export function sanitizeISSort(sort: string): string { + return VALID_IS_SORTS.has(sort) ? sort : ""; +} + +const segmentsFromTerm = (term: string) => term.split("/").filter(Boolean); + +const segmentsFromSearchParams = (url: string) => { + const searchParams = new URLSearchParams(url).entries(); + + const categories = Array.from(searchParams) + .sort() + .reduce((acc, [key, value]) => { + if (key.includes("filter.category")) { + acc.push(value); + } + + return acc; + }, [] as string[]); + + return categories.length ? categories : segmentsFromTerm(url); +}; + +export const pageTypesFromUrl = async (url: string): Promise => { + const segments = segmentsFromSearchParams(url); + + return await Promise.all( + segments.map((_, index) => + vtexFetch( + `/api/catalog_system/pub/portal/pagetype/${segments.slice(0, index + 1).join("/")}`, + ), + ), + ); +}; diff --git a/packages/apps-vtex/src/utils/legacy.ts b/packages/apps-vtex/src/utils/legacy.ts new file mode 100644 index 0000000..a9163f9 --- /dev/null +++ b/packages/apps-vtex/src/utils/legacy.ts @@ -0,0 +1,139 @@ +import type { Seo } from "@decocms/apps-commerce/types"; +import { vtexFetch } from "../client"; +import type { WrappedSegment } from "./segment"; +import { slugify } from "./slugify"; +import type { PageType } from "./types"; + +const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +export const toSegmentParams = ({ payload: segment }: WrappedSegment) => + Object.fromEntries( + Object.entries({ + utmi_campaign: segment.utmi_campaign ?? undefined, + utm_campaign: segment.utm_campaign ?? undefined, + utm_source: segment.utm_source ?? undefined, + sc: segment.channel ?? undefined, + }).filter(([_, v]) => v !== undefined), + ); + +const PAGE_TYPE_TO_MAP_PARAM = { + Brand: "b", + Category: "c", + Department: "c", + SubCategory: "c", + Collection: "productClusterIds", + Cluster: "productClusterIds", + Search: "ft", + FullText: "ft", + Product: "p", + NotFound: null, +}; + +const segmentsFromTerm = (term: string) => term.split("/").filter(Boolean); + +export const getValidTypesFromPageTypes = (pagetypes: PageType[]) => { + return pagetypes.filter((type) => PAGE_TYPE_TO_MAP_PARAM[type.pageType]); +}; + +export const pageTypesFromPathname = async (term: string): Promise => { + const segments = segmentsFromTerm(term); + + return await Promise.all( + segments.map((_, index) => + vtexFetch( + `/api/catalog_system/pub/portal/pagetype/${segments.slice(0, index + 1).join("/")}`, + ), + ), + ); +}; + +export const getMapAndTerm = (pageTypes: PageType[]) => { + const term = pageTypes + .map((type, index) => + type.url ? segmentsFromTerm(new URL(`http://${type.url}`).pathname)[index] : null, + ) + .filter(Boolean) + .join("/"); + + const map = pageTypes + .map((type) => PAGE_TYPE_TO_MAP_PARAM[type.pageType]) + .filter(Boolean) + .join(","); + + if (map === "ft" && term === "s") { + return ["", ""]; + } + + return [map, term]; +}; + +export const pageTypesToBreadcrumbList = (pages: PageType[], baseUrl: string) => { + const filteredPages = pages.filter( + ({ pageType }) => + pageType === "Category" || pageType === "Department" || pageType === "SubCategory", + ); + + return filteredPages.map((page, index) => { + const position = index + 1; + const slug = filteredPages.slice(0, position).map((x) => slugify(x.name!)); + + return { + "@type": "ListItem" as const, + name: page.name!, + item: new URL(`/${slug.join("/")}`, baseUrl).href, + position, + }; + }); +}; + +export const pageTypesToSeo = ( + pages: PageType[], + baseUrl: string, + currentPage?: number, +): Seo | null => { + const current = pages.at(-1); + const url = new URL(baseUrl); + const fullTextSearch = url.searchParams.get("q"); + const hasMapTermOrSkuId = !!(url.searchParams.get("map") || url.searchParams.get("skuId")); + + if ( + (!current || current.pageType === "Search" || current.pageType === "FullText") && + fullTextSearch + ) { + return { + title: capitalize(fullTextSearch), + description: capitalize(fullTextSearch), + canonical: url.href, + noIndexing: hasMapTermOrSkuId, + }; + } + + if (!current) { + return null; + } + + return { + title: current.title || current.name || "", + description: current.metaTagDescription!, + noIndexing: hasMapTermOrSkuId, + canonical: toCanonical( + new URL( + current.url && current.pageType !== "Collection" + ? current.url.replace(/^[^/]*\//, "/").toLowerCase() + : url, + url, + ), + currentPage, + ), + }; +}; + +function toCanonical(url: URL, page?: number) { + if (typeof page === "number") { + url.searchParams.set("page", `${page}`); + } + + return url.href; +} + +export { isFilterParam } from "./intelligentSearch"; diff --git a/packages/apps-vtex/src/utils/minicart.ts b/packages/apps-vtex/src/utils/minicart.ts new file mode 100644 index 0000000..e61f410 --- /dev/null +++ b/packages/apps-vtex/src/utils/minicart.ts @@ -0,0 +1,139 @@ +/** + * Map a VTEX OrderForm to the canonical `Minicart` contract. + * + * Pure function — no I/O, fully unit-testable. Pricing is converted from + * VTEX's native cents to major units (the canonical unit for `Minicart`). + * + * Locale and currency come from `orderForm.storePreferencesData` and follow + * VTEX's `storePreferencesData.countryCode` / `currencyCode` semantics. + * + * @example + * ```ts + * import { vtexOrderFormToMinicart } from "@decocms/apps/vtex/utils/minicart"; + * import { getCart } from "@decocms/apps/vtex/loaders/cart"; + * + * const orderForm = await getCart(orderFormId); + * const minicart = vtexOrderFormToMinicart(orderForm, { + * freeShippingTarget: 0, + * checkoutHref: "/checkout", + * }); + * ``` + */ + +import type { Minicart, MinicartItem } from "@decocms/apps-commerce/types"; +import type { OrderForm, OrderFormItem, Totalizer } from "../types"; + +export interface VtexOrderFormToMinicartOptions { + /** Free-shipping threshold in major units. `0` disables the progress bar. */ + freeShippingTarget?: number; + /** Override the OrderForm's `clientPreferencesData.locale` (BCP-47, e.g. `"pt-BR"`). */ + locale?: string; + /** Where the checkout button sends the user. Default: `/checkout`. */ + checkoutHref?: string; + /** Whether the UI should expose the coupon input. Default: `true`. */ + enableCoupon?: boolean; +} + +const CENTS_PER_MAJOR = 100; + +/** Convert VTEX cents to major units. Always returns a finite number. */ +function fromCents(cents: number | undefined | null): number { + if (cents == null || !Number.isFinite(cents)) return 0; + return cents / CENTS_PER_MAJOR; +} + +function findTotalizer(totalizers: Totalizer[] | undefined, id: string): number { + if (!totalizers) return 0; + const t = totalizers.find((x) => x.id === id); + return t?.value ?? 0; +} + +/** + * Locale heuristic. VTEX exposes `clientPreferencesData.locale` when set, but + * otherwise we synthesize one from `storePreferencesData.countryCode` so the UI + * always has a usable value for `Intl.NumberFormat`. + */ +function inferLocale(orderForm: OrderForm, override?: string): string { + if (override) return override; + const explicit = orderForm.clientPreferencesData?.locale; + if (explicit) return explicit; + + const country = orderForm.storePreferencesData?.countryCode; + if (country === "BRA" || country === "BR") return "pt-BR"; + if (country === "USA" || country === "US") return "en-US"; + return "en-US"; +} + +function vtexItemToMinicartItem(item: OrderFormItem, index: number, coupon?: string): MinicartItem { + const sellingPrice = fromCents(item.sellingPrice ?? item.price); + const listPrice = fromCents(item.listPrice ?? item.price); + const discount = Math.max(0, listPrice - sellingPrice); + + return { + // AnalyticsItem identifier — VTEX uses productId; sites map to numeric SKU + // when needed via `Number(item.item_id)` (see bagaggio Minicart). + item_id: item.id, + item_group_id: item.productId, + item_name: item.name ?? item.skuName ?? "", + item_variant: item.skuName, + item_brand: item.additionalInfo?.brandName ?? undefined, + item_url: item.detailUrl, + coupon, + affiliation: item.seller, + index, + // Cart-required fields + image: item.imageUrl?.replace(/^http:/, "https:") ?? "", + listPrice, + price: sellingPrice, + quantity: item.quantity, + discount: Number(discount.toFixed(2)), + // Platform-specific + seller: item.seller, + attachments: item.attachments as MinicartItem["attachments"], + attachmentOfferings: item.attachmentOfferings as MinicartItem["attachmentOfferings"], + }; +} + +/** + * Map a VTEX `OrderForm` to the canonical platform-agnostic `Minicart`. + * + * @param orderForm - Result from `getCart()` or `getOrCreateCart()`. + * @param opts - Storefront-level overrides (free-shipping target, checkout href, ...). + */ +export function vtexOrderFormToMinicart( + orderForm: OrderForm, + opts: VtexOrderFormToMinicartOptions = {}, +): Minicart { + const totalizers = orderForm.totalizers; + const subtotal = fromCents(findTotalizer(totalizers, "Items")); + const discountsRaw = findTotalizer(totalizers, "Discounts"); + const discounts = Math.abs(fromCents(discountsRaw)); + const shippingRaw = findTotalizer(totalizers, "Shipping"); + const shipping = totalizers?.some((t) => t.id === "Shipping") + ? fromCents(shippingRaw) + : undefined; + const total = fromCents(orderForm.value); + + const coupon = orderForm.marketingData?.coupon; + const items = (orderForm.items ?? []).map((item, index) => + vtexItemToMinicartItem(item, index, coupon), + ); + + return { + original: orderForm, + storefront: { + items, + subtotal, + discounts, + shipping, + total, + coupon, + locale: inferLocale(orderForm, opts.locale), + currency: orderForm.storePreferencesData?.currencyCode ?? "BRL", + enableCoupon: opts.enableCoupon ?? true, + freeShippingTarget: opts.freeShippingTarget ?? 0, + checkoutHref: opts.checkoutHref ?? "/checkout", + postalCode: orderForm.shippingData?.address?.postalCode ?? undefined, + }, + }; +} diff --git a/packages/apps-vtex/src/utils/operationRouter.ts b/packages/apps-vtex/src/utils/operationRouter.ts new file mode 100644 index 0000000..4566f71 --- /dev/null +++ b/packages/apps-vtex/src/utils/operationRouter.ts @@ -0,0 +1,139 @@ +/** + * URL-derived operation name router for VTEX API calls. + * + * Plugged into `@decocms/start`'s `createInstrumentedFetch` via the + * `resolveOperation(url, method)` option. The resolved string becomes the + * span suffix (`vtex.`) and the `fetch.operation` span + + * histogram label, so it must be: + * + * - low-cardinality (no IDs, slugs, search terms, account names); + * - stable across deploys (used for alerting + dashboards); + * - human-debuggable in a trace view. + * + * The router is intentionally a flat ordered list of regex matchers, + * not a tree. Adding/auditing routes is a one-line patch and routes + * are evaluated in priority order (most specific first). Unknown URLs + * return `undefined` so the framework falls back to the generic + * `vtex.fetch` span name — observable, just less specific. + * + * Callers that need finer granularity than the URL can express (e.g. + * `POST /orderForm/{id}/items` is one URL but covers add / update / + * remove flows) should set `init.operation` explicitly per call; that + * always wins over the router. + */ + +type OperationResolver = string | ((match: RegExpMatchArray, method: string) => string); + +interface Matcher { + pattern: RegExp; + operation: OperationResolver; +} + +const m = (pattern: RegExp, operation: OperationResolver): Matcher => ({ pattern, operation }); + +/** + * Ordered list of `(regex, operation)` matchers. The first match wins. + * + * Patterns match against the URL pathname (the host is ignored — VTEX + * spreads the same API surface across `*.vtexcommercestable.*`, + * `*.myvtex.com`, and storefront origins, all on identical paths). + * + * Operation strings are bare (no `vtex.` prefix) — the framework + * prefixes them with the integration name at span time. + */ +const MATCHERS: ReadonlyArray = [ + m(/^\/api\/io\/_v\/api\/intelligent-search\/([a-z_-]+)/, (mm) => `intelligent-search.${mm[1]}`), + m(/^\/_v\/private\/graphql\/v1/, "io.graphql"), + m(/^\/_v\/segment\//, "io.segment"), + + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/items\/update/, (_mm, method) => + method === "POST" ? "checkout.orderform.items.update" : "checkout.orderform.items", + ), + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/items/, (_mm, method) => { + if (method === "DELETE") return "checkout.orderform.items.remove"; + if (method === "PATCH" || method === "PUT") return "checkout.orderform.items.update"; + return "checkout.orderform.items.add"; + }), + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/coupons/, "checkout.orderform.coupons"), + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/profile/, "checkout.orderform.profile"), + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/shippingData/, "checkout.orderform.shipping"), + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/paymentData/, "checkout.orderform.payment"), + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+/, (_mm, method) => + method === "GET" ? "checkout.orderform.get" : "checkout.orderform.update", + ), + m(/^\/api\/checkout\/pub\/orderForm(?:\/?$)/, (_mm, method) => + method === "POST" ? "checkout.orderform.create" : "checkout.orderform.get", + ), + m(/^\/api\/checkout\/pub\/orderForms\/simulation/, "checkout.simulation"), + m(/^\/api\/checkout\/pub\/regions/, "checkout.regions"), + m(/^\/api\/checkout\/pub\/postal-code/, "checkout.postal-code"), + + m(/^\/api\/sessions/, (_mm, method) => (method === "POST" ? "sessions.update" : "sessions.get")), + m(/^\/api\/segments\//, "segments.get"), + + m(/^\/api\/catalog_system\/pub\/portal\/pagetype\//, "catalog.pagetype"), + m( + /^\/api\/catalog_system\/pub\/products\/crossselling\/([^/]+)/, + (mm) => `catalog.crossselling.${mm[1]}`, + ), + m(/^\/api\/catalog_system\/pub\/products\/variations\//, "catalog.products.variations"), + m(/^\/api\/catalog_system\/pub\/products\/search/, "catalog.products.search"), + m(/^\/api\/catalog_system\/pub\/facets\/search/, "catalog.facets.search"), + m(/^\/api\/catalog_system\/pub\/category\/tree/, "catalog.category.tree"), + m(/^\/api\/catalog_system\/(?:pub|pvt)\/specification/, "catalog.specification"), + m(/^\/api\/catalog_system\/pub\/brand/, "catalog.brand"), + m(/^\/api\/catalog_system\/pvt\/sku\//, "catalog.sku"), + m(/^\/api\/catalog_system\//, "catalog.other"), + + m(/^\/api\/wishlist\//, "wishlist"), + m(/^\/api\/profile-system\/profile\//, "profile"), + m(/^\/api\/dataentities\/([^/]+)/, (mm) => `masterdata.${mm[1]}`), + + m(/^\/api\/oms\/user\/orders\/[^/]+\/cancel/, "oms.orders.cancel"), + m(/^\/api\/oms\/user\/orders/, "oms.orders"), + m(/^\/api\/oms\/pvt\/orders/, "oms.orders.pvt"), + + m(/^\/api\/vtexid\/pub\/logout/, "vtexid.logout"), + m(/^\/api\/vtexid\/pub\/authentication\/start/, "vtexid.authentication.start"), + m(/^\/api\/vtexid\/pub\/authentication\/[a-z]+\/validate/, "vtexid.authentication.validate"), + m(/^\/api\/vtexid\/pub\/authenticated\/user/, "vtexid.user"), + m(/^\/api\/vtexid\//, "vtexid.other"), + + m(/^\/api\/events\/v1\//, "events.send"), + m(/^\/sitemap.*\.xml$/, "sitemap"), + m(/^\/api\/license-manager/, "license-manager"), +]; + +/** + * Resolve an operation name for a VTEX URL. Returns `undefined` if no + * matcher fires, which causes the framework to fall back to + * `vtex.fetch`. + * + * Designed to be passed directly to `createInstrumentedFetch`: + * + * ```ts + * createInstrumentedFetch({ + * name: "vtex", + * resolveOperation: vtexOperationRouter, + * }); + * ``` + */ +export function vtexOperationRouter(url: string, method: string): string | undefined { + let pathname: string; + try { + pathname = new URL(url).pathname; + } catch { + const qs = url.indexOf("?"); + const hash = url.indexOf("#"); + const end = [qs, hash].filter((i) => i >= 0).sort((a, b) => a - b)[0]; + pathname = end === undefined ? url : url.slice(0, end); + } + + const upperMethod = method.toUpperCase(); + for (const { pattern, operation } of MATCHERS) { + const match = pathname.match(pattern); + if (!match) continue; + return typeof operation === "function" ? operation(match, upperMethod) : operation; + } + return undefined; +} diff --git a/packages/apps-vtex/src/utils/pickAndOmit.ts b/packages/apps-vtex/src/utils/pickAndOmit.ts new file mode 100644 index 0000000..30f1e7c --- /dev/null +++ b/packages/apps-vtex/src/utils/pickAndOmit.ts @@ -0,0 +1,25 @@ +export function pick( + keys: K[], + obj: T | null | undefined, +): Pick | null { + if (!keys.length || !obj) { + return null; + } + + const entries = keys.map((key) => [key, obj[key]]); + + return Object.fromEntries(entries); +} + +export function omit( + keys: K[], + obj: T | null | undefined, +): Omit | null { + if (!keys.length || !obj) { + return null; + } + + const pickedKeys = (Object.keys(obj) as K[]).filter((key) => !keys.includes(key)); + + return pick(pickedKeys, obj) as unknown as Omit; +} diff --git a/packages/apps-vtex/src/utils/proxy.ts b/packages/apps-vtex/src/utils/proxy.ts new file mode 100644 index 0000000..bff7e04 --- /dev/null +++ b/packages/apps-vtex/src/utils/proxy.ts @@ -0,0 +1,435 @@ +/** + * VTEX Proxy Utility. + * + * Proxies storefront requests for /checkout, /account, /api, /files, /arquivos + * to the VTEX origin. Essential for checkout and My Account pages to work. + * + * Two flavors: + * - `proxyToVtex()` — simple single-origin proxy (vtexcommercestable) + * - `createVtexCheckoutProxy()` — production-grade dual-origin proxy with + * proper cookie attribute preservation, non-ASCII sanitization, and + * configurable origin routing (checkout UI vs API paths) + * + * Designed to be used with TanStack Start API routes or Cloudflare Worker + * fetch handlers. + */ + +import { getVtexConfig, getVtexFetch, type VtexConfig, vtexHost } from "../client"; +import { proxySetCookie } from "./cookies"; + +export interface VtexProxyOptions { + /** + * VTEX environment suffix. + * @default "vtexcommercestable" + */ + environment?: "vtexcommercestable" | "vtexcommercebeta"; + + /** + * Additional path prefixes to proxy beyond the defaults. + * Example: ["/custom-api/"] + */ + extraPaths?: string[]; + + /** + * Paths that should NOT be proxied even if they match a prefix. + */ + excludePaths?: string[]; + + /** + * Whether to rewrite Set-Cookie domains to the storefront's domain. + * @default true + */ + rewriteCookieDomain?: boolean; + + /** + * Custom headers to inject into every proxied request. + */ + extraHeaders?: Record; +} + +const DEFAULT_PROXY_PATHS = [ + "/checkout", + "/checkout/", + "/account", + "/account/", + "/api/", + "/files/", + "/arquivos/", + "/checkout/changeToAnonymousUser/", + "/_v/", + "/no-cache/", + "/graphql/", + "/login", + "/login/", + "/logout", + "/logout/", + "/assets/vtex", + "/_secure/account", + "/XMLData/", +] as const; + +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", +]); + +/** + * Returns all path prefixes that should be proxied to VTEX. + */ +export function getVtexProxyPaths(options?: VtexProxyOptions): string[] { + return [...DEFAULT_PROXY_PATHS, ...(options?.extraPaths ?? [])]; +} + +/** + * Check if a request path should be proxied to VTEX. + */ +export function shouldProxyToVtex(pathname: string, options?: VtexProxyOptions): boolean { + const paths = getVtexProxyPaths(options); + const excluded = options?.excludePaths ?? []; + + if (excluded.some((ex) => pathname.startsWith(ex))) return false; + return paths.some((prefix) => pathname.startsWith(prefix)); +} + +function buildOriginUrl(request: Request, config: VtexConfig, environment: string): URL { + const url = new URL(request.url); + const originHost = vtexHost(environment, config); + return new URL(`https://${originHost}${url.pathname}${url.search}`); +} + +/** + * Copy headers excluding hop-by-hop and Set-Cookie. + * + * Set-Cookie is excluded intentionally: Headers.forEach / .set() joins + * multiple Set-Cookie values with ", " which corrupts cookies containing + * commas (e.g. Expires dates). proxySetCookie handles Set-Cookie + * separately using Headers.getSetCookie() for correct multi-cookie support. + */ +function filterHeaders(headers: Headers): Headers { + const filtered = new Headers(); + headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if (lower === "set-cookie") return; + if (!HOP_BY_HOP_HEADERS.has(lower)) { + filtered.set(key, value); + } + }); + return filtered; +} + +/** + * Proxy a request to VTEX origin. + * + * Forwards the request with all cookies and headers, rewrites + * Set-Cookie domains on the response, and strips hop-by-hop headers. + * + * @example + * ```ts + * // In a TanStack Start API route or catch-all handler + * if (shouldProxyToVtex(url.pathname)) { + * return proxyToVtex(request); + * } + * ``` + */ +export async function proxyToVtex(request: Request, options?: VtexProxyOptions): Promise { + const config = getVtexConfig(); + const environment = options?.environment ?? "vtexcommercestable"; + + const originUrl = buildOriginUrl(request, config, environment); + const forwardHeaders = filterHeaders(new Headers(request.headers)); + + const requestUrl = new URL(request.url); + forwardHeaders.set("origin", request.headers.get("origin") ?? requestUrl.origin); + forwardHeaders.set("Host", originUrl.hostname); + forwardHeaders.set("X-Forwarded-Host", requestUrl.host); + forwardHeaders.set("X-Forwarded-Proto", "https"); + + if (options?.extraHeaders) { + for (const [k, v] of Object.entries(options.extraHeaders)) { + forwardHeaders.set(k, v); + } + } + + if (typeof config.appKey === "string" && typeof config.appToken === "string") { + forwardHeaders.set("X-VTEX-API-AppKey", config.appKey); + forwardHeaders.set("X-VTEX-API-AppToken", config.appToken); + } + + const init: RequestInit = { + method: request.method, + headers: forwardHeaders, + redirect: "manual", + }; + + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + // @ts-expect-error -- needed for streaming body in Workers + init.duplex = "half"; + } + + // Route through the configured VTEX fetch so traces / metrics / logs + // see the proxied origin call. The URL router classifies the call + // into the right `vtex..` bucket (e.g. `vtex.checkout.*`, + // `vtex.vtexid.logout`, `vtex.io.segment`) — no per-callsite hint + // needed because we're a generic forwarder. + const originResponse = await getVtexFetch()(originUrl.toString(), init); + + const responseHeaders = filterHeaders(new Headers(originResponse.headers)); + + proxySetCookie( + originResponse.headers, + responseHeaders, + options?.rewriteCookieDomain !== false ? requestUrl.origin : undefined, + ); + + if (originResponse.status >= 300 && originResponse.status < 400) { + const location = originResponse.headers.get("location"); + if (location) { + const originVtexHost = vtexHost(environment, config); + const storefrontOrigin = requestUrl.origin; + const vtexOrigin = `https://${originVtexHost}`; + const rewritten = location.replace(vtexOrigin, storefrontOrigin); + responseHeaders.set("location", rewritten); + } + } + + return new Response(originResponse.body, { + status: originResponse.status, + statusText: originResponse.statusText, + headers: responseHeaders, + }); +} + +// --------------------------------------------------------------------------- +// Production-grade checkout proxy factory +// --------------------------------------------------------------------------- + +export interface VtexCheckoutProxyConfig { + /** VTEX account name (e.g. "casaevideonewio"). */ + account: string; + + /** + * Store's public checkout domain (e.g. "secure.casaevideo.com.br"). + * Checkout UI, /files/, and /_v/private/graphql are routed here. + */ + checkoutOrigin: string; + + /** + * VTEX commerce-stable origin for API calls. + * @default `https://{account}.vtexcommercestable.com.br` + */ + apiOrigin?: string; + + /** + * myvtex origin — used for redirect rewriting. + * @default `https://{account}.myvtex.com` + */ + myvtexOrigin?: string; + + /** + * VTEX TLD — most accounts use `.com.br`, but some use `.com`. + * @default "com.br" + */ + domain?: string; + + /** + * Extra paths on which to force-expire cookies. + * Useful for logout: VTEX sends Max-Age=0 for auth cookies, but the + * checkout orderForm cookie sometimes survives. This appends explicit + * Set-Cookie: name=; Max-Age=0 entries. + */ + expireCookiesOnPaths?: Array<{ + pathPrefix: string; + cookies: string[]; + }>; + + /** + * Optional HTML transform for checkout pages. + * Receives the full HTML string and should return the modified version. + */ + htmlTransform?: (html: string) => string; +} + +const CF_INTERNAL_HEADERS = new Set([ + "cf-connecting-ip", + "cf-ipcountry", + "cf-ray", + "cf-visitor", + "cf-ew-via", + "cdn-loop", +]); + +const CHECKOUT_SKIP_HEADERS = new Set([ + ...HOP_BY_HOP_HEADERS, + "set-cookie", + ...CF_INTERNAL_HEADERS, +]); + +const toAscii = (v: string) => v.replace(/[^\x20-\x7E]/g, ""); + +function filterHeadersStrict(headers: Headers): Headers { + const filtered = new Headers(); + headers.forEach((value, key) => { + if (CHECKOUT_SKIP_HEADERS.has(key.toLowerCase())) return; + try { + filtered.set(key, toAscii(value)); + } catch { + // skip headers that still fail after sanitization + } + }); + return filtered; +} + +/** + * Rewrite Set-Cookie headers: only change the Domain attribute. + * Unlike `proxySetCookie`, this preserves ALL attributes (Max-Age, + * Expires, SameSite, etc.) which is critical for logout. + */ +function rewriteSetCookieDomain(from: Headers, to: Headers, toHostname: string) { + const raw: string[] = + typeof from.getSetCookie === "function" + ? from.getSetCookie() + : (from.get("set-cookie") ?? "").split(/,(?=[^ ]+=)/).filter(Boolean); + + for (const cookie of raw) { + const rewritten = cookie.replace(/Domain=[^;]*/i, `Domain=${toHostname}`); + to.append("Set-Cookie", rewritten); + } +} + +/** + * Creates a production-grade VTEX checkout proxy handler. + * + * Routes checkout UI pages to the store's public domain and API calls + * to vtexcommercestable. Properly rewrites Set-Cookie domains (preserving + * Max-Age/Expires), sanitizes non-ASCII headers, filters hop-by-hop and + * CF-internal headers, and rewrites Location redirects. + * + * Returns a function compatible with `createDecoWorkerEntry`'s `proxyHandler`. + * + * @example + * ```ts + * const vtexProxy = createVtexCheckoutProxy({ + * account: "casaevideonewio", + * checkoutOrigin: "secure.casaevideo.com.br", + * expireCookiesOnPaths: [ + * { pathPrefix: "/api/vtexid/pub/logout", cookies: ["checkout.vtex.com"] }, + * ], + * htmlTransform: (html) => + * html.replace("", ""), + * }); + * + * createDecoWorkerEntry(serverEntry, { + * proxyHandler: async (request, url) => { + * if (url.pathname === "/login") return null; + * if (!shouldProxyToVtex(url.pathname)) return null; + * return vtexProxy(request, url); + * }, + * }); + * ``` + */ +export function createVtexCheckoutProxy( + config: VtexCheckoutProxyConfig, +): (request: Request, url: URL) => Promise { + const domain = config.domain ?? "com.br"; + const checkoutOrigin = config.checkoutOrigin.startsWith("https://") + ? config.checkoutOrigin + : `https://${config.checkoutOrigin}`; + const apiOrigin = config.apiOrigin ?? `https://${config.account}.vtexcommercestable.${domain}`; + const myvtexOrigin = config.myvtexOrigin ?? `https://${config.account}.myvtex.com`; + + function getOrigin(pathname: string, method: string): string { + if ( + pathname.startsWith("/checkout") || + pathname.startsWith("/account") || + pathname.startsWith("/_secure/account") || + pathname.startsWith("/files/") || + pathname.startsWith("/_v/private/graphql") + ) { + return checkoutOrigin; + } + if (method !== "GET" && method !== "HEAD" && pathname.startsWith("/_v/")) { + return checkoutOrigin; + } + return apiOrigin; + } + + return async (request: Request, url: URL): Promise => { + const origin = getOrigin(url.pathname, request.method); + const originUrl = new URL(`${origin}${url.pathname}${url.search}`); + const fwd = filterHeadersStrict(new Headers(request.headers)); + + fwd.set("Host", originUrl.hostname); + fwd.set("X-Forwarded-Host", url.host); + fwd.set("X-Forwarded-Proto", "https"); + fwd.set("origin", request.headers.get("origin") ?? url.origin); + + const isCheckoutUI = + url.pathname.startsWith("/checkout") || url.pathname.startsWith("/account"); + const isLogout = url.pathname.startsWith("/api/vtexid/pub/logout"); + + const init: RequestInit = { + method: request.method, + headers: fwd, + redirect: isCheckoutUI || isLogout ? "manual" : "follow", + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + // @ts-expect-error -- needed for streaming body in Workers + init.duplex = "half"; + } + + const originRes = await getVtexFetch()(originUrl.toString(), init); + const resHeaders = filterHeadersStrict(new Headers(originRes.headers)); + rewriteSetCookieDomain(originRes.headers, resHeaders, url.hostname); + + // Force-expire cookies on configured paths + if (config.expireCookiesOnPaths) { + for (const rule of config.expireCookiesOnPaths) { + if (url.pathname.startsWith(rule.pathPrefix)) { + for (const name of rule.cookies) { + resHeaders.append("Set-Cookie", `${name}=; Path=/; Max-Age=0; Domain=${url.hostname}`); + } + } + } + } + + // Rewrite redirect Location headers from VTEX domains to storefront + if (originRes.status >= 300 && originRes.status < 400) { + const loc = originRes.headers.get("location"); + if (loc) { + resHeaders.set( + "location", + loc + .replace(checkoutOrigin, url.origin) + .replace(apiOrigin, url.origin) + .replace(myvtexOrigin, url.origin), + ); + } + } + + // HTML transform for checkout pages + const ct = originRes.headers.get("content-type") ?? ""; + if (config.htmlTransform && ct.includes("text/html")) { + const html = await originRes.text(); + const patched = config.htmlTransform(html); + return new Response(patched, { + status: originRes.status, + statusText: originRes.statusText, + headers: resHeaders, + }); + } + + return new Response(originRes.body, { + status: originRes.status, + statusText: originRes.statusText, + headers: resHeaders, + }); + }; +} diff --git a/packages/apps-vtex/src/utils/resourceRange.ts b/packages/apps-vtex/src/utils/resourceRange.ts new file mode 100644 index 0000000..6e549e8 --- /dev/null +++ b/packages/apps-vtex/src/utils/resourceRange.ts @@ -0,0 +1,10 @@ +/** + * Build REST-Range header values for VTEX paginated APIs. + * Ported from deco-cx/apps vtex/utils/resourceRange.ts + */ +export function resourceRange(skip: number, take: number) { + const from = Math.max(skip, 0); + const to = from + Math.min(100, take); + + return { from, to }; +} diff --git a/packages/apps-vtex/src/utils/segment.ts b/packages/apps-vtex/src/utils/segment.ts new file mode 100644 index 0000000..98b9d81 --- /dev/null +++ b/packages/apps-vtex/src/utils/segment.ts @@ -0,0 +1,152 @@ +import type { Segment } from "./types"; + +const removeNonLatin1Chars = (str: string) => str.replace(/[^\x00-\xFF]/g, ""); + +export const SEGMENT_COOKIE_NAME = "vtex_segment"; +export const SALES_CHANNEL_COOKIE = "VTEXSC"; + +export interface WrappedSegment { + payload: Partial; + token: string; +} + +export const DEFAULT_SEGMENT: Partial = { + utmi_campaign: null, + utmi_page: null, + utmi_part: null, + utm_campaign: null, + utm_source: null, + utm_medium: null, + channel: "1", + cultureInfo: "pt-BR", + currencyCode: "BRL", + currencySymbol: "R$", + countryCode: "BRA", +}; + +/** + * Stable serialization. + * + * Even if attributes are in a different order, the final segment + * value will be the same. This improves cache hits. + */ +export const serializeSegment = ({ + campaigns, + channel, + priceTables, + regionId, + utm_campaign, + utm_source, + utm_medium, + utmi_campaign, + utmi_page, + utmi_part, + currencyCode, + currencySymbol, + countryCode, + cultureInfo, + channelPrivacy, +}: Partial): string => { + const seg = { + campaigns, + channel, + priceTables, + regionId, + utm_campaign: utm_campaign && removeNonLatin1Chars(utm_campaign).replace(/[/[\]{}()<>.]/g, ""), + utm_source: utm_source && removeNonLatin1Chars(utm_source).replace(/[/[\]{}()<>.]/g, ""), + utm_medium: utm_medium && removeNonLatin1Chars(utm_medium).replace(/[/[\]{}()<>.]/g, ""), + utmi_campaign: utmi_campaign && removeNonLatin1Chars(utmi_campaign), + utmi_page: utmi_page && removeNonLatin1Chars(utmi_page), + utmi_part: utmi_part && removeNonLatin1Chars(utmi_part), + currencyCode, + currencySymbol, + countryCode, + cultureInfo, + channelPrivacy, + }; + return btoa(JSON.stringify(seg)); +}; + +export const parseSegment = (cookie: string): Partial | null => { + try { + return JSON.parse(atob(cookie)); + } catch { + return null; + } +}; + +const SEGMENT_QUERY_PARAMS = [ + "utmi_campaign", + "utmi_page", + "utmi_part", + "utm_campaign", + "utm_source", + "utm_medium", +] as const; + +export const buildSegmentFromParams = (searchParams: URLSearchParams): Partial => { + const partialSegment: Partial = {}; + for (const qs of SEGMENT_QUERY_PARAMS) { + const param = searchParams.get(qs); + if (param) { + partialSegment[qs] = param; + } + } + + const sc = searchParams.get("sc"); + if (sc) { + partialSegment.channel = sc; + } + + return partialSegment; +}; + +export const withSegmentCookie = (segment: WrappedSegment, headers?: Headers): Headers => { + const h = new Headers(headers); + if (!segment) return h; + + h.set("cookie", `${SEGMENT_COOKIE_NAME}=${segment.token}`); + return h; +}; + +function getCookieValue(cookieHeader: string, name: string): string | null { + const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)); + return match?.[1] ?? null; +} + +/** + * Build a complete segment from request cookies. + * Reads both vtex_segment and VTEXSC cookies. + * VTEXSC contains the sales channel and overrides the segment channel. + */ +export const buildSegmentFromCookies = (cookieHeader: string): Partial => { + const segmentCookie = getCookieValue(cookieHeader, SEGMENT_COOKIE_NAME); + const vtexsc = getCookieValue(cookieHeader, SALES_CHANNEL_COOKIE); + + const base = segmentCookie ? parseSegment(segmentCookie) : null; + const segment: Partial = { ...DEFAULT_SEGMENT, ...base }; + + if (vtexsc) { + segment.channel = vtexsc; + } + + return segment; +}; + +/** + * Check if the current segment represents an anonymous user + * (no campaigns, no UTMs, no regionId, no custom priceTables). + */ +export const isAnonymous = (segment: Partial): boolean => { + return ( + !segment.campaigns && + !segment.utm_campaign && + !segment.utm_source && + !segment.utm_medium && + !segment.utmi_campaign && + !segment.utmi_page && + !segment.utmi_part && + !segment.regionId && + !segment.priceTables + ); +}; diff --git a/packages/apps-vtex/src/utils/similars.ts b/packages/apps-vtex/src/utils/similars.ts new file mode 100644 index 0000000..f00e734 --- /dev/null +++ b/packages/apps-vtex/src/utils/similars.ts @@ -0,0 +1,37 @@ +import type { Product } from "@decocms/apps-commerce/types"; +import { getVtexConfig, vtexFetch } from "../client"; +import { pickSku, toProduct } from "./transform"; +import type { LegacyProduct } from "./types"; + +export const withIsSimilarTo = async (product: Product): Promise => { + const id = product.isVariantOf?.productGroupID ?? product.inProductGroupWithID; + + if (!id) { + return product; + } + + try { + const rawSimilars = await vtexFetch( + `/api/catalog_system/pub/products/crossselling/similars/${id}`, + ); + + if (!rawSimilars?.length) return product; + + const config = getVtexConfig(); + const baseUrl = config.publicUrl + ? `https://${config.publicUrl}` + : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`; + + const similars = rawSimilars.map((p) => { + const sku = pickSku(p); + return toProduct(p, sku, 0, { baseUrl, priceCurrency: "BRL" }); + }); + + return { + ...product, + isSimilarTo: similars, + }; + } catch { + return product; + } +}; diff --git a/packages/apps-vtex/src/utils/sitemap.ts b/packages/apps-vtex/src/utils/sitemap.ts new file mode 100644 index 0000000..e4035c0 --- /dev/null +++ b/packages/apps-vtex/src/utils/sitemap.ts @@ -0,0 +1,282 @@ +/** + * VTEX Sitemap utilities. + * + * Two flavors: + * - `getVtexSitemapEntries()` — flatten VTEX sub-sitemaps into a single + * `SitemapEntry[]` list, for composition with the CMS sitemap generator. + * - `createVtexSitemapProxy()` — proxy `/sitemap.xml` and `/sitemap/*` + * straight from VTEX's commerce-stable origin, preserving the sitemap-index + * shape (so crawlers stay within Google's per-file size limit). This is the + * right choice when the storefront has no native sitemap renderer and just + * needs to expose VTEX's existing crawl tree to the public hostname. + */ + +import { getVtexConfig, vtexFetchResponse, vtexHost } from "../client"; + +export interface SitemapEntry { + loc: string; + lastmod?: string; + changefreq?: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never"; + priority?: number; +} + +/** + * Fetch sitemap entries from VTEX's sitemap API. + * + * VTEX exposes /sitemap.xml which contains links to sub-sitemaps + * (products, categories, brands, etc.). This function fetches the + * main sitemap index and extracts all entries from the + * referenced sub-sitemaps. + * + * @param origin - The storefront origin (e.g., "https://www.mystore.com") + * @param options.maxDepth - How many levels of sub-sitemaps to follow (default: 1) + * @param options.rewriteHost - Whether to rewrite VTEX hostnames to the storefront origin (default: true) + */ +export async function getVtexSitemapEntries( + origin: string, + options?: { + maxDepth?: number; + rewriteHost?: boolean; + includeBrands?: boolean; + includeCategories?: boolean; + includeProducts?: boolean; + }, +): Promise { + const config = getVtexConfig(); + const vtexSitemapHost = vtexHost("vtexcommercestable", config); + const rewrite = options?.rewriteHost !== false; + const includeProducts = options?.includeProducts !== false; + const includeCategories = options?.includeCategories !== false; + const includeBrands = options?.includeBrands !== false; + + try { + const mainSitemapUrl = `https://${vtexSitemapHost}/sitemap.xml`; + const mainResponse = await vtexFetchResponse(mainSitemapUrl); + const mainXml = await mainResponse.text(); + + const subSitemapUrls = extractLocs(mainXml); + const entries: SitemapEntry[] = []; + + const filteredUrls = subSitemapUrls.filter((url) => { + const lower = url.toLowerCase(); + if (!includeProducts && lower.includes("product")) return false; + if (!includeCategories && lower.includes("categor")) return false; + if (!includeBrands && lower.includes("brand")) return false; + return true; + }); + + const maxDepth = options?.maxDepth ?? 1; + if (maxDepth < 1) { + return filteredUrls.map((url) => ({ + loc: rewrite ? rewriteUrl(url, vtexSitemapHost, origin) : url, + changefreq: "daily" as const, + priority: 0.5, + })); + } + + const settled = await Promise.allSettled( + filteredUrls.map(async (subUrl) => { + try { + const resp = await vtexFetchResponse(subUrl); + const xml = await resp.text(); + return extractLocs(xml); + } catch { + return []; + } + }), + ); + + const today = new Date().toISOString().split("T")[0]; + + for (const result of settled) { + if (result.status !== "fulfilled") continue; + for (const loc of result.value) { + entries.push({ + loc: rewrite ? rewriteUrl(loc, vtexSitemapHost, origin) : loc, + lastmod: today, + changefreq: "daily", + priority: 0.5, + }); + } + } + + return entries; + } catch (error) { + console.error("[VTEX Sitemap] Failed to fetch VTEX sitemap:", error); + return []; + } +} + +function extractLocs(xml: string): string[] { + const locs: string[] = []; + const regex = /\s*(.*?)\s*<\/loc>/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(xml)) !== null) { + if (match[1]) locs.push(match[1].trim()); + } + return locs; +} + +function rewriteUrl(url: string, vtexSitemapHost: string, origin: string): string { + try { + const parsed = new URL(url); + const originParsed = new URL(origin); + const config = getVtexConfig(); + const domain = config.domain ?? "com.br"; + if ( + parsed.hostname === vtexSitemapHost || + parsed.hostname.endsWith(`.vtexcommercestable.${domain}`) + ) { + parsed.protocol = originParsed.protocol; + parsed.hostname = originParsed.hostname; + parsed.port = originParsed.port; + } + return parsed.toString(); + } catch { + return url.replace(`https://${vtexSitemapHost}`, origin); + } +} + +// --------------------------------------------------------------------------- +// VTEX sitemap proxy factory +// --------------------------------------------------------------------------- + +/** + * Returns true if `pathname` is one of the proxied sitemap paths + * (`/sitemap.xml` or any `/sitemap/*` sub-sitemap). + */ +export function isVtexSitemapPath(pathname: string): boolean { + return pathname === "/sitemap.xml" || pathname.startsWith("/sitemap/"); +} + +export interface VtexSitemapProxyConfig { + /** + * Extra `` entries to inject into the root sitemap index + * (`/sitemap.xml` only — sub-sitemaps are passed through untouched). + * + * Useful for site-managed sitemaps such as a static search-result + * index (`sitemap-busca.xml`) that VTEX doesn't generate. + * + * Each value is normalized to an absolute URL on the storefront + * origin: leading-slash paths become `${origin}${path}`, and bare + * names become `${origin}/${name}`. Absolute URLs are used as-is. + * + * @example ["/sitemap-busca.xml"] + */ + extraSitemaps?: string[]; + + /** + * VTEX environment for the upstream sitemap fetch. + * @default "vtexcommercestable" + */ + environment?: "vtexcommercestable" | "vtexcommercebeta"; + + /** + * `Cache-Control` header to set on proxied responses. The default + * favors edge caching (Cloudflare honors `s-maxage`) with a long + * stale-while-revalidate window so a slow VTEX origin never blocks + * crawlers. + * + * @default "public, s-maxage=3600, stale-while-revalidate=86400" + */ + cacheControl?: string; + + /** + * Optional fetch override — primarily for tests. Defaults to the + * platform `fetch`. + */ + fetchImpl?: typeof fetch; +} + +const DEFAULT_SITEMAP_CACHE_CONTROL = "public, s-maxage=3600, stale-while-revalidate=86400"; + +function normalizeExtraSitemap(entry: string, origin: string): string { + if (entry.startsWith("http://") || entry.startsWith("https://")) return entry; + const path = entry.startsWith("/") ? entry : `/${entry}`; + return `${origin}${path}`; +} + +/** + * Creates a sitemap proxy handler that mirrors VTEX's `/sitemap.xml` + * (and sub-sitemaps) onto the storefront origin. + * + * Returns a function compatible with `createDecoWorkerEntry`'s + * `proxyHandler`: it returns `null` for non-sitemap paths, so it + * composes naturally with other proxy handlers + * (`createVtexCheckoutProxy`, custom logic, etc.). + * + * The VTEX account is read from the `configureVtex(...)` call done at + * worker startup — no per-call account configuration is needed. + * + * @example + * ```ts + * import { createVtexSitemapProxy } from "@decocms/apps/vtex/utils/sitemap"; + * import { + * createVtexCheckoutProxy, + * shouldProxyToVtex, + * } from "@decocms/apps/vtex/utils/proxy"; + * + * const proxySitemap = createVtexSitemapProxy({ + * extraSitemaps: ["/sitemap-busca.xml"], // optional, site-managed + * }); + * const proxyCheckout = createVtexCheckoutProxy({ ... }); + * + * createDecoWorkerEntry(serverEntry, { + * proxyHandler: async (request, url) => { + * const sitemap = await proxySitemap(request, url); + * if (sitemap) return sitemap; + * + * if (!shouldProxyToVtex(url.pathname)) return null; + * return proxyCheckout(request, url); + * }, + * }); + * ``` + */ +export function createVtexSitemapProxy( + config: VtexSitemapProxyConfig = {}, +): (request: Request, url: URL) => Promise { + const environment = config.environment ?? "vtexcommercestable"; + const cacheControl = config.cacheControl ?? DEFAULT_SITEMAP_CACHE_CONTROL; + const extraSitemaps = config.extraSitemaps ?? []; + const fetchImpl = config.fetchImpl ?? fetch; + + return async (_request: Request, url: URL): Promise => { + if (!isVtexSitemapPath(url.pathname)) return null; + + // vtexHost() reads the configured account from configureVtex(). + const vtexSitemapHost = vtexHost(environment); + const target = `https://${vtexSitemapHost}${url.pathname}`; + + try { + const resp = await fetchImpl(target); + if (!resp.ok) { + console.error(`[vtex-sitemap] VTEX returned ${resp.status} for ${url.pathname}`); + return new Response("Sitemap temporarily unavailable", { status: 502 }); + } + + let xml = await resp.text(); + xml = xml.replaceAll(`https://${vtexSitemapHost}`, url.origin); + + if (url.pathname === "/sitemap.xml" && extraSitemaps.length > 0) { + const extraEntries = extraSitemaps + .map( + (s) => + ` \n ${normalizeExtraSitemap(s, url.origin)}\n `, + ) + .join("\n"); + xml = xml.replace("", `${extraEntries}\n`); + } + + return new Response(xml, { + status: 200, + headers: { + "Content-Type": "application/xml; charset=utf-8", + "Cache-Control": cacheControl, + }, + }); + } catch (err) { + console.error("[vtex-sitemap] Failed to proxy VTEX sitemap:", err); + return new Response("Sitemap temporarily unavailable", { status: 502 }); + } + }; +} diff --git a/packages/apps-vtex/src/utils/slugCache.ts b/packages/apps-vtex/src/utils/slugCache.ts new file mode 100644 index 0000000..5ba86aa --- /dev/null +++ b/packages/apps-vtex/src/utils/slugCache.ts @@ -0,0 +1,28 @@ +/** + * In-flight dedup + SWR cache for VTEX Legacy Catalog slug→product lookups. + * + * Multiple loaders on the same page (PDP, relatedProducts x3, BuyTogether) + * all call `/api/catalog_system/pub/products/search/{slug}/p` for the same slug. + * This module routes through vtexCachedFetch which provides in-flight dedup + * and stale-while-revalidate caching (3 min TTL for 200 responses). + */ +import { getVtexConfig, vtexCachedFetch } from "../client"; +import type { LegacyProduct } from "./types"; + +export function searchBySlug(linkText: string): Promise { + const config = getVtexConfig(); + const sc = config.salesChannel; + const scParam = sc ? `?sc=${sc}` : ""; + + return vtexCachedFetch( + `/api/catalog_system/pub/products/search/${encodeURIComponent(linkText)}/p${scParam}`, + ).catch((err) => { + console.error(`[VTEX] searchBySlug error for "${linkText}":`, err); + return null; + }); +} + +export async function resolveProductIdBySlug(linkText: string): Promise { + const products = await searchBySlug(linkText); + return products?.length ? products[0].productId : null; +} diff --git a/packages/apps-vtex/src/utils/slugify.ts b/packages/apps-vtex/src/utils/slugify.ts new file mode 100644 index 0000000..a30b997 --- /dev/null +++ b/packages/apps-vtex/src/utils/slugify.ts @@ -0,0 +1,13 @@ +const mapped = JSON.parse( + `{"Á":"A","Ä":"A","Â":"A","À":"A","Ã":"A","Å":"A","Č":"C","Ç":"C","Ć":"C","Ď":"D","É":"E","Ě":"E","Ë":"E","È":"E","Ê":"E","Ẽ":"E","Ĕ":"E","Ȇ":"E","Í":"I","Ì":"I","Î":"I","Ï":"I","Ň":"N","Ñ":"N","Ó":"O","Ö":"O","Ò":"O","Ô":"O","Õ":"O","Ø":"O","Ř":"R","Ŕ":"R","Š":"S","Ť":"T","Ú":"U","Ů":"U","Ü":"U","Ù":"U","Û":"U","Ý":"Y","Ÿ":"Y","Ž":"Z","á":"a","ä":"a","â":"a","à":"a","ã":"a","å":"a","č":"c","ç":"c","ć":"c","ď":"d","é":"e","ě":"e","ë":"e","è":"e","ê":"e","ẽ":"e","ĕ":"e","ȇ":"e","í":"i","ì":"i","î":"i","ï":"i","ň":"n","ñ":"n","ó":"o","ö":"o","ò":"o","ô":"o","õ":"o","ø":"o","ð":"o","ř":"r","ŕ":"r","š":"s","ť":"t","ú":"u","ů":"u","ü":"u","ù":"u","û":"u","ý":"y","ÿ":"y","ž":"z","þ":"b","Þ":"B","Đ":"D","đ":"d","ß":"B","Æ":"A","a":"a"}`, +); + +export const slugify = (str: string) => + str + .replace(/,/g, "") + .replace(/[·/_,:]/g, "-") + .replace(/[*+~.()'"!:@&[\]`/ %$#?{}|><=_^]/g, "-") + .split("") + .map((char) => mapped[char] ?? char) + .join("") + .toLowerCase(); diff --git a/packages/apps-vtex/src/utils/transform.ts b/packages/apps-vtex/src/utils/transform.ts new file mode 100644 index 0000000..6ff6680 --- /dev/null +++ b/packages/apps-vtex/src/utils/transform.ts @@ -0,0 +1,1509 @@ +import type { + AggregateOffer, + Brand, + BreadcrumbList, + DayOfWeek, + Filter, + FilterToggleValue, + ItemAvailability, + Offer, + OpeningHoursSpecification, + PageType, + Place, + PostalAddress, + PriceComponentTypeEnumeration, + PriceTypeEnumeration, + Product, + ProductDetailsPage, + ProductGroup, + PropertyValue, + SiteNavigationElement, + UnitPriceSpecification, +} from "@decocms/apps-commerce/types"; +import { DEFAULT_IMAGE } from "@decocms/apps-commerce/utils/constants"; +import { formatRange } from "@decocms/apps-commerce/utils/filters"; +import { pick } from "./pickAndOmit"; +import { slugify } from "./slugify"; +import type { + Address, + Brand as BrandVTEX, + Category, + FacetValueBoolean, + FacetValueRange, + Facet as FacetVTEX, + LegacyFacet, + LegacyProduct, + LegacyProduct as LegacyProductVTEX, + LegacyItem as LegacySkuVTEX, + Maybe, + OrderForm, + PageType as PageTypeVTEX, + PickupHolidays, + PickupPoint, + ProductInventoryData, + ProductRating, + ProductReviewData, + Product as ProductVTEX, + SelectedFacet, + Seller as SellerVTEX, + Item as SkuVTEX, + Teasers, +} from "./types"; + +interface PickupPointVCS { + name?: string; + address?: { + country?: { acronym?: string }; + location?: { latitude?: number; longitude?: number }; + city?: string; + state?: string; + postalCode?: string; + street?: string; + }; + pickupHolidays?: any[]; + businessHours?: any[]; + isActive?: boolean; + id?: string; + [key: string]: unknown; +} + +const DEFAULT_CATEGORY_SEPARATOR = ">"; + +export const SCHEMA_LIST_PRICE: PriceTypeEnumeration = "https://schema.org/ListPrice"; +export const SCHEMA_SALE_PRICE: PriceTypeEnumeration = "https://schema.org/SalePrice"; +export const SCHEMA_SRP: PriceTypeEnumeration = "https://schema.org/SRP"; +export const SCHEMA_INSTALLMENT: PriceComponentTypeEnumeration = "https://schema.org/Installment"; +export const SCHEMA_IN_STOCK: ItemAvailability = "https://schema.org/InStock"; +export const SCHEMA_OUT_OF_STOCK: ItemAvailability = "https://schema.org/OutOfStock"; + +const isLegacySku = (sku: LegacySkuVTEX | SkuVTEX): sku is LegacySkuVTEX => + typeof (sku as LegacySkuVTEX).variations?.[0] === "string" || !!(sku as LegacySkuVTEX).Videos; + +const isLegacyProduct = (product: ProductVTEX | LegacyProductVTEX): product is LegacyProductVTEX => + product.origin !== "intelligent-search"; + +const getProductGroupURL = (origin: string, { linkText }: { linkText: string }) => + new URL(`/${linkText}/p`, origin); + +const getProductURL = (origin: string, product: { linkText: string }, skuId?: string) => { + const canonicalUrl = getProductGroupURL(origin, product); + + if (skuId) { + canonicalUrl.searchParams.set("skuId", skuId); + } + + return canonicalUrl; +}; + +const nonEmptyArray = (array: T[] | null | undefined) => + Array.isArray(array) && array.length > 0 ? array : null; + +interface ProductOptions { + baseUrl: string; + /** Price coded currency, e.g.: USD, BRL */ + priceCurrency: string; + imagesByKey?: Map; + /** Original attributes to be included in the transformed product */ + includeOriginalAttributes?: string[]; + /** Use lean toProductVariant for hasVariant[] instead of full toProduct at level=1 */ + leanVariants?: boolean; + /** Property names to keep on lean variant additionalProperty. Defaults to VARIANT_PROPERTY_NAMES. */ + variantPropertyNames?: Set; + /** When leanVariants is true, still include image[0] on each variant entry. Default true. */ + variantIncludeImage?: boolean; + /** When leanVariants is true, still include inventoryLevel on each variant offer. Default true. */ + variantIncludeInventory?: boolean; +} + +/** Returns first available sku */ +const findFirstAvailable = (items: Array) => + items?.find((item) => + Boolean(item?.sellers?.find((s) => s.commertialOffer?.AvailableQuantity > 0)), + ); + +export const pickSku = ( + product: T, + maybeSkuId?: string, +): T["items"][number] => { + const skuId = maybeSkuId ?? findFirstAvailable(product.items)?.itemId ?? product.items[0]?.itemId; + for (const item of product.items) { + if (item.itemId === skuId) { + return item; + } + } + + return product.items[0]; +}; + +const toAccessoryOrSparePartFor = ( + sku: T["items"][number], + kitItems: T[], + options: ProductOptions, +) => { + const productBySkuId = kitItems.reduce((map, product) => { + product.items.forEach((item) => map.set(item.itemId, product)); + + return map; + }, new Map()); + + return sku.kitItems + ?.map(({ itemId }) => { + const product = productBySkuId.get(itemId); + + /** Sometimes VTEX does not return what I've asked for */ + if (!product) return; + + const sku = pickSku(product, itemId); + + return toProduct(product, sku, 0, options); + }) + .filter((p): p is Product => typeof p !== "undefined"); +}; + +export const forceHttpsOnAssets = (orderForm: OrderForm) => { + orderForm.items.forEach((item) => { + if (item.imageUrl) { + item.imageUrl = item.imageUrl.startsWith("http://") + ? item.imageUrl.replace("http://", "https://") + : item.imageUrl; + } + }); + return orderForm; +}; + +export const toProductPage = ( + product: T, + sku: T["items"][number], + kitItems: T[], + options: ProductOptions, +): Omit => { + const partialProduct = toProduct(product, sku, 0, options); + // This is deprecated. Compose this loader at loaders > product > extension > detailsPage.ts + const isAccessoryOrSparePartFor = toAccessoryOrSparePartFor(sku, kitItems, options); + + return { + "@type": "ProductDetailsPage", + breadcrumbList: toBreadcrumbList(product, options), + product: { ...partialProduct, isAccessoryOrSparePartFor }, + }; +}; + +export const inStock = (offer: Offer) => offer.availability === SCHEMA_IN_STOCK; + +// Smallest Available Spot Price First +export const bestOfferFirst = (a: Offer, b: Offer) => { + if (inStock(a) && !inStock(b)) { + return -1; + } + + if (!inStock(a) && inStock(b)) { + return 1; + } + + return a.price - b.price; +}; + +const getHighPriceIndex = (offers: Offer[]) => { + let it = offers.length - 1; + for (; it > 0 && !inStock(offers[it]); it--); + return it; +}; + +const splitCategory = (firstCategory: string) => firstCategory.split("/").filter(Boolean); + +const toAdditionalPropertyCategories =

( + product: P, +): Product["additionalProperty"] => { + const categories = new Set(); + const categoryIds = new Set(); + + product.categories.forEach((productCategory, i) => { + const category = splitCategory(productCategory); + const categoryId = splitCategory(product.categoriesIds[i]); + + category.forEach((splitCategoryItem, j) => { + categories.add(splitCategoryItem); + categoryIds.add(categoryId[j]); + }); + }); + + const categoriesArray = Array.from(categories); + const categoryIdsArray = Array.from(categoryIds); + + return categoriesArray.map((category, index) => + toAdditionalPropertyCategory({ + propertyID: categoryIdsArray[index], + value: category || "", + }), + ); +}; + +export const toAdditionalPropertyCategory = ({ + propertyID, + value, +}: { + propertyID: string; + value: string; +}): PropertyValue => ({ + "@type": "PropertyValue" as const, + name: "category", + propertyID, + value, +}); + +const toAdditionalPropertyClusters =

( + product: P, +): Product["additionalProperty"] => { + const mapEntriesToIdName = ([id, name]: [string, unknown]) => ({ + id, + name: name as string, + }); + + const allClusters = isLegacyProduct(product) + ? Object.entries(product.productClusters).map(mapEntriesToIdName) + : product.productClusters; + + const highlightsSet = isLegacyProduct(product) + ? new Set(Object.keys(product.clusterHighlights)) + : new Set(product.clusterHighlights.map(({ id }) => id)); + + return allClusters.map((cluster) => + toAdditionalPropertyCluster( + { + propertyID: cluster.id, + value: cluster.name || "", + }, + highlightsSet, + ), + ); +}; + +export const toAdditionalPropertyCluster = ( + { propertyID, value }: { propertyID: string; value: string }, + highlights?: Set, +): PropertyValue => ({ + "@type": "PropertyValue", + name: "cluster", + value, + propertyID, + description: highlights?.has(propertyID) ? "highlight" : undefined, +}); + +const toAdditionalPropertyReferenceIds = ( + referenceId: Array<{ Key: string; Value: string }>, +): Product["additionalProperty"] => { + return referenceId.map(({ Key, Value }) => + toAdditionalPropertyReferenceId({ name: Key, value: Value }), + ); +}; + +export const toAdditionalPropertyReferenceId = ({ + name, + value, +}: { + name: string; + value: string; +}): PropertyValue => ({ + "@type": "PropertyValue", + name, + value, + valueReference: "ReferenceID", +}); + +const getImageKey = (src = "") => { + return src; + + // TODO: figure out how we can improve this + // const match = new URLPattern({ + // pathname: "/arquivos/ids/:skuId/:imageId", + // }).exec(src); + + // if (match == null) { + // return src; + // } + + // return `${match.pathname.groups.imageId}${match.search.input}`; +}; + +export const aggregateOffers = ( + offers: Offer[], + priceCurrency?: string, +): AggregateOffer | undefined => { + const sorted = offers.sort(bestOfferFirst); + + if (sorted.length === 0) return; + + const highPriceIndex = getHighPriceIndex(sorted); + const lowPriceIndex = 0; + + return { + "@type": "AggregateOffer", + priceCurrency, + highPrice: sorted[highPriceIndex]?.price ?? null, + lowPrice: sorted[lowPriceIndex]?.price ?? null, + offerCount: sorted.length, + offers: sorted, + }; +}; + +export const toProduct =

( + product: P, + sku: P["items"][number], + level = 0, // prevent inifinte loop while self referencing the product + options: ProductOptions, +): Product => { + const { baseUrl, priceCurrency } = options; + const { + brand, + brandId, + brandImageUrl, + productId, + productReference, + description, + releaseDate, + items, + } = product; + const { name, ean, itemId: skuId, referenceId = [], kitItems, estimatedDateArrival } = sku; + + const videos = isLegacySku(sku) ? sku.Videos : sku.videos; + const nonEmptyVideos = nonEmptyArray(videos); + const imagesByKey = + options.imagesByKey ?? + items + .flatMap((i) => i.images) + .reduce((map, img) => { + img?.imageUrl && map.set(getImageKey(img.imageUrl), img.imageUrl); + return map; + }, new Map()); + + const groupAdditionalProperty = isLegacyProduct(product) + ? legacyToProductGroupAdditionalProperties(product) + : toProductGroupAdditionalProperties(product); + const originalAttributesAdditionalProperties = toOriginalAttributesAdditionalProperties( + options.includeOriginalAttributes, + product, + ); + const specificationsAdditionalProperty = isLegacySku(sku) + ? toAdditionalPropertiesLegacy(sku) + : toAdditionalProperties(sku); + const referenceIdAdditionalProperty = toAdditionalPropertyReferenceIds(referenceId); + const images = nonEmptyArray(sku.images); + const offers = (sku.sellers ?? []).map(isLegacyProduct(product) ? toOfferLegacy : toOffer); + + const variantOptions = + imagesByKey !== options.imagesByKey ? { ...options, imagesByKey } : options; + const isVariantOf = + level < 1 + ? ({ + "@type": "ProductGroup", + productGroupID: productId, + hasVariant: options.leanVariants + ? items.map((sku) => toProductVariant(product, sku, variantOptions)) + : items.map((sku) => toProduct(product, sku, 1, variantOptions)), + url: getProductGroupURL(baseUrl, product).href, + name: product.productName, + additionalProperty: [ + ...groupAdditionalProperty, + ...originalAttributesAdditionalProperties, + ], + model: productReference, + } satisfies ProductGroup) + : undefined; + + const finalImages = images?.map(({ imageUrl, imageText, imageLabel }) => { + const url = imagesByKey.get(getImageKey(imageUrl)) ?? imageUrl; + const alternateName = imageText || imageLabel || ""; + const name = imageLabel || ""; + const encodingFormat = "image"; + + return { + "@type": "ImageObject" as const, + alternateName, + url, + name, + encodingFormat, + }; + }) ?? [DEFAULT_IMAGE]; + + const finalVideos = nonEmptyVideos?.map((video) => { + const url = video; + const alternateName = "Product video"; + const name = "Product video"; + const encodingFormat = "video"; + return { + "@type": "VideoObject" as const, + alternateName, + contentUrl: url, + name, + encodingFormat, + }; + }); + + // From schema.org: A category for the item. Greater signs or slashes can be used to informally indicate a category hierarchy + const categoriesString = splitCategory(product.categories[0]).join(DEFAULT_CATEGORY_SEPARATOR); + + const categoryAdditionalProperties = toAdditionalPropertyCategories(product); + const clusterAdditionalProperties = toAdditionalPropertyClusters(product); + + const additionalProperty: PropertyValue[] = [...specificationsAdditionalProperty]; + if (categoryAdditionalProperties) { + additionalProperty.push(...categoryAdditionalProperties); + } + if (clusterAdditionalProperties) { + additionalProperty.push(...clusterAdditionalProperties); + } + if (referenceIdAdditionalProperty) { + additionalProperty.push(...referenceIdAdditionalProperty); + } + + estimatedDateArrival && + additionalProperty.push({ + "@type": "PropertyValue", + name: "Estimated Date Arrival", + value: estimatedDateArrival, + }); + + if (sku.modalType) { + additionalProperty.push({ + "@type": "PropertyValue", + name: "Modal Type", + value: sku.modalType, + }); + } + + return { + "@type": "Product", + category: categoriesString, + productID: skuId, + url: getProductURL(baseUrl, product, sku.itemId).href, + name, + alternateName: sku.complementName, + description, + brand: { + "@type": "Brand", + "@id": brandId?.toString(), + name: brand, + logo: brandImageUrl, + }, + isAccessoryOrSparePartFor: kitItems?.map(({ itemId }) => ({ + "@type": "Product", + productID: itemId, + sku: itemId, + })), + inProductGroupWithID: productId, + sku: skuId, + gtin: ean, + releaseDate, + additionalProperty, + isVariantOf, + image: finalImages, + video: finalVideos, + offers: aggregateOffers(offers, priceCurrency), + }; +}; + +/** + * Determines if an installment has no interest by checking if + * billingDuration * billingIncrement ≈ total price (within 1 cent tolerance). + */ +const isNoInterest = (spec: UnitPriceSpecification): boolean => { + if (spec.billingDuration == null || spec.billingIncrement == null || spec.price == null) { + return false; + } + return Math.abs(spec.billingDuration * spec.billingIncrement - spec.price) < 0.01; +}; + +/** + * Build a lean offer for shelf display. Keeps only: + * - ListPrice, SalePrice, SRP price types + * - PIX installment (name?.toUpperCase() === "PIX") + * - Best no-interest installment (highest billingDuration) + * Drops: inventoryLevel, giftSkuIds, priceValidUntil + */ +const buildOfferShelf = (offer: Offer): Offer => { + const leanSpecs: UnitPriceSpecification[] = []; + + let bestNoInterest: UnitPriceSpecification | null = null; + + for (const spec of offer.priceSpecification ?? []) { + // Keep base price types + if ( + spec.priceType === SCHEMA_LIST_PRICE || + spec.priceType === SCHEMA_SALE_PRICE || + spec.priceType === SCHEMA_SRP + ) { + if (spec.priceComponentType !== SCHEMA_INSTALLMENT) { + leanSpecs.push(spec); + continue; + } + } + + // Keep PIX installment + if (spec.priceComponentType === SCHEMA_INSTALLMENT && spec.name?.toUpperCase() === "PIX") { + leanSpecs.push(spec); + continue; + } + + // Track best no-interest installment (highest billingDuration) + if ( + spec.priceComponentType === SCHEMA_INSTALLMENT && + isNoInterest(spec) && + (bestNoInterest == null || + (spec.billingDuration ?? 0) > (bestNoInterest.billingDuration ?? 0)) + ) { + bestNoInterest = spec; + } + } + + if (bestNoInterest) { + leanSpecs.push(bestNoInterest); + } + + return { + "@type": "Offer", + identifier: offer.identifier, + price: offer.price, + seller: offer.seller, + sellerName: offer.sellerName, + teasers: offer.teasers, + priceSpecification: leanSpecs, + availability: offer.availability, + inventoryLevel: { value: 0 }, + }; +}; + +/** Property names commonly used by ProductCard/Shelf components */ +const SHELF_PROPERTY_NAMES = new Set([ + "category", + "cluster", + "Cor", + "Tamanho", + "Voltagem", + "sellerId", +]); + +/** + * Lean product transform for shelf/card display. Same signature as toProduct(). + * + * Differences from toProduct(): + * - Images: capped at 2 per SKU (front + back) + * - Offers: best seller only (in-stock first, then cheapest), stripped installments (keeps ListPrice, SalePrice, SRP, PIX, best no-interest) + * - isVariantOf: single in-stock variant at level 0 + * - additionalProperty: filtered to known-used property names + * - Drops: description, video, isAccessoryOrSparePartFor, alternateName, gtin, releaseDate, model + */ +export const toProductShelf =

( + product: P, + sku: P["items"][number], + level = 0, + options: ProductOptions, +): Product => { + const { baseUrl, priceCurrency } = options; + const { productId, items } = product; + const { name, itemId: skuId } = sku; + + // Images: cap at 2 + const rawImages = nonEmptyArray(sku.images); + const mappedImages = (rawImages ?? []).slice(0, 2).map(({ imageUrl, imageText, imageLabel }) => ({ + "@type": "ImageObject" as const, + alternateName: imageText || imageLabel || "", + url: imageUrl, + name: imageLabel || "", + encodingFormat: "image", + })); + const finalImages = mappedImages.length > 0 ? mappedImages : [DEFAULT_IMAGE]; + + // Offers: best seller (in-stock first, then cheapest), lean. + // Must consider ALL sellers so marketplace products where sellers[0] + // is OOS but another seller has stock still show as available. + const offerConverter = isLegacyProduct(product) ? toOfferLegacy : toOffer; + const allOffers = (sku.sellers ?? []).map(offerConverter).sort(bestOfferFirst); + const bestOffer = allOffers[0]; + const leanOffers = bestOffer ? [buildOfferShelf(bestOffer)] : []; + + // isVariantOf: single in-stock variant at level 0 + const isVariantOf = + level < 1 + ? (() => { + const inStockSku = findFirstAvailable(items) ?? items[0]; + const singleVariant = inStockSku ? [toProductShelf(product, inStockSku, 1, options)] : []; + return { + "@type": "ProductGroup" as const, + productGroupID: productId, + hasVariant: singleVariant, + url: getProductGroupURL(baseUrl, product).href, + name: product.productName, + additionalProperty: [], + } satisfies ProductGroup; + })() + : undefined; + + // additionalProperty: filter to known-used names + const specificationsAdditionalProperty = isLegacySku(sku) + ? toAdditionalPropertiesLegacy(sku) + : toAdditionalProperties(sku); + const categoryAdditionalProperties = toAdditionalPropertyCategories(product) ?? []; + const clusterAdditionalProperties = toAdditionalPropertyClusters(product) ?? []; + + const additionalProperty = [ + ...specificationsAdditionalProperty, + ...categoryAdditionalProperties, + ...clusterAdditionalProperties, + ].filter((prop) => SHELF_PROPERTY_NAMES.has(prop.name ?? "")); + + const categoriesString = splitCategory(product.categories[0]).join(DEFAULT_CATEGORY_SEPARATOR); + + return { + "@type": "Product", + category: categoriesString, + productID: skuId, + url: getProductURL(baseUrl, product, sku.itemId).href, + name, + brand: { "@type": "Brand", name: product.brand }, + inProductGroupWithID: productId, + sku: skuId, + image: finalImages, + offers: aggregateOffers(leanOffers, priceCurrency), + isVariantOf, + additionalProperty, + }; +}; + +/** Property names that differentiate SKU variants (used by variant selectors) */ +const VARIANT_PROPERTY_NAMES = new Set(["Cor", "Voltagem", "Tamanho"]); + +/** + * Build a minimal offer for variant display. Keeps only availability and seller. + * No priceSpecification, no teasers. inventoryLevel is preserved from the real + * offer unless `includeInventory` is false (variant selectors rely on it to + * decide stock state per SKU — dropping it hard-zeroes every variant). + */ +const buildOfferVariant = (offer: Offer, includeInventory: boolean): Offer => ({ + "@type": "Offer", + identifier: offer.identifier, + price: offer.price, + seller: offer.seller, + availability: offer.availability, + priceSpecification: [], + inventoryLevel: includeInventory ? offer.inventoryLevel : { value: 0 }, +}); + +/** + * Minimal product transform for variant entries inside isVariantOf.hasVariant[]. + * + * Keeps only what variant selectors need: + * - url, productID, sku, name, inProductGroupWithID + * - additionalProperty filtered to variant-differentiating props (Cor, Voltagem, Tamanho) + * - image[0] (when variantIncludeImage is not false) — selectors render thumbnails + * - offers with availability + seller + real inventoryLevel (when variantIncludeInventory + * is not false) — selectors decide stock state per SKU from inventoryLevel.value + * + * Drops: description, video, brand, category, gtin, releaseDate, + * alternateName, isAccessoryOrSparePartFor, isVariantOf + */ +export const toProductVariant =

( + product: P, + sku: P["items"][number], + options: ProductOptions, +): Product => { + const { baseUrl, priceCurrency } = options; + const { productId, items } = product; + const { name, itemId: skuId } = sku; + const variantProps = options.variantPropertyNames ?? VARIANT_PROPERTY_NAMES; + const includeImage = options.variantIncludeImage !== false; + const includeInventory = options.variantIncludeInventory !== false; + + // additionalProperty: only variant-differentiating specs + const specificationsAdditionalProperty = isLegacySku(sku) + ? toAdditionalPropertiesLegacy(sku) + : toAdditionalProperties(sku); + const additionalProperty = specificationsAdditionalProperty.filter((prop) => + variantProps.has(prop.name ?? ""), + ); + + // Offers: best seller, lean (availability + seller; optional inventoryLevel) + const offerConverter = isLegacyProduct(product) ? toOfferLegacy : toOffer; + const allOffers = (sku.sellers ?? []).map(offerConverter).sort(bestOfferFirst); + const bestOffer = allOffers[0]; + const leanOffers = bestOffer ? [buildOfferVariant(bestOffer, includeInventory)] : []; + + // image[0] only — selectors render a single thumbnail. Reuse the same + // imagesByKey lookup toProduct uses so URLs stay consistent across variants. + const imagesByKey = + options.imagesByKey ?? + items + .flatMap((i) => i.images) + .reduce((map, img) => { + img?.imageUrl && map.set(getImageKey(img.imageUrl), img.imageUrl); + return map; + }, new Map()); + + const image = includeImage + ? nonEmptyArray(sku.images) + ?.slice(0, 1) + .map(({ imageUrl, imageText, imageLabel }) => ({ + "@type": "ImageObject" as const, + alternateName: imageText || imageLabel || "", + url: imagesByKey.get(getImageKey(imageUrl)) ?? imageUrl, + name: imageLabel || "", + encodingFormat: "image", + })) + : undefined; + + return { + "@type": "Product", + productID: skuId, + sku: skuId, + name, + url: getProductURL(baseUrl, product, sku.itemId).href, + inProductGroupWithID: productId, + additionalProperty, + ...(image ? { image } : {}), + offers: aggregateOffers(leanOffers, priceCurrency), + }; +}; + +const toBreadcrumbList = ( + product: ProductVTEX | LegacyProductVTEX, + { baseUrl }: ProductOptions, +): BreadcrumbList => { + const { categories, productName } = product; + const names = categories[0]?.split("/").filter(Boolean); + + const segments = names.map(slugify); + + return { + "@type": "BreadcrumbList", + itemListElement: [ + ...names.map((name, index) => { + const position = index + 1; + + return { + "@type": "ListItem" as const, + name, + item: new URL(`/${segments.slice(0, position).join("/")}`, baseUrl).href, + position, + }; + }), + { + "@type": "ListItem", + name: productName, + item: getProductGroupURL(baseUrl, product).href, + position: categories.length + 1, + }, + ], + numberOfItems: categories.length + 1, + }; +}; + +const legacyToProductGroupAdditionalProperties = (product: LegacyProductVTEX) => { + const groups = product.allSpecificationsGroups ?? []; + const allSpecifications = product.allSpecifications ?? []; + + const specByGroup: Record = {}; + + groups.forEach((group) => { + const groupSpecs = (product as unknown as Record)[group]; + groupSpecs.forEach((specName) => { + specByGroup[specName] = group; + }); + }); + + return allSpecifications.flatMap((name) => { + const values = (product as unknown as Record)[name]; + return values.map((value) => + toAdditionalPropertySpecification({ + name, + value, + propertyID: specByGroup[name], + }), + ); + }); +}; + +const toProductGroupAdditionalProperties = ({ specificationGroups = [] }: ProductVTEX) => + specificationGroups.flatMap(({ name: groupName, specifications }) => + specifications.flatMap(({ name, values }) => + values.map( + (value) => + ({ + "@type": "PropertyValue", + name, + value, + propertyID: groupName, + valueReference: "PROPERTY" as string, + }) as const, + ), + ), + ); + +const toOriginalAttributesAdditionalProperties = ( + originalAttributes: Maybe, + product: ProductVTEX | LegacyProduct, +) => { + if (!originalAttributes) { + return []; + } + + const attributes = pick(originalAttributes as Array, product) ?? {}; + + return Object.entries(attributes).map( + ([name, value]) => + ({ + "@type": "PropertyValue", + name, + value, + valueReference: "ORIGINAL_PROPERTY" as string, + }) as const, + ) as unknown as PropertyValue[]; +}; + +const toAdditionalProperties = (sku: SkuVTEX): PropertyValue[] => + sku.variations?.flatMap(({ name, values }) => + values.map((value) => toAdditionalPropertySpecification({ name, value })), + ) ?? []; + +export const toAdditionalPropertySpecification = ({ + name, + value, + propertyID, +}: { + name: string; + value: string; + propertyID?: string; +}): PropertyValue => ({ + "@type": "PropertyValue", + name, + value, + propertyID, + valueReference: "SPECIFICATION", +}); + +const toAdditionalPropertiesLegacy = (sku: LegacySkuVTEX): PropertyValue[] => { + const { variations = [], attachments = [] } = sku; + + const specificationProperties = variations.flatMap((variation) => + sku[variation].map((value) => toAdditionalPropertySpecification({ name: variation, value })), + ); + + const attachmentProperties = attachments.map( + (attachment) => + ({ + "@type": "PropertyValue", + propertyID: `${attachment.id}`, + name: attachment.name, + value: attachment.domainValues, + required: attachment.required, + valueReference: "ATTACHMENT", + }) as const, + ); + + if (attachmentProperties.length === 0) return specificationProperties; + specificationProperties.push(...attachmentProperties); + return specificationProperties; +}; + +const buildOffer = ( + { commertialOffer: offer, sellerId, sellerName, sellerDefault }: SellerVTEX, + teasers: Teasers[], +): Offer => ({ + "@type": "Offer", + identifier: sellerDefault ? "default" : undefined, + price: offer.spotPrice ?? offer.Price, + seller: sellerId, + sellerName, + priceValidUntil: offer.PriceValidUntil, + inventoryLevel: { value: offer.AvailableQuantity }, + giftSkuIds: offer.GiftSkuIds ?? [], + teasers, + priceSpecification: [ + { + "@type": "UnitPriceSpecification", + priceType: SCHEMA_LIST_PRICE, + price: offer.ListPrice, + }, + { + "@type": "UnitPriceSpecification", + priceType: SCHEMA_SALE_PRICE, + price: offer.Price, + }, + { + "@type": "UnitPriceSpecification", + priceType: SCHEMA_SRP, + price: offer.PriceWithoutDiscount, + }, + ...offer.Installments.map( + (installment): UnitPriceSpecification => ({ + "@type": "UnitPriceSpecification", + priceType: SCHEMA_SALE_PRICE, + priceComponentType: SCHEMA_INSTALLMENT, + name: installment.PaymentSystemName, + description: installment.Name, + billingDuration: installment.NumberOfInstallments, + billingIncrement: installment.Value, + price: installment.TotalValuePlusInterestRate, + }), + ), + ], + availability: offer.AvailableQuantity > 0 ? SCHEMA_IN_STOCK : SCHEMA_OUT_OF_STOCK, +}); + +const toOffer = (seller: SellerVTEX): Offer => + buildOffer(seller, seller.commertialOffer.teasers ?? []); + +const toOfferLegacy = (seller: SellerVTEX): Offer => { + const otherTeasers = + seller.commertialOffer.DiscountHighLight?.map((i) => { + const discount = i as Record; + const [_k__BackingField, discountName] = Object.entries(discount)?.[0] ?? []; + + const teasers: Teasers = { + name: discountName, + conditions: { + minimumQuantity: 0, + parameters: [], + }, + effects: { + parameters: [], + }, + }; + + return teasers; + }) ?? []; + + const legacyTeasers = (seller.commertialOffer.Teasers ?? []).map((teaser) => ({ + name: teaser["k__BackingField"], + generalValues: teaser["k__BackingField"], + conditions: { + minimumQuantity: teaser["k__BackingField"]["k__BackingField"], + parameters: teaser["k__BackingField"]["k__BackingField"].map( + (parameter) => ({ + name: parameter["k__BackingField"], + value: parameter["k__BackingField"], + }), + ), + }, + effects: { + parameters: teaser["k__BackingField"]["k__BackingField"].map( + (parameter) => ({ + name: parameter["k__BackingField"], + value: parameter["k__BackingField"], + }), + ), + }, + })); + + return buildOffer(seller, [...otherTeasers, ...legacyTeasers]); +}; + +export const legacyFacetToFilter = ( + name: string, + facets: LegacyFacet[], + url: URL, + map: string, + term: string, + behavior: "dynamic" | "static", + ignoreCaseSelected?: boolean, + fullPath = false, +): Filter | null => { + const mapSegments = map.split(",").filter((x) => x.length > 0); + const pathSegments = term.replace(/^\//, "").split("/").slice(0, mapSegments.length); + + const mapSet = new Set(mapSegments.map((i) => (ignoreCaseSelected ? i.toLowerCase() : i))); + const pathSet = new Set(pathSegments.map((i) => (ignoreCaseSelected ? i.toLowerCase() : i))); + + // for productClusterIds, we have to use the full path + // example: + // category2/123?map=c,productClusterIds -> DO NOT WORK + // category1/category2/123?map=c,c,productClusterIds -> WORK + const hasProductClusterIds = mapSegments.includes("productClusterIds"); + const hasToBeFullpath = + fullPath || hasProductClusterIds || mapSegments.includes("ft") || mapSegments.includes("b"); + + const getLink = (facet: LegacyFacet, selected: boolean) => { + const index = pathSegments.findIndex((s) => { + if (ignoreCaseSelected) { + return s.toLowerCase() === facet.Value.toLowerCase(); + } + + return s === facet.Value; + }); + + const map = hasToBeFullpath ? facet.Link.split("map=")[1].split(",") : [facet.Map]; + const value = hasToBeFullpath ? facet.Link.split("?")[0].slice(1).split("/") : [facet.Value]; + + const pathSegmentsFiltered = hasProductClusterIds + ? [pathSegments[mapSegments.indexOf("productClusterIds")]] + : []; + const mapSegmentsFiltered = hasProductClusterIds ? ["productClusterIds"] : []; + + const _mapSegments = hasToBeFullpath ? mapSegmentsFiltered : mapSegments; + const _pathSegments = hasToBeFullpath ? pathSegmentsFiltered : pathSegments; + + const newMap = selected + ? [...mapSegments.filter((_, i) => i !== index)] + : [..._mapSegments, ...map]; + const newPath = selected + ? [...pathSegments.filter((_, i) => i !== index)] + : [..._pathSegments, ...value]; + + // Insertion-sort like algorithm. Uses the c-continuum theorem + const zipped: [string, string][] = []; + for (let it = 0; it < newMap.length; it++) { + let i = 0; + while (i < zipped.length && (zipped[i][0] === "c" || zipped[i][0] === "C")) i++; + + zipped.splice(i, 0, [newMap[it], newPath[it]]); + } + + const link = new URL(`/${zipped.map(([, s]) => s).join("/")}`, url); + link.searchParams.set("map", zipped.map(([m]) => m).join(",")); + if (behavior === "static") { + link.searchParams.set("fmap", url.searchParams.get("fmap") || mapSegments.join(",")); + } + const currentQuery = url.searchParams.get("q"); + if (currentQuery) { + link.searchParams.set("q", currentQuery); + } + + return `${link.pathname}${link.search}`; + }; + + return { + "@type": "FilterToggle", + quantity: facets?.length, + label: name, + key: name, + values: facets.map((facet) => { + const normalizedFacet = name !== "PriceRanges" ? facet : normalizeFacet(facet); + + const selected = + mapSet.has(ignoreCaseSelected ? normalizedFacet.Map.toLowerCase() : normalizedFacet.Map) && + pathSet.has( + ignoreCaseSelected ? normalizedFacet.Value.toLowerCase() : normalizedFacet.Value, + ); + + return { + value: normalizedFacet.Value, + quantity: normalizedFacet.Quantity, + url: getLink(normalizedFacet, selected), + label: normalizedFacet.Name, + selected, + children: + facet.Children?.length > 0 + ? legacyFacetToFilter( + normalizedFacet.Name, + facet.Children, + url, + map, + term, + behavior, + ignoreCaseSelected, + fullPath, + ) + : undefined, + }; + }), + }; +}; + +export const filtersToSearchParams = ( + selectedFacets: SelectedFacet[], + paramsToPersist?: URLSearchParams, +) => { + const searchParams = new URLSearchParams(paramsToPersist); + + for (const { key, value } of selectedFacets) { + searchParams.append(`filter.${key}`, value); + } + + return searchParams; +}; + +const fromLegacyMap: Record = { + priceFrom: "price", + productClusterSearchableIds: "productClusterIds", +}; + +export const legacyFacetsNormalize = (map: string, path: string) => { + // Replace legacy price path param to IS price facet format + // exemple: de-34,90-a-56,90 turns to 34.90:56.90 + // may this regex have to be adjusted for international stores + const value = path.replace( + /de-(?\d+[,]?[\d]+)-a-(?\d+[,]?[\d]+)/, + (_match, from, to) => { + return `${from.replace(",", ".")}:${to.replace(",", ".")}`; + }, + ); + + const key = fromLegacyMap[map] || map; + + return { key, value }; +}; + +/** + * Transform ?map urls into selected facets. This happens when a store is migrating + * to Deco and also migrating from VTEX Legacy to VTEX Intelligent Search. + */ +export const legacyFacetsFromURL = (url: URL) => { + const mapSegments = url.searchParams.get("map")?.split(",") ?? []; + const pathSegments = url.pathname.split("/").slice(1); // Remove first slash + const length = Math.min(mapSegments.length, pathSegments.length); + + const selectedFacets: SelectedFacet[] = []; + for (let it = 0; it < length; it++) { + const facet = legacyFacetsNormalize(mapSegments[it], pathSegments[it]); + + selectedFacets.push(facet); + } + + return selectedFacets; +}; + +export const filtersFromURL = (url: URL) => { + const selectedFacets: SelectedFacet[] = legacyFacetsFromURL(url); + + url.searchParams.forEach((value, name) => { + const [filter, key] = name.split("."); + + if (filter === "filter" && typeof key === "string") { + selectedFacets.push({ key, value }); + } + }); + + return selectedFacets; +}; + +export const mergeFacets = (f1: SelectedFacet[], f2: SelectedFacet[]): SelectedFacet[] => { + const facetKey = (facet: SelectedFacet) => `key:${facet.key}-value:${facet.value}`; + const merged = new Map(); + + for (const f of f1) { + merged.set(facetKey(f), f); + } + for (const f of f2) { + merged.set(facetKey(f), f); + } + + return [...merged.values()]; +}; + +const isValueRange = (facet: FacetValueRange | FacetValueBoolean): facet is FacetValueRange => + // deno-lint-ignore no-explicit-any + Boolean((facet as any).range); + +const facetToToggle = + (selectedFacets: SelectedFacet[], key: string, paramsToPersist?: URLSearchParams) => + (item: FacetValueRange | FacetValueBoolean): FilterToggleValue => { + const { quantity, selected } = item; + const isRange = isValueRange(item); + + const value = isRange ? formatRange(item.range.from, item.range.to) : item.value; + const label = isRange ? value : item.name; + const facet = { key, value }; + + const filters = selected + ? selectedFacets.filter((f) => f.key !== key || f.value !== value) + : [...selectedFacets, facet]; + + return { + value, + quantity, + selected, + url: `?${filtersToSearchParams(filters, paramsToPersist)}`, + label, + }; + }; + +export const toFilter = + (selectedFacets: SelectedFacet[], paramsToPersist?: URLSearchParams) => + ({ key, name, quantity, values }: FacetVTEX): Filter => ({ + "@type": "FilterToggle", + key, + label: name, + quantity: quantity, + values: values.map(facetToToggle(selectedFacets, key, paramsToPersist)), + }); + +function nodeToNavbar(node: Category): SiteNavigationElement { + const url = new URL(node.url, "https://example.com"); + + return { + "@type": "SiteNavigationElement", + url: `${url.pathname}${url.search}`, + name: node.name, + children: node.children.map(nodeToNavbar), + }; +} + +export const categoryTreeToNavbar = (tree: Category[]): SiteNavigationElement[] => + tree.map(nodeToNavbar); + +export const toBrand = ( + { id, name, imageUrl, metaTagDescription }: BrandVTEX, + baseUrl: string, +): Brand => ({ + "@type": "Brand", + "@id": `${id}`, + name, + logo: imageUrl?.startsWith("http") ? imageUrl : `${baseUrl}${imageUrl}`, + description: metaTagDescription, +}); + +export const normalizeFacet = (facet: LegacyFacet) => { + return { + ...facet, + Map: "priceFrom", + Value: facet.Slug!, + }; +}; + +export const toReview = ( + products: Product[], + ratings: ProductRating[], + reviews: ProductReviewData[], +): Product[] => { + return products.map((p, index) => { + const ratingsCount = ratings[index].totalCount || 0; + const productReviews = reviews[index].data || []; + + return { + ...p, + aggregateRating: { + "@type": "AggregateRating", + reviewCount: ratingsCount, + ratingCount: ratingsCount, + ratingValue: ratings[index]?.average || 0, + }, + review: productReviews.map((_, reviewIndex) => ({ + "@type": "Review", + id: productReviews[reviewIndex]?.id?.toString(), + author: [ + { + "@type": "Author", + name: productReviews[reviewIndex]?.reviewerName, + verifiedBuyer: productReviews[reviewIndex]?.verifiedPurchaser, + }, + ], + itemReviewed: productReviews[reviewIndex]?.productId, + datePublished: productReviews[reviewIndex]?.reviewDateTime, + reviewHeadline: productReviews[reviewIndex]?.title, + reviewBody: productReviews[reviewIndex]?.text, + reviewRating: { + "@type": "AggregateRating", + ratingValue: productReviews[reviewIndex]?.rating || 0, + }, + })), + }; + }); +}; + +export const toInventories = ( + products: Product[], + inventoriesData: ProductInventoryData[], +): Product[] => { + return products.map((p, index) => { + const balance = inventoriesData[index].balance || []; + + const additionalProperty = Array.from(p.additionalProperty || []); + + const inventories: PropertyValue[] = balance.map((b) => ({ + "@type": "PropertyValue", + valueReference: "INVENTORY", + propertyID: b.warehouseId, + name: b.warehouseName, + value: b.totalQuantity?.toString(), + })); + + return { + ...p, + additionalProperty: [...additionalProperty, ...inventories], + }; + }); +}; + +type ProductMap = Record; + +export const sortProducts = ( + products: Product[], + orderOfIdsOrSkus: string[], + prop: "sku" | "inProductGroupWithID", +) => { + const productMap: ProductMap = {}; + + products.forEach((product) => { + productMap[product[prop] || product.sku] = product; + }); + + return orderOfIdsOrSkus.map((id) => productMap[id]); +}; + +export const parsePageType = (p: PageTypeVTEX): PageType => { + const type = p.pageType; + + // Search or Busca vazia + if (type === "FullText") { + return "Search"; + } + + // A page that vtex doesn't recognize + if (type === "NotFound") { + return "Unknown"; + } + + return type; +}; + +function dayOfWeekIndexToString(day?: number): DayOfWeek | undefined { + switch (day) { + case 0: + return "Sunday"; + case 1: + return "Monday"; + case 2: + return "Tuesday"; + case 3: + return "Wednesday"; + case 4: + return "Thursday"; + case 5: + return "Friday"; + case 6: + return "Saturday"; + default: + return undefined; + } +} + +interface Hours { + dayOfWeek?: number; + openingTime?: string; + closingTime?: string; +} + +function toHoursSpecification(hours: Hours): OpeningHoursSpecification { + return { + "@type": "OpeningHoursSpecification", + opens: hours.openingTime, + closes: hours.closingTime, + dayOfWeek: dayOfWeekIndexToString(hours.dayOfWeek), + }; +} + +function toSpecialHoursSpecification(holiday: PickupHolidays): OpeningHoursSpecification { + const dateHoliday = new Date(holiday.date ?? ""); + // VTEX provide date in ISO format, at 00h on the day + const validThrough = dateHoliday.setDate(dateHoliday.getDate() + 1).toString(); + + return { + "@type": "OpeningHoursSpecification", + opens: holiday.hourBegin, + closes: holiday.hourEnd, + validFrom: holiday.date, + validThrough, + }; +} + +function isPickupPointVCS( + pickupPoint: PickupPoint | PickupPointVCS, +): pickupPoint is PickupPointVCS { + return "name" in pickupPoint; +} + +interface ToPlaceOptions { + isActive?: boolean; +} + +export function toPlace( + pickupPoint: (PickupPoint & { distance?: number }) | PickupPointVCS, + options?: ToPlaceOptions, +): Place { + const { + name, + country, + latitude, + longitude, + openingHoursSpecification, + specialOpeningHoursSpecification, + isActive, + } = isPickupPointVCS(pickupPoint) + ? { + name: pickupPoint.name, + country: pickupPoint.address?.country?.acronym, + latitude: pickupPoint.address?.location?.latitude, + longitude: pickupPoint.address?.location?.longitude, + specialOpeningHoursSpecification: pickupPoint.pickupHolidays?.map( + toSpecialHoursSpecification, + ), + openingHoursSpecification: pickupPoint.businessHours?.map(toHoursSpecification), + isActive: pickupPoint.isActive, + } + : { + name: pickupPoint.friendlyName, + country: pickupPoint.address?.country, + latitude: pickupPoint.address?.geoCoordinates?.[0], + longitude: pickupPoint.address?.geoCoordinates?.[1], + specialOpeningHoursSpecification: pickupPoint.pickupHolidays?.map( + toSpecialHoursSpecification, + ), + openingHoursSpecification: pickupPoint.businessHours?.map( + ({ ClosingTime, DayOfWeek, OpeningTime }) => + toHoursSpecification({ + closingTime: ClosingTime, + dayOfWeek: DayOfWeek, + openingTime: OpeningTime, + }), + ), + isActive: options?.isActive, + }; + + return { + "@id": pickupPoint.id, + "@type": "Place", + address: { + "@type": "PostalAddress", + addressCountry: country, + addressLocality: pickupPoint.address?.city, + addressRegion: pickupPoint.address?.state, + postalCode: pickupPoint.address?.postalCode, + streetAddress: pickupPoint.address?.street, + }, + latitude, + longitude, + name, + specialOpeningHoursSpecification, + openingHoursSpecification, + additionalProperty: [ + { + "@type": "PropertyValue", + name: "distance", + value: `${pickupPoint.distance}`, + }, + { + "@type": "PropertyValue", + name: "isActive", + value: typeof isActive === "boolean" ? `${isActive}` : undefined, + }, + ], + }; +} + +export const toPostalAddress = (address: Address): PostalAddress => { + return { + "@type": "PostalAddress", + "@id": address.addressId, + addressCountry: address.country, + addressLocality: address.city, + addressRegion: address.state, + areaServed: address.neighborhood || undefined, + postalCode: address.postalCode, + streetAddress: address.street, + identifier: address.number || undefined, + name: address.addressName || undefined, + alternateName: address.receiverName || undefined, + description: address.complement || undefined, + disambiguatingDescription: address.reference || undefined, + latitude: address.geoCoordinates?.[0] || undefined, + longitude: address.geoCoordinates?.[1] || undefined, + }; +}; diff --git a/packages/apps-vtex/src/utils/types.ts b/packages/apps-vtex/src/utils/types.ts new file mode 100644 index 0000000..61dc510 --- /dev/null +++ b/packages/apps-vtex/src/utils/types.ts @@ -0,0 +1,1884 @@ +/** + * @format dynamic-options + * @options vtex/loaders/options/productIdByTerm.ts + * @description Equivalent to sku ID in VTEX platform + */ +export type ProductID = string; + +export interface OrderForm { + orderFormId: string; + salesChannel: string; + loggedIn: boolean; + isCheckedIn: boolean; + storeId: null; + checkedInPickupPointId: null; + allowManualPrice: boolean; + canEditData: boolean; + userProfileId: null; + userType: null; + ignoreProfileData: boolean; + value: number; + messages: Message[]; + items: OrderFormItem[]; + selectableGifts: unknown[]; + totalizers: Totalizer[]; + shippingData: ShippingData; + clientProfileData: ClientProfileData | null; + paymentData: PaymentData; + marketingData: MarketingData; + sellers: Seller[]; + clientPreferencesData: ClientPreferencesData; + commercialConditionData: null; + storePreferencesData: StorePreferencesData; + giftRegistryData: null; + openTextField: null; + invoiceData: null; + customData: null; + itemMetadata: ItemMetadata; + hooksData: null; + ratesAndBenefitsData: RatesAndBenefitsData; + subscriptionData: null; + merchantContextData: null; + itemsOrdination: null; +} + +export interface ClientProfileData { + email: string; + firstName: null; + lastName: null; + document: null; + documentType: null; + phone: null; + corporateName: null; + tradeName: null; + corporateDocument: null; + stateInscription: null; + corporatePhone: null; + isCorporate: boolean; + profileCompleteOnLoading: boolean; + profileErrorOnLoading: boolean; + customerClass: null; +} + +export interface ClientPreferencesData { + locale: string; + optinNewsLetter: null; +} + +export interface ItemMetadata { + items: ItemMetadataItem[]; +} + +export interface ItemMetadataItem { + id: string; + seller: string; + name: string; + skuName: string; + productId: string; + refId: string; + ean: null | string; + imageUrl: string; + detailUrl: string; + assemblyOptions: AssemblyOption[]; +} + +export interface AssemblyOption { + id: Name; + name: Name; + required: boolean; + inputValues: Schema; + composition: null; +} + +export enum Name { + AlternativeSubstitute = "ALTERNATIVE_SUBSTITUTE", + Substituto = "Substituto", +} + +export interface Schema { + Sku: Ean; + Price: Ean; + Qtde?: Ean; + EAN: Ean; + Measuremt_unit: Ean; + Unit_multiplier: Ean; + Quantity: Ean; +} + +export interface Ean { + maximumNumberOfCharacters: number; + domain: unknown[]; +} + +export interface OrderFormItem { + uniqueId: string; + id: string; + productId: string; + productRefId: string; + refId: string; + ean: null | string; + name: string; + skuName: string; + modalType: null | string; + parentItemIndex: null; + parentAssemblyBinding: null; + assemblies: unknown[]; + priceValidUntil: Date; + tax: number; + price: number; + listPrice: number; + manualPrice: null; + manualPriceAppliedBy: null; + sellingPrice: number; + rewardValue: number; + isGift: boolean; + additionalInfo: AdditionalInfo; + preSaleDate: null; + productCategoryIds: string; + productCategories: { [key: string]: string }; + quantity: number; + seller: string; + sellerChain: string[]; + imageUrl: string; + detailUrl: string; + components: Component[]; + bundleItems: unknown[]; + attachments: unknown[]; + attachmentOfferings: AttachmentOffering[]; + offerings: Offering[]; + priceTags: PriceTag[]; + availability: string; + measurementUnit: string; + unitMultiplier: number; + manufacturerCode: null; + priceDefinition: PriceDefinition; +} + +export interface AdditionalInfo { + dimension: null; + brandName: null | string; + brandId: null | string; + offeringInfo: null; + offeringType: null; + offeringTypeId: null; +} + +export interface AttachmentOffering { + name: Name; + required: boolean; + schema: Schema; +} + +export interface Component { + uniqueId: string; + id: string; + productId: null; + productRefId: null; + refId: string; + ean: string; + name: string; + skuName: null; + modalType: null; + parentItemIndex: null; + parentAssemblyBinding: null; + assemblies: unknown[]; + priceValidUntil: null; + tax: number; + price: number; + listPrice: null; + manualPrice: null; + manualPriceAppliedBy: null; + sellingPrice: number; + rewardValue: number; + isGift: boolean; + additionalInfo: AdditionalInfo; + preSaleDate: null; + productCategoryIds: null; + productCategories: AvailableAssociations; + quantity: number; + seller: null; + sellerChain: null[]; + imageUrl: null; + detailUrl: null; + components: unknown[]; + bundleItems: unknown[]; + attachments: unknown[]; + attachmentOfferings: unknown[]; + offerings: unknown[]; + priceTags: PriceTag[]; + availability: null; + measurementUnit: string; + unitMultiplier: number; + manufacturerCode: null; + priceDefinition: PriceDefinition; +} + +export interface PriceDefinition { + calculatedSellingPrice: number; + total: number; + sellingPrices: SellingPrice[]; +} + +export interface SellingPrice { + value: number; + quantity: number; +} + +export interface PriceTag { + name: string; + value: number; + rawValue: number; + isPercentual: boolean; + identifier: string; + owner: string; +} + +export type AvailableAssociations = Record; + +export interface MarketingData { + utmSource: string | undefined; + utmMedium: string | undefined; + utmCampaign: string | undefined; + utmiPage: string | undefined; + utmiPart: string | undefined; + utmiCampaign: string | undefined; + coupon: string | undefined; + marketingTags: string[] | undefined; +} + +export interface Message { + code: null | string; + text: string; + status: string; + fields: Fields; +} + +export interface Fields { + ean?: string; + itemIndex?: string; + skuName?: string; +} + +export interface PaymentData { + updateStatus: string; + installmentOptions: InstallmentOption[]; + paymentSystems: PaymentSystem[]; + payments: unknown[]; + giftCards: unknown[]; + giftCardMessages: unknown[]; + availableAccounts: unknown[]; + availableTokens: unknown[]; + availableAssociations: AvailableAssociations; +} + +export interface InstallmentOption { + paymentSystem: string; + bin: null; + paymentName: null; + paymentGroupName: null; + value: number; + installments: CartInstallment[]; +} + +export interface CartInstallment { + count: number; + hasInterestRate: boolean; + interestRate: number; + value: number; + total: number; + sellerMerchantInstallments?: Installment[]; + id?: ID; +} + +export enum ID { + Carrefourbrfood = "CARREFOURBRFOOD", +} + +export interface PaymentSystem { + id: number; + name: string; + groupName: string; + validator: Validator; + stringId: string; + template: string; + requiresDocument: boolean; + displayDocument: boolean; + isCustom: boolean; + description: null | string; + requiresAuthentication: boolean; + dueDate: Date; + availablePayments: null; +} + +export interface Validator { + regex: null | string; + mask: null | string; + cardCodeRegex: CardCodeRegex | null; + cardCodeMask: null | string; + weights: number[] | null; + useCvv: boolean; + useExpirationDate: boolean; + useCardHolderName: boolean; + useBillingAddress: boolean; +} + +export enum CardCodeRegex { + The093$ = "^[0-9]{3}$", + The094$ = "^[0-9]{4}$", +} + +export interface RatesAndBenefitsData { + rateAndBenefitsIdentifiers: RateAndBenefitsIdentifier[]; + teaser: unknown[]; +} + +export interface RateAndBenefitsIdentifier { + id: string; + name: string; + featured: boolean; + description: string; + matchedParameters: MatchedParameters; + additionalInfo: null; +} + +export interface MatchedParameters { + "Seller@CatalogSystem": string; + "productCluster@CatalogSystem"?: string; + "zipCode@Shipping"?: string; + slaIds?: string; +} + +export interface Seller { + id: string; + name: string; + logo: string; +} + +export interface ShippingData { + address: Address; + logisticsInfo: LogisticsInfo[]; + selectedAddresses: Address[]; + availableAddresses: Address[]; + pickupPoints: PickupPoint[]; +} + +export interface Address { + addressType?: string; + receiverName: string | null; + addressId: string; + isDisposable?: boolean; + postalCode?: string; + city?: string; + state?: string; + country?: string; + street?: string; + number?: string; + neighborhood?: string; + addressName?: string; + complement: string | null; + reference?: string; + geoCoordinates?: number[]; + name?: string; +} + +export interface LogisticsInfo { + itemIndex: number; + selectedSla: SelectedSla | null; + selectedDeliveryChannel: SelectedDeliveryChannel; + addressId: string; + slas: Sla[]; + shipsTo: string[]; + itemId: string; + deliveryChannels: DeliveryChannel[]; +} + +export interface DeliveryChannel { + id: SelectedDeliveryChannel; +} + +export enum SelectedDeliveryChannel { + Delivery = "delivery", + PickupInPoint = "pickup-in-point", +} + +export enum SelectedSla { + ClickRetireRJS = "Click & Retire (RJS)", + Normal = "Normal", +} + +export interface Sla { + id: SelectedSla; + deliveryChannel: SelectedDeliveryChannel; + name: SelectedSla; + deliveryIds: DeliveryID[]; + shippingEstimate: string; + shippingEstimateDate: null; + lockTTL: null; + availableDeliveryWindows: AvailableDeliveryWindow[]; + deliveryWindow: null; + price: number; + listPrice: number; + tax: number; + pickupStoreInfo: PickupStoreInfo; + pickupPointId: null | string; + pickupDistance: number | null; + polygonName: string; + transitTime: string; +} + +export interface AvailableDeliveryWindow { + startDateUtc: Date; + endDateUtc: Date; + price: number; + lisPrice: number; + tax: number; +} + +export interface DeliveryID { + courierId: string; + warehouseId: string; + dockId: string; + courierName: string; + quantity: number; + kitItemDetails: unknown[]; +} + +export interface PickupStoreInfo { + isPickupStore: boolean; + friendlyName: null | string; + address: Address | null; + additionalInfo: null | string; + dockId: null; +} + +export interface PickupHolidays { + date?: string; + hourBegin?: string; + hourEnd?: string; +} + +export interface PickupPoint { + friendlyName: string; + address: Address; + additionalInfo: string; + id: string; + businessHours: BusinessHour[]; + pickupHolidays?: PickupHolidays[]; +} + +export interface BusinessHour { + DayOfWeek: number; + OpeningTime: string; + ClosingTime: string; +} + +export interface StorePreferencesData { + countryCode: string; + saveUserData: boolean; + timeZone: string; + currencyCode: string; + currencyLocale: number; + currencySymbol: string; + currencyFormatInfo: CurrencyFormatInfo; +} + +export interface CurrencyFormatInfo { + currencyDecimalDigits: number; + currencyDecimalSeparator: string; + currencyGroupSeparator: string; + currencyGroupSize: number; + startsWithCurrencySymbol: boolean; +} + +export interface Totalizer { + id: string; + name: string; + value: number; + alternativeTotals?: Totalizer[]; +} + +export interface OrderFormItemInput { + id?: number; + index?: number; + quantity?: number; + seller?: string; + uniqueId?: string; + // options?: AssemblyOptionInput[] +} + +export interface LegacySearchArgs { + query?: string; + page: number; + count: number; + type: "product_search" | "facets"; + selectedFacets?: SelectedFacet[]; + sort?: LegacySort; +} + +export type LegacySort = + | "OrderByPriceDESC" + | "OrderByPriceASC" + | "OrderByTopSaleDESC" + | "OrderByReviewRateDESC" + | "OrderByNameASC" + | "OrderByNameDESC" + | "OrderByReleaseDateDESC" + | "OrderByBestDiscountDESC" + | "OrderByScoreDESC" + | ""; + +export type Fuzzy = "0" | "1" | "auto"; + +export interface SearchArgs { + query?: string; + page: number; + count: number; + type: "product_search" | "facets"; + sort?: Sort; + selectedFacets?: SelectedFacet[]; + fuzzy?: Fuzzy; + hideUnavailableItems?: boolean; + locale?: string; + segment?: Partial; +} + +export interface SelectedFacet { + /** + * @title Key + * @description The key of the facet (e.g. "brand", "category-1", "productClusterIds") + */ + key: string; + /** + * @title Value + * @description The value of the facet + */ + value: string; +} + +export type Sort = + | "price:desc" + | "price:asc" + | "orders:desc" + | "name:desc" + | "name:asc" + | "release:desc" + | "discount:desc" + | ""; + +export interface Suggestion { + searches: Search[]; +} + +export interface Search { + term: string; + count: number; +} + +export interface ProductSearchResult { + /** + * @description Total of products. + */ + recordsFiltered: number; + products: Product[]; + pagination: Pagination; + sampling: boolean; + options: Options; + translated: boolean; + locale: string; + query: string; + operator: "and" | "or"; + redirect: string; + fuzzy: string; + correction?: Correction; +} + +export interface Correction { + misspelled: boolean; +} + +export interface Options { + sorts: { + field: string; + order: string; + active?: boolean; + proxyURL: string; + }[]; + counts: Count[]; +} + +export interface Count { + count: number; + proxyURL: string; +} + +export interface Pagination { + count: number; + current: Page; + before: Page[]; + after: Page[]; + perPage: number; + next: Page; + previous: Page; + first: Page; + last: Page; +} + +export interface Page { + index: number; + proxyUrl: string; +} + +export interface First { + index: number; +} + +export interface Suggestion { + searches: Search[]; +} + +export interface Search { + term: string; + count: number; +} + +export interface IProduct { + productId: string; + productName: string; + brand: string; + brandId: number; + brandImageUrl?: string; + cacheId?: string; + linkText: string; + productReference: string; + categoryId: string; + clusterHighlights: Record; + productClusters: Record; + categories: string[]; + categoriesIds: string[]; + link: string; + description: string; + skuSpecifications?: SkuSpecification[]; + priceRange: PriceRange; + specificationGroups: SpecificationGroup[]; + properties: Array<{ name: string; values: string[] }>; + selectedProperties: Array<{ key: string; value: string }>; + releaseDate: string; + origin?: string; +} + +export type Product = IProduct & { + items: Item[]; + clusterHighlights: Array<{ id: string; name: string }>; + productClusters: Array<{ id: string; name: string }>; +}; + +export type LegacyProduct = IProduct & { + metaTagDescription: string; + productTitle: string; + items: LegacyItem[]; + allSpecifications: string[]; + allSpecificationsGroups?: string[]; +}; + +export type LegacyFacets = { + Departments: LegacyFacet[]; + CategoriesTrees: LegacyFacet[]; + Brands: LegacyFacet[]; + SpecificationFilters: Record; + PriceRanges: LegacyFacet[]; +}; + +export interface PageType { + id: string | null; + name: string | null; + url: string | null; + title: string | null; + metaTagDescription: string | null; + pageType: + | "Brand" + | "Category" + | "Department" + | "SubCategory" + | "Product" + | "Collection" + | "Cluster" + | "NotFound" + | "FullText" + | "Search"; +} + +export interface Category { + id: number; + name: string; + hasChildren: boolean; + children: Category[]; + url: string; + Title?: string; + MetaTagDescription?: string; +} + +export interface LegacyFacet { + Id: number; + Quantity: number; + Name: string; + Link: string; + LinkEncoded: string; + Map: string; + Value: string; + Slug?: string; + Children: LegacyFacet[]; +} + +export interface Image { + imageId: string; + imageLabel: string | null; + imageTag: string; + imageUrl: string; + imageText: string; +} + +export interface Installment { + Value: number; + InterestRate: number; + TotalValuePlusInterestRate: number; + NumberOfInstallments: number; + PaymentSystemName: string; + PaymentSystemGroupName: string; + Name: string; +} + +export type LegacyItem = Omit & { + variations: string[]; + Videos: string[]; +} & Record; + +export interface Item { + itemId: string; + name: string; + nameComplete: string; + complementName: string; + ean: string; + estimatedDateArrival: string; + referenceId?: Array<{ Key: string; Value: string }>; + measurementUnit: string; + unitMultiplier: number; + modalType: string | null; + images: Image[]; + videos: string[]; + variations: Array<{ + name: string; + values: string[]; + }>; + sellers: Seller[]; + attachments: Array<{ + id: number; + name: string; + required: boolean; + domainValues: string; + }>; + isKit: boolean; + kitItems?: Array<{ + itemId: string; + amount: number; + }>; +} + +export interface TeasersParametersLegacy { + "k__BackingField": string; + "k__BackingField": string; +} + +export interface TeasersConditionsLegacy { + "k__BackingField": number; + "k__BackingField": TeasersParametersLegacy[]; +} + +export interface TeasersEffectLegacy { + "k__BackingField": TeasersParametersLegacy[]; +} + +export interface TeasersLegacy { + "k__BackingField": string; + "k__BackingField": unknown; + "k__BackingField": TeasersConditionsLegacy; + "k__BackingField": TeasersEffectLegacy; +} + +export interface TeasersParameters { + name: string; + value: string; +} + +export interface TeasersConditions { + minimumQuantity: number; + parameters: TeasersParameters[]; +} + +export interface TeasersEffect { + parameters: TeasersParameters[]; +} + +export interface Teasers { + name: string; + generalValues?: unknown; + conditions: TeasersConditions; + effects: TeasersEffect; +} + +export interface CommertialOffer { + DeliverySlaSamplesPerRegion: Record< + string, + { DeliverySlaPerTypes: unknown[]; Region: unknown | null } + >; + Installments: Installment[]; + DiscountHighLight: unknown[]; + GiftSkuIds: string[]; + Teasers: TeasersLegacy[]; + teasers?: Teasers[]; + BuyTogether: unknown[]; + ItemMetadataAttachment: unknown[]; + Price: number; + ListPrice: number; + spotPrice: number; + PriceWithoutDiscount: number; + RewardValue: number; + PriceValidUntil: string; + AvailableQuantity: number; + Tax: number; + DeliverySlaSamples: Array<{ + DeliverySlaPerTypes: unknown[]; + Region: unknown | null; + }>; + GetInfoErrorMessage: unknown | null; + CacheVersionUsedToCallCheckout: string; +} + +export interface Seller { + sellerId: string; + sellerName: string; + addToCartLink: string; + sellerDefault: boolean; + commertialOffer: CommertialOffer; +} + +export interface SkuSpecification { + field: SKUSpecificationField; + values: SKUSpecificationValue[]; +} +export interface SKUSpecificationValue { + name: string; + id?: string; + fieldId?: string; + originalName?: string; +} + +export interface SKUSpecificationField { + name: string; + originalName?: string; + id?: string; +} + +export interface Price { + highPrice: number | null; + lowPrice: number | null; +} + +export interface PriceRange { + sellingPrice: Price; + listPrice: Price; +} + +export interface SpecificationGroup { + name: string; + originalName: string; + specifications: Array<{ + name: string; + originalName: string; + values: string[]; + }>; +} + +export type FilterType = RangeFacet["type"] | BooleanFacet["type"]; + +export interface FacetSearchResult { + facets: Facet[]; + breadcrumb: Breadcrumb[]; +} + +export interface BaseFacet { + name: string; + hidden: boolean; + key: string; + quantity: number; +} + +export interface RangeFacet extends BaseFacet { + type: "PRICERANGE"; + values: FacetValueRange[]; +} + +export interface BooleanFacet extends BaseFacet { + type: "TEXT" | "NUMBER"; + values: FacetValueBoolean[]; +} + +export type Facet = RangeFacet | BooleanFacet; + +export interface FacetValueBoolean { + quantity: number; + name: string; + key: string; + value: string; + selected: boolean; + href: string; +} + +export interface FacetValueRange { + selected: boolean; + quantity: number; + range: { + from: number; + to: number; + }; +} + +export interface Breadcrumb { + href: string; + name: string; +} + +export interface SKU { + id: number; + quantity: number; + seller: string; +} + +export interface SimulationOrderForm { + items: Item[]; + ratesAndBenefitsData: RatesAndBenefitsData; + paymentData: PaymentData; + selectableGifts: unknown[]; + marketingData?: MarketingData; + postalCode: string; + country: string; + logisticsInfo: LogisticsInfo[]; + messages: Message[]; + purchaseConditions: PurchaseConditions; + pickupPoints: PickupPoint[]; + subscriptionData?: unknown; + totals: Total[]; + itemMetadata?: ItemMetadata; + allowMultipleDeliveries: boolean; +} + +export interface Total { + id: string; + name: string; + value: number; +} + +export interface PurchaseConditions { + itemPurchaseConditions: ItemPurchaseCondition[]; +} + +export interface ItemPurchaseCondition { + id: string; + seller: string; + sellerChain: string[]; + slas: Sla[]; + price: number; + listPrice: number; +} + +export interface DeliveryId { + courierId: string; + warehouseId: string; + dockId: string; + courierName: string; + quantity: number; + kitItemDetails: unknown[]; +} + +export interface Installment { + count: number; + hasInterestRate: boolean; + interestRate: number; + value: number; + total: number; + sellerMerchantInstallments: SellerMerchantInstallment[]; +} + +export interface SellerMerchantInstallment { + id: string; + count: number; + hasInterestRate: boolean; + interestRate: number; + value: number; + total: number; +} + +export interface Teaser { + featured: boolean; + id: string; + name: string; + conditions: Conditions; + effects: Effects; + teaserType: string; +} + +export interface Effects { + parameters: Parameter[]; +} + +export interface Conditions { + parameters: Parameter[]; + minimumQuantity: number; +} + +export interface Parameter { + name: string; + value: string; +} + +export interface PriceDefinition { + calculatedSellingPrice: number; + total: number; + sellingPrices: SellingPrice[]; +} + +export interface SellingPrice { + value: number; + quantity: number; +} + +export interface Offering { + type: string; + id: string; + name: string; + allowGiftMessage: boolean; + attachmentOfferings: unknown[]; + price: number; +} + +export type CrossSellingType = + | "whosawalsosaw" + | "whosawalsobought" + | "whoboughtalsobought" + | "showtogether" + | "accessories" + | "similars" + | "suggestions"; + +export interface CrossSellingArgs { + productId: string; + type: CrossSellingType; +} + +export interface Segment { + campaigns: unknown | null; + /** @description 1,2,3 etc */ + channel: string; + priceTables: string | null; + regionId: string | null; + utm_campaign: string | null; + utm_source: string | null; + utm_medium: string | null; + utmi_campaign: string | null; + utmi_page: string | null; + utmi_part: string | null; + /** @description BRL, USD stc */ + currencyCode: string; + /** @description R$, $ etc */ + currencySymbol: string; + /** @description BRA, USA etc */ + countryCode: string; + /** @description pt-BR, en-US etc */ + cultureInfo: string; + channelPrivacy: "public" | "private"; +} + +export interface WishlistItem { + id: string; + productId: string; + sku: string; + title: string; +} + +export interface PortalSuggestion { + itemsReturned: ItemsReturned[]; +} + +export interface ItemsReturned { + items: Item[]; + thumb: string; + thumbUrl: null | string; + name: string; + href: string; + criteria: null | string; +} + +export interface Item { + productId: string; + itemId: string; + name: string; + nameComplete: string; + imageUrl: string; +} + +export interface SimulationItem { + id: number; + quantity: number; + seller: string; +} + +export type SPEvent = + | { + type: "session.ping"; + url: string; + } + | { + type: "page.cart"; + products: { + productId: string; + quantity: number; + }[]; + } + | { + type: "page.empty_cart"; + // Empty array is converted to a invalid json schema... so, let it be anything. + // deno-lint-ignore ban-types + products: {}; + } + | { + type: "page.confirmation"; + order: string; + products: { + productId: string; + quantity: number; + price: number; + }[]; + } + | { + type: "search.click"; + position: number; + text: string; + productId: string; + url: string; + } + | { + type: "search.query"; + url: string; + text: string; + misspelled: boolean; + match: number; + operator: string; + locale: string; + }; + +export interface CreateNewDocument { + Id?: string; + Href?: string; + DocumentId?: string; +} + +export interface Document extends Record { + additionalProperties?: string; + id?: string; + accountId?: string; + accountName?: string; + dataEntityId?: string; +} + +export interface SelectableGifts { + id: string; + selectedGifts: { + id: string; + seller: string; + index: number; + }[]; +} + +export interface Brand { + id: number; + name: string; + isActive: boolean; + title: string; + metaTagDescription: string; + imageUrl: string | null; +} + +export interface Collection { + id: number; + name: "Live influencers"; + searchable: boolean; + highlight: boolean; + dateFrom: string; + dateTo: string; + totalSku: number; + totalProducts: number; + type: "Manual" | "Automatic" | "Hybrid"; + lastModifiedBy?: string; +} + +export interface CollectionList { + paging: { + page: number; + perPage: number; + total: number; + pages: number; + limit: number; + }; + items?: Collection[]; +} + +export interface ProductRatingAndReviews { + productRating?: ProductRating; + productReviews?: ProductReviewData; +} + +export interface ProductRating { + average?: number; + totalCount?: number; +} + +export interface ProductReview { + id?: string | undefined; + productId?: string | undefined; + rating?: number | undefined; + title?: string | undefined; + text?: string | undefined; + reviewerName?: string | undefined; + shopperId?: string | undefined; + reviewDateTime?: string | undefined; + searchDate?: string | undefined; + verifiedPurchaser?: boolean | undefined; + sku?: string | null; + approved?: boolean; + location?: string | null; + locale?: string | null; + pastReviews?: string | null; +} + +export interface ProductReviewRange { + total?: number; + from?: number; + to?: number; +} + +export interface ProductReviewData { + data?: ProductReview[] | undefined; + range?: ProductReviewRange; +} + +export interface ProductBalance { + hasUnlimitedQuantity?: boolean; + leadTime?: string; + reservedQuantity?: number; + totalQuantity?: number; + warehouseId?: string; + warehouseName?: string; +} + +export interface ProductInventoryData { + skuId?: string; + balance?: ProductBalance[]; +} + +interface OrderPlacedSeller { + id: string; + name: string; + logo: string; +} + +export interface OrderPlaced { + sellers: OrderPlacedSeller[]; + orderId: string; + orderGroup: string; + state: string; + isCheckedIn: boolean; + sellerOrderId: string; + storeId: string | null; + checkedInPickupPointId: string | null; + value: number; + items: OrderFormItem[]; + totals: Total[]; + clientProfileData: ClientProfileData; + ratesAndBenefitsData: RatesAndBenefitsData; + shippingData: ShippingData; + paymentData: PaymentData; + clientPreferencesData: ClientPreferencesData; + commercialConditionData: null; + giftRegistryData: null; + marketingData: MarketingData | null; + storePreferencesData: StorePreferencesData; + openTextField: null; + invoiceData: null; + itemMetadata: ItemMetadata; + taxData: null; + customData: null; + hooksData: null; + changeData: null; + subscriptionData: null; + merchantContextData: null; + purchaseAgentData: null; + salesChannel: string; + followUpEmail: string; + creationDate: string; + lastChange: string; + timeZoneCreationDate: string; + timeZoneLastChange: string; + isCompleted: boolean; + hostName: string; + merchantName: string | null; + userType: string; + roundingError: number; + allowEdition: boolean; + allowCancellation: boolean; + isUserDataVisible: boolean; + cancellationData: CancelattionData; + orderFormCreationDate: string; + marketplaceRequestedCancellationWindow: null; +} + +interface CancelattionData { + requestedByUser: true; + reason: string; + cancellationDate: string; + cancellationRequestId: string; + requestedBy: null; + cancellationSource: null; +} + +export interface Order { + ShippingEstimatedDate?: Date; + ShippingEstimatedDateMax: string; + ShippingEstimatedDateMin: string; + affiliateId: string; + authorizedDate: string; + callCenterOperatorName: string; + clientName: string; + creationDate: string; + currencyCode: string; + deliveryDates: Date[]; + giftCardProviders: string[]; + hostname: string; + invoiceInput: string; + invoiceOutput: string[]; + isAllDelivered: boolean; + isAnyDelivered: boolean; + items: OrderFormItem[]; + lastChange: string; + lastMessageUnread: string; + listId: string; + listType: string; + marketPlaceOrderId: string; + orderFormId: string; + orderId: string; + orderIsComplete: boolean; + origin: string; + paymentApprovedDate: string; + paymentNames: string; + salesChannel: string; + sequence: string; + status: string; + statusDescription: string; + totalItems: number; + totalValue: number; + workflowInErrorState: boolean; + workflowInRetry: boolean; +} + +export interface Orders { + facets: Facet[]; + list: Order[]; + paging: { + total: number; + pages: number; + currentPage: number; + perPage: number; + }; + reportRecordsLimit: number; + stats: { + stats: { + totalItems: { + Count: number; + Max: number; + Mean: number; + Min: number; + Missing: number; + StdDev: number; + Sum: number; + SumOfSquares: number; + }; + totalValue: { + Count: number; + Max: number; + Mean: number; + Min: number; + Missing: number; + StdDev: number; + Sum: number; + SumOfSquares: number; + }; + }; + }; +} + +export interface SalesChannel { + Id?: number; + Name?: string; + IsActive?: boolean; + ProductClusterId?: number | null; + CountryCode?: string; + CultureInfo?: string; + TimeZone?: string; + CurrencyCode?: string; + CurrencySymbol?: string; + CurrencyLocale?: number; + CurrencyFormatInfo?: { + CurrencyDecimalDigits?: number; + CurrencyDecimalSeparator?: string; + CurrencyGroupSeparator?: string; + CurrencyGroupSize?: number; + StartsWithCurrencySymbol?: boolean; + }; + Origin?: string | null; + Position?: number | null; + ConditionRule?: string | null; + CurrencyDecimalDigits?: number; +} + +export interface Promotion { + idCalculatorConfiguration: string; + name: string; + beginDateUtc: string; + endDateUtc: string; + lastModified: string; + daysAgoOfPurchases: number; + isActive: boolean; + isArchived: boolean; + isFeatured: boolean; + disableDeal: boolean; + activeDaysOfWeek: number[]; + offset: number; + activateGiftsMultiplier: boolean; + newOffset: number; + maxPricesPerItems: string[]; + cumulative: boolean; + nominalShippingDiscountValue: number; + absoluteShippingDiscountValue: number; + nominalDiscountValue: number; + nominalDiscountType: string; + maximumUnitPriceDiscount: number; + percentualDiscountValue: number; + rebatePercentualDiscountValue: number; + percentualShippingDiscountValue: number; + percentualTax: number; + shippingPercentualTax: number; + percentualDiscountValueList1: number; + percentualDiscountValueList2: number; + skusGift: { + quantitySelectable: number; + gifts: number; + }; + nominalRewardValue: number; + percentualRewardValue: number; + orderStatusRewardValue: string; + applyToAllShippings: boolean; + nominalTax: number; + maxPackValue: number; + origin: string; + idSellerIsInclusive: boolean; + idsSalesChannel: string[]; + areSalesChannelIdsExclusive: boolean; + marketingTags: string[]; + marketingTagsAreNotInclusive: boolean; + paymentsMethods: { + id: string; + name: string; + }[]; + stores: string[]; + campaigns: string[]; + storesAreInclusive: boolean; + categories: { + id: string; + name: string; + }[]; + categoriesAreInclusive: boolean; + brands: { + id: string; + name: string; + }[]; + brandsAreInclusive: boolean; + products: { + id: string; + name: string; + }[]; + productsAreInclusive: boolean; + skus: { + id: string; + name: string; + }[]; + skusAreInclusive: boolean; + collections1BuyTogether: { + id: string; + name: string; + }[]; + collections2BuyTogether: { + id: string; + name: string; + }[]; + minimumQuantityBuyTogether: number; + quantityToAffectBuyTogether: number; + enableBuyTogetherPerSku: boolean; + listSku1BuyTogether: { + id: string; + name: string; + }[]; + listSku2BuyTogether: { + id: string; + name: string; + }[]; + coupon: string[]; + totalValueFloor: number; + totalValueCeling: number; + totalValueIncludeAllItems: boolean; + totalValueMode: string; + collections: { + id: string; + name: string; + }[]; + collectionsIsInclusive: boolean; + restrictionsBins: string[]; + cardIssuers: string[]; + totalValuePurchase: number; + slasIds: string[]; + isSlaSelected: boolean; + isFirstBuy: boolean; + firstBuyIsProfileOptimistic: boolean; + compareListPriceAndPrice: boolean; + isDifferentListPriceAndPrice: boolean; + zipCodeRanges: { + zipCodeFrom: string; + zipCodeTo: string; + inclusive: string; + }[]; + itemMaxPrice: number; + itemMinPrice: number; + installment: number; + isMinMaxInstallments: boolean; + minInstallment: number; + maxInstallment: number; + merchants: string[]; + clusterExpressions: string[]; + clusterOperator: string; + paymentsRules: string[]; + giftListTypes: string[]; + productsSpecifications: string[]; + affiliates: { + id: string; + name: string; + }[]; + maxUsage: number; + maxUsagePerClient: number; + shouldDistributeDiscountAmongMatchedItems: boolean; + multipleUsePerClient: boolean; + accumulateWithManualPrice: boolean; + type: string; + useNewProgressiveAlgorithm: boolean; + percentualDiscountValueList: number[]; + isAppliedToMostExpensive: boolean; + maxNumberOfAffectedItems: number; + maxNumberOfAffectedItemsGroupKey: string; +} + +export interface AdvancedLoaderConfig { + /** @description Specifies an array of attribute names from the original object to be directly included in the transformed object. */ + includeOriginalAttributes: string[]; + + /** @description Allow this field if you prefer to use description instead of metaTagDescription */ + preferDescription?: boolean; +} + +export type Maybe = T | null | undefined; + +export interface AddressInput { + name?: string; + addressName: string; + addressType?: string; + city?: string; + complement?: string; + country?: string; + geoCoordinates?: number[]; + neighborhood?: string; + number?: string; + postalCode?: string; + receiverName?: string; + reference?: string; + state?: string; + street?: string; +} + +export interface SavedAddress { + id: string; + cacheId: string; +} + +export type SimulationBehavior = "default" | "skip" | "only1P"; + +export enum DeviceType { + Mobile = "MOBILE", + Tablet = "TABLET", + Desktop = "DESKTOP", +} + +export interface LoginSession { + id: string; + cacheId: string; + deviceType: DeviceType; + city?: Maybe; + lastAccess: string; + browser?: Maybe; + os?: Maybe; + ip?: Maybe; + fullAddress?: Maybe; + firstAccess: string; +} + +export interface LoginSessionsInfo { + currentLoginSessionId?: Maybe; + loginSessions?: Maybe; +} + +export interface Session { + id: string; + namespaces?: { + profile: SessionProfile; + impersonate: SessionImpersonate; + authentication: Record; + public: SessionPublic; + }; +} + +export interface SessionProfile { + id?: { value: string }; + email?: { value: string }; + firstName?: { value: string }; + lastName?: { value: string }; + phone?: { value: string }; + isAuthenticated?: { value: string }; + priceTables?: { value: string }; +} + +export interface SessionImpersonate { + storeUserEmail?: { value: string }; + storeUserId?: { value: string }; +} + +export interface SessionPublic { + orderFormId?: { value: string }; + utm_source?: { value: string }; + utm_medium?: { value: string }; + utm_campaign?: { value: string }; + utm_term?: { value: string }; + utm_content?: { value: string }; + utmi_cp?: { value: string }; + utmi_pc?: { value: string }; + utmi_p?: { value: string }; +} + +export interface ProfileCustomField { + key?: Maybe; + value?: Maybe; +} + +export interface PaymentProfile { + cacheId?: Maybe; + id?: Maybe; + paymentSystem?: Maybe; + paymentSystemName?: Maybe; + cardNumber?: Maybe; + address?: Maybe

; + isExpired?: Maybe; + expirationDate?: Maybe; + accountStatus?: Maybe; +} + +export interface Profile { + cacheId?: Maybe; + firstName?: Maybe; + lastName?: Maybe; + profilePicture?: Maybe; + email?: Maybe; + document?: Maybe; + userId?: Maybe; + birthDate?: Maybe; + gender?: Maybe; + homePhone?: Maybe; + businessPhone?: Maybe; + addresses?: Maybe; + isCorporate?: Maybe; + corporateName?: Maybe; + corporateDocument?: Maybe; + stateRegistration?: Maybe; + payments?: Maybe; + customFields?: Maybe; + passwordLastUpdate?: Maybe; + pii?: Maybe; + tradeName?: Maybe; +} + +export interface ProfileInput { + email: string; + firstName?: Maybe; + lastName?: Maybe; + document?: Maybe; + phone?: Maybe; + birthDate?: Maybe; + gender?: Maybe; + homePhone?: Maybe; + businessPhone?: Maybe; + tradeName?: Maybe; + corporateName?: Maybe; + corporateDocument?: Maybe; + stateRegistration?: Maybe; + isCorporate?: Maybe; +} + +export interface AuthResponse { + authStatus: string | "WrongCredentials" | "BlockedUser" | "Success"; + promptMFA: boolean; + clientToken: string | null; + authCookie: { + Name: string; + Value: string; + } | null; + accountAuthCookie: { + Name: string; + Value: string; + } | null; + expiresIn: number; + userId: string | null; + phoneNumber: string | null; + scope: string | null; +} + +export interface AuthProvider { + providerName: string; + className: string; + expectedContext: unknown[]; +} + +export interface StartAuthentication { + authenticationToken: string | null; + oauthProviders: AuthProvider[]; + showClassicAuthentication: boolean; + showAccessKeyAuthentication: boolean; + showPasskeyAuthentication: boolean; + authCookie: string | null; + isAuthenticated: boolean; + selectedProvider: string | null; + samlProviders: unknown[]; +} + +export interface CanceledOrder { + date?: string; + orderId?: string; + receipt?: string | null; +} + +export interface ReceiptData { + ReceiptCollection: Receipt[]; +} + +export interface Receipt { + ReceiptType: string; + Date: string; + ReceiptToken: string; + Source: string; + InvoiceNumber: string | null; + TransactionId: string; + MerchantName: string; + SellerOrderId: string | null; + ValueAsInt: number | null; +} + +export interface CancellationData { + requestedByUser: boolean; + reason: string; + cancellationDate: string; + cancellationRequestId: string; + requestedBy: string | null; + cancellationSource: string | null; +} + +export interface OrderFormOrder { + sellers: Seller[]; + receiptData?: ReceiptData; + sequence?: string; + marketPlaceOrderId?: string; + origin?: number; + items: OrderFormItem[]; + giftRegistryData?: unknown; + contextData?: unknown; + marketPlaceOrderGroup?: string | null; + marketplaceServicesEndpoint?: string | null; + orderFormId?: string; + affiliateId?: string; + status?: string; + callCenterOperator?: string; + userProfileId?: string; + creationVersion?: string; + creationEnvironment?: string; + lastChangeVersion?: string; + workflowInstanceId?: string; + workflowInstanceGroupId?: string | null; + marketplacePaymentValue?: number | null; + marketplacePaymentReferenceValue?: number | null; + marketplace?: string | null; + orderId: string; + orderGroup: string; + state: string; + isCheckedIn: boolean; + sellerOrderId: string; + storeId?: string | null; + checkedInPickupPointId?: string | null; + value: number; + totals: Total[]; + clientProfileData: ClientProfileData; + ratesAndBenefitsData: RatesAndBenefitsData; + shippingData: ShippingData; + paymentData: PaymentData; + clientPreferencesData: ClientPreferencesData; + commercialConditionData?: unknown; + marketingData?: MarketingData | null; + storePreferencesData: StorePreferencesData; + openTextField?: unknown; + invoiceData?: unknown; + itemMetadata: ItemMetadata; + taxData?: unknown; + customData?: unknown; + hooksData?: unknown; + changeData?: unknown; + subscriptionData?: unknown; + merchantContextData?: unknown; + purchaseAgentData?: unknown; + salesChannel: string; + followUpEmail?: string; + creationDate: string; + lastChange: string; + timeZoneCreationDate: string; + timeZoneLastChange: string; + isCompleted: boolean; + hostName: string; + merchantName?: string | null; + userType?: string; + roundingError?: number; + allowEdition?: boolean; + allowCancellation?: boolean; + isUserDataVisible?: boolean; + allowChangeSeller?: boolean; + cancellationData?: CancellationData; + orderFormCreationDate?: string; + marketplaceRequestedCancellationWindow?: unknown; +} diff --git a/packages/apps-vtex/src/utils/vtexId.ts b/packages/apps-vtex/src/utils/vtexId.ts new file mode 100644 index 0000000..107ab36 --- /dev/null +++ b/packages/apps-vtex/src/utils/vtexId.ts @@ -0,0 +1,138 @@ +/** + * VTEX authentication cookie parser. + * + * Parses the VtexIdclientAutCookie JWT to detect login state + * without making API calls. Only decodes the payload (no signature + * verification -- we only need presence and expiry, not auth). + */ + +export interface VtexAuthInfo { + isLoggedIn: boolean; + email?: string; + account?: string; + /** Unix timestamp (seconds) when the token expires. */ + exp?: number; + /** Whether the token is expired. */ + isExpired: boolean; +} + +const VTEX_AUTH_COOKIE = "VtexIdclientAutCookie"; + +/** + * Extract the VtexIdclientAutCookie value from a cookie string. + */ +export function extractVtexAuthCookie(cookieHeader: string): string | null { + const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${VTEX_AUTH_COOKIE}=([^;]+)`)); + return match?.[1] ?? null; +} + +/** + * Decode a JWT payload without verification. + * Only reads the middle segment (claims). + */ +function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = parts[1]; + const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(decoded); + } catch { + return null; + } +} + +/** + * Parse a VTEX auth cookie token into structured auth info. + */ +export function parseVtexAuthToken(token: string): VtexAuthInfo { + const payload = decodeJwtPayload(token); + if (!payload) { + return { isLoggedIn: false, isExpired: true }; + } + + const exp = typeof payload.exp === "number" ? payload.exp : undefined; + const isExpired = exp != null ? exp * 1000 < Date.now() : false; + const email = + typeof payload.sub === "string" + ? payload.sub + : typeof payload.userId === "string" + ? payload.userId + : undefined; + + const account = typeof payload.account === "string" ? payload.account : undefined; + + return { + isLoggedIn: !isExpired, + email, + account, + exp, + isExpired, + }; +} + +/** + * Check if a request has a valid (non-expired) VTEX auth cookie. + */ +export function isVtexLoggedIn(request: Request): boolean { + const cookies = request.headers.get("cookie") ?? ""; + const token = extractVtexAuthCookie(cookies); + if (!token) return false; + return parseVtexAuthToken(token).isLoggedIn; +} + +/** + * Build a complete auth cookie header string from either a raw token + * or an already-formatted cookie string. + * + * VTEX requires both `VtexIdclientAutCookie` and the account-suffixed + * variant `VtexIdclientAutCookie_{account}` for authenticated GraphQL + * calls to myvtex.com. + * + * If `authCookie` already contains `=` (i.e. it's a full cookie string), + * it's returned as-is. Otherwise the token is wrapped in both cookie names. + */ +export function buildAuthCookieHeader(authCookie: string, account: string): string { + if (authCookie.includes("=")) return authCookie; + return `${VTEX_AUTH_COOKIE}=${authCookie}; ${VTEX_AUTH_COOKIE}_${account}=${authCookie}`; +} + +export interface CookiePayload { + sub?: string; + account?: string; + audience?: string; + sess?: string; + exp?: number; + userId?: string; +} + +/** + * Parse VTEX auth cookies from request headers. + * + * Returns the serialized cookie string (for forwarding) and the decoded + * JWT payload. Compatible with the legacy deco-cx/apps parseCookie API. + */ +export function parseCookie( + headers: Headers, + account: string, +): { cookie: string; payload: CookiePayload | undefined } { + const cookieHeader = headers.get("cookie") ?? ""; + + const base = extractVtexAuthCookie(cookieHeader); + const suffixedRe = new RegExp(`(?:^|;\\s*)${VTEX_AUTH_COOKIE}_${account}=([^;]+)`); + const suffixedMatch = cookieHeader.match(suffixedRe); + const suffixed = suffixedMatch?.[1] ?? null; + + const token = base ?? suffixed; + const payload = token + ? ((decodeJwtPayload(token) as CookiePayload | null) ?? undefined) + : undefined; + + const parts: string[] = []; + if (base) parts.push(`${VTEX_AUTH_COOKIE}=${base}`); + if (suffixed) parts.push(`${VTEX_AUTH_COOKIE}_${account}=${suffixed}`); + + return { cookie: parts.join("; "), payload }; +} + +export { VTEX_AUTH_COOKIE }; diff --git a/packages/apps-vtex/tsconfig.json b/packages/apps-vtex/tsconfig.json new file mode 100644 index 0000000..42386d3 --- /dev/null +++ b/packages/apps-vtex/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/tanstack/src/index.ts b/packages/tanstack/src/index.ts index d9f2e80..fc3553d 100644 --- a/packages/tanstack/src/index.ts +++ b/packages/tanstack/src/index.ts @@ -28,3 +28,5 @@ export { decoStringifySearch, } from "./sdk/router"; export type { CreateDecoRouterOptions } from "./sdk/router"; +export { createInvokeFn } from "./sdk/createInvoke"; +export type { InvokeFnOpts } from "./sdk/createInvoke"; From 2df7e3caec7985dc5614be32d7998086c5614013 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:00:55 -0300 Subject: [PATCH 31/85] fix(blocks-cli): update generate-invoke.ts for @decocms/apps-vtex's flat layout generate-invoke.ts still resolved @decocms/apps + a vtex/ subpath and emitted @decocms/apps/vtex/... import strings, both stale now that vtex/ is its own package (@decocms/apps-vtex) with no vtex/ nesting. Updated resolveAppsDir(), the invoke.ts lookup path, and every generated import specifier; updated generate-invoke.test.ts's fixture to the new flat layout to match. --- .../scripts/generate-invoke.test.ts | 23 ++++++------- .../blocks-cli/scripts/generate-invoke.ts | 34 +++++++++++-------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/blocks-cli/scripts/generate-invoke.test.ts b/packages/blocks-cli/scripts/generate-invoke.test.ts index e252a44..c23d8a4 100644 --- a/packages/blocks-cli/scripts/generate-invoke.test.ts +++ b/packages/blocks-cli/scripts/generate-invoke.test.ts @@ -1,7 +1,7 @@ /** * Integration test for scripts/generate-invoke.ts. * - * The generator scans a `vtex/invoke.ts` file from @decocms/apps and emits a + * The generator scans an `invoke.ts` file from @decocms/apps-vtex and emits a * site-local `src/server/invoke.gen.ts` with top-level `createServerFn` * declarations. The piece we care most about locking is the Set-Cookie * bridge: every handler must call `forwardResponseCookies()` after the @@ -81,20 +81,17 @@ describe("generate-invoke.ts — output shape", () => { beforeAll(() => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-")); - appsDir = path.join(tmp, "apps"); + // Mirrors @decocms/apps-vtex's actual layout post-migration: invoke.ts + // sits at the package root (no more vtex/ nesting — the old vtex/ + // directory in apps-start IS this package's root now). + appsDir = path.join(tmp, "apps-vtex"); siteDir = path.join(tmp, "site"); - fs.mkdirSync(path.join(appsDir, "vtex", "actions"), { recursive: true }); + fs.mkdirSync(path.join(appsDir, "actions"), { recursive: true }); fs.mkdirSync(path.join(siteDir, "src", "server"), { recursive: true }); - fs.writeFileSync(path.join(appsDir, "vtex", "invoke.ts"), FIXTURE_INVOKE_TS); - fs.writeFileSync( - path.join(appsDir, "vtex", "actions", "checkout.ts"), - FIXTURE_ACTIONS_CHECKOUT_TS, - ); - fs.writeFileSync( - path.join(appsDir, "vtex", "actions", "session.ts"), - FIXTURE_ACTIONS_SESSION_TS, - ); - fs.writeFileSync(path.join(appsDir, "vtex", "types.ts"), FIXTURE_TYPES_TS); + fs.writeFileSync(path.join(appsDir, "invoke.ts"), FIXTURE_INVOKE_TS); + fs.writeFileSync(path.join(appsDir, "actions", "checkout.ts"), FIXTURE_ACTIONS_CHECKOUT_TS); + fs.writeFileSync(path.join(appsDir, "actions", "session.ts"), FIXTURE_ACTIONS_SESSION_TS); + fs.writeFileSync(path.join(appsDir, "types.ts"), FIXTURE_TYPES_TS); outFile = path.join(siteDir, "src", "server", "invoke.gen.ts"); const result = spawnSync( diff --git a/packages/blocks-cli/scripts/generate-invoke.ts b/packages/blocks-cli/scripts/generate-invoke.ts index d7da3c3..dc07b7e 100644 --- a/packages/blocks-cli/scripts/generate-invoke.ts +++ b/packages/blocks-cli/scripts/generate-invoke.ts @@ -1,11 +1,11 @@ #!/usr/bin/env tsx /** - * Scans @decocms/apps vtex/invoke.ts and generates a site-local invoke file + * Scans @decocms/apps-vtex's invoke.ts and generates a site-local invoke file * with top-level createServerFn declarations. * * TanStack Start's compiler only transforms createServerFn().handler() when * the call is at module top-level (assigned to a const). The factory pattern - * used in @decocms/apps/vtex/invoke.ts causes the "fast path" in the compiler + * used in @decocms/apps-vtex/invoke.ts causes the "fast path" in the compiler * to skip the .handler() calls because they're inside a function body. * * This script generates an equivalent file where each server function is a @@ -16,7 +16,7 @@ * * Env / CLI: * --out-file override output (default: src/server/invoke.gen.ts) - * --apps-dir override @decocms/apps location (default: auto-resolve from node_modules) + * --apps-dir override @decocms/apps-vtex location (default: auto-resolve from node_modules) */ import fs from "node:fs"; import path from "node:path"; @@ -35,19 +35,23 @@ function resolveAppsDir(): string { const explicit = arg("apps-dir", ""); if (explicit) return path.resolve(cwd, explicit); - // Try common locations + // Try common locations. Both point at the directory that contains + // invoke.ts directly at its root: the installed @decocms/apps-vtex + // package (no more vtex/ nesting — that directory IS the package root + // now), or a raw apps-start checkout's vtex/ subdirectory as a legacy + // fallback for anyone still developing against the pre-split monorepo. const candidates = [ - path.resolve(cwd, "node_modules/@decocms/apps"), - path.resolve(cwd, "../apps-start"), + path.resolve(cwd, "node_modules/@decocms/apps-vtex"), + path.resolve(cwd, "../apps-start/vtex"), ]; for (const c of candidates) { - if (fs.existsSync(path.join(c, "vtex/invoke.ts"))) return c; + if (fs.existsSync(path.join(c, "invoke.ts"))) return c; } - throw new Error("Could not find @decocms/apps. Use --apps-dir to specify its location."); + throw new Error("Could not find @decocms/apps-vtex. Use --apps-dir to specify its location."); } const appsDir = resolveAppsDir(); -const invokeFile = path.join(appsDir, "vtex/invoke.ts"); +const invokeFile = path.join(appsDir, "invoke.ts"); if (!fs.existsSync(invokeFile)) { console.error(`invoke.ts not found at: ${invokeFile}`); @@ -60,7 +64,7 @@ if (!fs.existsSync(invokeFile)) { interface ActionDef { name: string; - /** The import source for the action function (e.g., "@decocms/apps/vtex/actions/checkout") */ + /** The import source for the action function (e.g., "@decocms/apps-vtex/actions/checkout") */ importSource: string; /** The imported function name (e.g., "addItemsToCart") */ importedFn: string; @@ -85,7 +89,7 @@ for (const imp of sourceFile.getImportDeclarations()) { const localName = named.getName(); const importedName = named.getAliasNode()?.getText() || localName; importMap.set(localName, { - source: source.startsWith("./") ? `@decocms/apps/vtex/${source.slice(2)}` : source, + source: source.startsWith("./") ? `@decocms/apps-vtex/${source.slice(2)}` : source, importedName: localName, }); } @@ -100,7 +104,7 @@ for (const imp of sourceFile.getImportDeclarations()) { const localName = named.getName(); const source = imp.getModuleSpecifierValue(); typeImportMap.set(localName, { - source: source.startsWith("./") ? `@decocms/apps/vtex/${source.slice(2)}` : source, + source: source.startsWith("./") ? `@decocms/apps-vtex/${source.slice(2)}` : source, importedName: localName, }); } @@ -111,7 +115,7 @@ for (const imp of sourceFile.getImportDeclarations()) { for (const named of imp.getNamedImports()) { const localName = named.getName(); typeImportMap.set(localName, { - source: source.startsWith("./") ? `@decocms/apps/vtex/${source.slice(2)}` : source, + source: source.startsWith("./") ? `@decocms/apps-vtex/${source.slice(2)}` : source, importedName: localName, }); } @@ -376,7 +380,7 @@ for (const action of actions) { if (action.importedFn) { // Emit the wrapper body verbatim. The arrow function in - // @decocms/apps/vtex/invoke.ts is the contract that maps the external + // @decocms/apps-vtex/invoke.ts is the contract that maps the external // invoke shape (what storefront callers send) to the internal action // shape (what vtex/actions/* expects). Most wrappers are direct // pass-throughs (`actionFn(data)`) but some adapt the payload @@ -450,7 +454,7 @@ for (const action of actions) { out += `} as const; // Re-export OrderForm type (commonly imported from invoke by site components) -export type { OrderForm } from "@decocms/apps/vtex/types"; +export type { OrderForm } from "@decocms/apps-vtex/types"; // --------------------------------------------------------------------------- // Default invoke object — import this if you don't need site extensions From c7604df862eec1cfb72cd77e4a59a9a8acf7ae23 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:07:45 -0300 Subject: [PATCH 32/85] feat(apps-shopify): migrate shopify/ from apps-start Co-Authored-By: Claude Sonnet 5 --- bun.lock | 21 + packages/apps-shopify/package.json | 42 ++ .../apps-shopify/src/actions/cart/addItems.ts | 36 ++ .../src/actions/cart/updateCoupons.ts | 31 ++ .../src/actions/cart/updateItems.ts | 31 ++ .../apps-shopify/src/actions/user/signIn.ts | 40 ++ .../apps-shopify/src/actions/user/signUp.ts | 31 ++ packages/apps-shopify/src/client.ts | 58 ++ packages/apps-shopify/src/index.ts | 43 ++ packages/apps-shopify/src/init.ts | 39 ++ .../src/loaders/ProductDetailsPage.ts | 31 ++ .../apps-shopify/src/loaders/ProductList.ts | 102 ++++ .../src/loaders/ProductListingPage.ts | 172 ++++++ .../src/loaders/RelatedProducts.ts | 42 ++ packages/apps-shopify/src/loaders/cart.ts | 74 +++ packages/apps-shopify/src/loaders/shop.ts | 35 ++ packages/apps-shopify/src/loaders/user.ts | 39 ++ packages/apps-shopify/src/manifest.gen.ts | 39 ++ packages/apps-shopify/src/mod.ts | 67 +++ packages/apps-shopify/src/registry.ts | 9 + .../__tests__/graphqlOperationName.test.ts | 80 +++ .../utils/__tests__/instrumentedFetch.test.ts | 84 +++ .../utils/__tests__/operationRouter.test.ts | 56 ++ .../src/utils/__tests__/transform.test.ts | 216 +++++++ .../apps-shopify/src/utils/admin/admin.ts | 57 ++ .../apps-shopify/src/utils/admin/queries.ts | 29 + packages/apps-shopify/src/utils/cart.ts | 32 ++ packages/apps-shopify/src/utils/cookies.ts | 82 +++ packages/apps-shopify/src/utils/enums.ts | 438 +++++++++++++++ packages/apps-shopify/src/utils/graphql.ts | 69 +++ .../src/utils/graphqlOperationName.ts | 67 +++ .../src/utils/instrumentedFetch.ts | 55 ++ .../apps-shopify/src/utils/operationRouter.ts | 70 +++ .../src/utils/storefront/queries.ts | 525 ++++++++++++++++++ packages/apps-shopify/src/utils/transform.ts | 430 ++++++++++++++ packages/apps-shopify/src/utils/types.ts | 191 +++++++ packages/apps-shopify/src/utils/user.ts | 23 + packages/apps-shopify/src/utils/utils.ts | 159 ++++++ packages/apps-shopify/tsconfig.json | 7 + 39 files changed, 3652 insertions(+) create mode 100644 packages/apps-shopify/package.json create mode 100644 packages/apps-shopify/src/actions/cart/addItems.ts create mode 100644 packages/apps-shopify/src/actions/cart/updateCoupons.ts create mode 100644 packages/apps-shopify/src/actions/cart/updateItems.ts create mode 100644 packages/apps-shopify/src/actions/user/signIn.ts create mode 100644 packages/apps-shopify/src/actions/user/signUp.ts create mode 100644 packages/apps-shopify/src/client.ts create mode 100644 packages/apps-shopify/src/index.ts create mode 100644 packages/apps-shopify/src/init.ts create mode 100644 packages/apps-shopify/src/loaders/ProductDetailsPage.ts create mode 100644 packages/apps-shopify/src/loaders/ProductList.ts create mode 100644 packages/apps-shopify/src/loaders/ProductListingPage.ts create mode 100644 packages/apps-shopify/src/loaders/RelatedProducts.ts create mode 100644 packages/apps-shopify/src/loaders/cart.ts create mode 100644 packages/apps-shopify/src/loaders/shop.ts create mode 100644 packages/apps-shopify/src/loaders/user.ts create mode 100644 packages/apps-shopify/src/manifest.gen.ts create mode 100644 packages/apps-shopify/src/mod.ts create mode 100644 packages/apps-shopify/src/registry.ts create mode 100644 packages/apps-shopify/src/utils/__tests__/graphqlOperationName.test.ts create mode 100644 packages/apps-shopify/src/utils/__tests__/instrumentedFetch.test.ts create mode 100644 packages/apps-shopify/src/utils/__tests__/operationRouter.test.ts create mode 100644 packages/apps-shopify/src/utils/__tests__/transform.test.ts create mode 100644 packages/apps-shopify/src/utils/admin/admin.ts create mode 100644 packages/apps-shopify/src/utils/admin/queries.ts create mode 100644 packages/apps-shopify/src/utils/cart.ts create mode 100644 packages/apps-shopify/src/utils/cookies.ts create mode 100644 packages/apps-shopify/src/utils/enums.ts create mode 100644 packages/apps-shopify/src/utils/graphql.ts create mode 100644 packages/apps-shopify/src/utils/graphqlOperationName.ts create mode 100644 packages/apps-shopify/src/utils/instrumentedFetch.ts create mode 100644 packages/apps-shopify/src/utils/operationRouter.ts create mode 100644 packages/apps-shopify/src/utils/storefront/queries.ts create mode 100644 packages/apps-shopify/src/utils/transform.ts create mode 100644 packages/apps-shopify/src/utils/types.ts create mode 100644 packages/apps-shopify/src/utils/user.ts create mode 100644 packages/apps-shopify/src/utils/utils.ts create mode 100644 packages/apps-shopify/tsconfig.json diff --git a/bun.lock b/bun.lock index fd350b5..317eb48 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,25 @@ "react-dom": "^19.0.0", }, }, + "packages/apps-shopify": { + "name": "@decocms/apps-shopify", + "version": "0.0.0", + "dependencies": { + "@decocms/apps-commerce": "workspace:*", + "@decocms/blocks": "workspace:*", + "@decocms/tanstack": "workspace:*", + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "knip": "^5.86.0", + "typescript": "^5.9.0", + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + }, "packages/apps-vtex": { "name": "@decocms/apps-vtex", "version": "0.0.0", @@ -310,6 +329,8 @@ "@decocms/apps-commerce": ["@decocms/apps-commerce@workspace:packages/apps-commerce"], + "@decocms/apps-shopify": ["@decocms/apps-shopify@workspace:packages/apps-shopify"], + "@decocms/apps-vtex": ["@decocms/apps-vtex@workspace:packages/apps-vtex"], "@decocms/apps-website": ["@decocms/apps-website@workspace:packages/apps-website"], diff --git a/packages/apps-shopify/package.json b/packages/apps-shopify/package.json new file mode 100644 index 0000000..4b2a6d4 --- /dev/null +++ b/packages/apps-shopify/package.json @@ -0,0 +1,42 @@ +{ + "name": "@decocms/apps-shopify", + "version": "0.0.0", + "type": "module", + "description": "Deco commerce app: Shopify integration", + "repository": { + "type": "git", + "url": "https://github.com/decocms/blocks.git", + "directory": "packages/apps-shopify" + }, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./mod": "./src/mod.ts", + "./client": "./src/client.ts", + "./loaders/*": "./src/loaders/*.ts", + "./actions/*": "./src/actions/*.ts", + "./actions/cart/*": "./src/actions/cart/*.ts", + "./actions/user/*": "./src/actions/user/*.ts", + "./utils/*": "./src/utils/*.ts", + "./registry": "./src/registry.ts" + }, + "scripts": { + "build": "tsc", + "test": "vitest run --root ../.. packages/apps-shopify/", + "typecheck": "tsc --noEmit", + "lint:unused": "knip" + }, + "dependencies": { + "@decocms/blocks": "workspace:*", + "@decocms/apps-commerce": "workspace:*", + "@decocms/tanstack": "workspace:*" + }, + "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "knip": "^5.86.0", + "typescript": "^5.9.0" + }, + "publishConfig": { "registry": "https://registry.npmjs.org", "access": "public" } +} diff --git a/packages/apps-shopify/src/actions/cart/addItems.ts b/packages/apps-shopify/src/actions/cart/addItems.ts new file mode 100644 index 0000000..13b978a --- /dev/null +++ b/packages/apps-shopify/src/actions/cart/addItems.ts @@ -0,0 +1,36 @@ +import { getShopifyClient } from "../../client"; +import type { ShopifyCart } from "../../loaders/cart"; +import { getCartCookie, setCartCookie } from "../../utils/cart"; +import { AddItemToCart } from "../../utils/storefront/queries"; + +export interface AddItemProps { + lines: { + merchandiseId: string; + attributes?: Array<{ key: string; value: string }>; + quantity?: number; + sellingPlanId?: string; + }; + requestHeaders: Headers; + responseHeaders?: Headers; +} + +export default async function addItems({ + lines, + requestHeaders, + responseHeaders, +}: AddItemProps): Promise { + const client = getShopifyClient(); + const cartId = getCartCookie(requestHeaders); + + if (!cartId) throw new Error("Missing cart cookie"); + + const data = await client.query<{ + payload?: { cart?: ShopifyCart }; + }>(AddItemToCart, { cartId, lines }); + + if (responseHeaders) { + setCartCookie(responseHeaders, cartId); + } + + return data.payload?.cart ?? null; +} diff --git a/packages/apps-shopify/src/actions/cart/updateCoupons.ts b/packages/apps-shopify/src/actions/cart/updateCoupons.ts new file mode 100644 index 0000000..b9cea15 --- /dev/null +++ b/packages/apps-shopify/src/actions/cart/updateCoupons.ts @@ -0,0 +1,31 @@ +import { getShopifyClient } from "../../client"; +import type { ShopifyCart } from "../../loaders/cart"; +import { getCartCookie, setCartCookie } from "../../utils/cart"; +import { AddCoupon } from "../../utils/storefront/queries"; + +export interface UpdateCouponsProps { + discountCodes: string[]; + requestHeaders: Headers; + responseHeaders?: Headers; +} + +export default async function updateCoupons({ + discountCodes, + requestHeaders, + responseHeaders, +}: UpdateCouponsProps): Promise { + const client = getShopifyClient(); + const cartId = getCartCookie(requestHeaders); + + if (!cartId) throw new Error("Missing cart cookie"); + + const data = await client.query<{ + payload?: { cart?: ShopifyCart }; + }>(AddCoupon, { cartId, discountCodes }); + + if (responseHeaders) { + setCartCookie(responseHeaders, cartId); + } + + return data.payload?.cart ?? null; +} diff --git a/packages/apps-shopify/src/actions/cart/updateItems.ts b/packages/apps-shopify/src/actions/cart/updateItems.ts new file mode 100644 index 0000000..3beb3cb --- /dev/null +++ b/packages/apps-shopify/src/actions/cart/updateItems.ts @@ -0,0 +1,31 @@ +import { getShopifyClient } from "../../client"; +import type { ShopifyCart } from "../../loaders/cart"; +import { getCartCookie, setCartCookie } from "../../utils/cart"; +import { UpdateItems } from "../../utils/storefront/queries"; + +export interface UpdateItemsProps { + lines: Array<{ id: string; quantity: number }>; + requestHeaders: Headers; + responseHeaders?: Headers; +} + +export default async function updateItems({ + lines, + requestHeaders, + responseHeaders, +}: UpdateItemsProps): Promise { + const client = getShopifyClient(); + const cartId = getCartCookie(requestHeaders); + + if (!cartId) throw new Error("Missing cart cookie"); + + const data = await client.query<{ + payload?: { cart?: ShopifyCart }; + }>(UpdateItems, { cartId, lines }); + + if (responseHeaders) { + setCartCookie(responseHeaders, cartId); + } + + return data.payload?.cart ?? null; +} diff --git a/packages/apps-shopify/src/actions/user/signIn.ts b/packages/apps-shopify/src/actions/user/signIn.ts new file mode 100644 index 0000000..923701f --- /dev/null +++ b/packages/apps-shopify/src/actions/user/signIn.ts @@ -0,0 +1,40 @@ +import { getShopifyClient } from "../../client"; +import { SignInWithEmailAndPassword } from "../../utils/storefront/queries"; +import { getUserCookie, setUserCookie } from "../../utils/user"; + +export interface SignInProps { + email: string; + password: string; + requestHeaders: Headers; + responseHeaders?: Headers; +} + +export interface SignInResult { + customerAccessTokenCreate: { + customerAccessToken?: { accessToken: string; expiresAt: string } | null; + customerUserErrors?: Array<{ code?: string; message: string }>; + }; +} + +export default async function signIn(props: SignInProps): Promise { + const client = getShopifyClient(); + const { email, password, requestHeaders, responseHeaders } = props; + + const existingToken = getUserCookie(requestHeaders); + if (existingToken) return null; + + try { + const data = await client.query(SignInWithEmailAndPassword, { email, password }); + + if (data.customerAccessTokenCreate.customerAccessToken && responseHeaders) { + setUserCookie( + responseHeaders, + data.customerAccessTokenCreate.customerAccessToken.accessToken, + ); + } + + return data; + } catch { + return null; + } +} diff --git a/packages/apps-shopify/src/actions/user/signUp.ts b/packages/apps-shopify/src/actions/user/signUp.ts new file mode 100644 index 0000000..4a239cc --- /dev/null +++ b/packages/apps-shopify/src/actions/user/signUp.ts @@ -0,0 +1,31 @@ +import { getShopifyClient } from "../../client"; +import { RegisterAccount } from "../../utils/storefront/queries"; + +export interface SignUpProps { + email: string; + password: string; + firstName?: string; + lastName?: string; + acceptsMarketing?: boolean; +} + +export interface SignUpResult { + customerCreate: { + customer?: { id: string } | null; + customerUserErrors?: Array<{ code?: string; message: string }>; + }; +} + +export default async function signUp(props: SignUpProps): Promise { + const client = getShopifyClient(); + + const data = await client.query(RegisterAccount, { + email: props.email, + password: props.password, + firstName: props.firstName, + lastName: props.lastName, + acceptsMarketing: props.acceptsMarketing, + }); + + return data; +} diff --git a/packages/apps-shopify/src/client.ts b/packages/apps-shopify/src/client.ts new file mode 100644 index 0000000..946ec8b --- /dev/null +++ b/packages/apps-shopify/src/client.ts @@ -0,0 +1,58 @@ +import { createGraphqlClient, type GraphQLClient } from "./utils/graphql"; + +export interface ShopifyConfig { + storeName: string; + storefrontAccessToken: string; + publicUrl?: string; +} + +let _client: GraphQLClient | null = null; +let _config: ShopifyConfig | null = null; +let _fetch: typeof fetch | undefined; + +/** + * Override the fetch function used by the Shopify GraphQL client. + * Use this to plug in instrumented fetch for logging/tracing. + * + * @example + * ```ts + * import { createInstrumentedFetch } from "@decocms/blocks/sdk/instrumentedFetch"; + * import { setShopifyFetch } from "@decocms/apps/shopify"; + * setShopifyFetch(createInstrumentedFetch("shopify")); + * ``` + */ +export function setShopifyFetch(fetchFn: typeof fetch) { + _fetch = fetchFn; + if (_config) configureShopify(_config); +} + +export function configureShopify(config: ShopifyConfig) { + _config = config; + _client = createGraphqlClient( + `https://${config.storeName}.myshopify.com/api/2025-04/graphql.json`, + { + "X-Shopify-Storefront-Access-Token": config.storefrontAccessToken, + }, + _fetch, + ); +} + +export function getShopifyClient(): GraphQLClient { + if (!_client || !_config) { + throw new Error( + "Shopify not configured. Call configureShopify() first or check deco-shopify.json block.", + ); + } + return _client; +} + +export function getShopifyConfig(): ShopifyConfig { + if (!_config) { + throw new Error("Shopify not configured."); + } + return _config; +} + +export function getBaseUrl(): string { + return _config?.publicUrl || ""; +} diff --git a/packages/apps-shopify/src/index.ts b/packages/apps-shopify/src/index.ts new file mode 100644 index 0000000..3d10e3d --- /dev/null +++ b/packages/apps-shopify/src/index.ts @@ -0,0 +1,43 @@ +// App contract +export { configure, type ShopifyState } from "./mod"; + +// Client & Config + +export { default as addItems } from "./actions/cart/addItems"; +export { default as updateCoupons } from "./actions/cart/updateCoupons"; +export { default as updateItems } from "./actions/cart/updateItems"; +export { default as signIn } from "./actions/user/signIn"; +export { default as signUp } from "./actions/user/signUp"; +export type { ShopifyConfig } from "./client"; +export { + configureShopify, + getBaseUrl, + getShopifyClient, + getShopifyConfig, + setShopifyFetch, +} from "./client"; +export { initShopify, initShopifyFromBlocks } from "./init"; +export type { CartLine, ShopifyCart } from "./loaders/cart"; +// Cart +export { createCart, getCart } from "./loaders/cart"; +export { default as productDetailsPageLoader } from "./loaders/ProductDetailsPage"; +// Product Loaders +export { default as productListLoader } from "./loaders/ProductList"; +export { default as productListingPageLoader } from "./loaders/ProductListingPage"; +export { default as relatedProductsLoader } from "./loaders/RelatedProducts"; +export type { Shop } from "./loaders/shop"; +// Shop +export { default as shopLoader } from "./loaders/shop"; +export type { ShopifyUser } from "./loaders/user"; +// User +export { default as userLoader } from "./loaders/user"; +export { getCartCookie, setCartCookie } from "./utils/cart"; +// Cookie utils +export { getCookies, setCookie } from "./utils/cookies"; +export { extractGraphqlOperationName } from "./utils/graphqlOperationName"; +export { + type CreateShopifyFetchOptions, + createShopifyFetch, +} from "./utils/instrumentedFetch"; +export { shopifyOperationRouter } from "./utils/operationRouter"; +export { getUserCookie, setUserCookie } from "./utils/user"; diff --git a/packages/apps-shopify/src/init.ts b/packages/apps-shopify/src/init.ts new file mode 100644 index 0000000..5cdafe9 --- /dev/null +++ b/packages/apps-shopify/src/init.ts @@ -0,0 +1,39 @@ +import { configureShopify } from "./client"; + +let initialized = false; + +/** + * Initialize Shopify from raw block data. + * The site is responsible for reading the blocks and passing the config here. + */ +export function initShopify(config: { storeName: string; storefrontAccessToken: string }) { + if (initialized) return; + + if (!config.storeName || !config.storefrontAccessToken) { + console.warn("[Shopify] Missing storeName or storefrontAccessToken."); + return; + } + + console.log(`[Shopify] Initializing: ${config.storeName}.myshopify.com`); + configureShopify(config); + initialized = true; +} + +/** + * Initialize Shopify from a blocks map (convenience wrapper). + * Looks for the "deco-shopify" block and extracts credentials. + */ +export function initShopifyFromBlocks(blocks: Record) { + const shopifyBlock = blocks["deco-shopify"] as + | { storeName: string; storefrontAccessToken: string } + | undefined; + if (!shopifyBlock) { + console.warn("[Shopify] No deco-shopify block found."); + return; + } + + initShopify({ + storeName: shopifyBlock.storeName, + storefrontAccessToken: shopifyBlock.storefrontAccessToken, + }); +} diff --git a/packages/apps-shopify/src/loaders/ProductDetailsPage.ts b/packages/apps-shopify/src/loaders/ProductDetailsPage.ts new file mode 100644 index 0000000..7ad0bfe --- /dev/null +++ b/packages/apps-shopify/src/loaders/ProductDetailsPage.ts @@ -0,0 +1,31 @@ +import type { ProductDetailsPage } from "@decocms/apps-commerce/types"; +import { getShopifyClient } from "../client"; +import { GetProduct } from "../utils/storefront/queries"; +import { type ProductShopify, toProductPage } from "../utils/transform"; +import type { Metafield } from "../utils/types"; + +export interface Props { + slug: string; + metafields?: Metafield[]; +} + +export default async function productDetailsPageLoader( + props: Props, + url?: URL, +): Promise { + const client = getShopifyClient(); + const { slug, metafields = [] } = props; + + const splitted = slug?.split("-") ?? []; + const maybeSkuId = Number(splitted[splitted.length - 1]); + const handle = splitted.slice(0, maybeSkuId ? -1 : undefined).join("-"); + + const data = await client.query<{ product?: ProductShopify }>(GetProduct, { + handle, + identifiers: metafields, + }); + + if (!data?.product) return null; + + return toProductPage(data.product, url ?? new URL("https://localhost"), maybeSkuId || undefined); +} diff --git a/packages/apps-shopify/src/loaders/ProductList.ts b/packages/apps-shopify/src/loaders/ProductList.ts new file mode 100644 index 0000000..d2dc121 --- /dev/null +++ b/packages/apps-shopify/src/loaders/ProductList.ts @@ -0,0 +1,102 @@ +import type { Product } from "@decocms/apps-commerce/types"; +import { getShopifyClient } from "../client"; +import { ProductsByCollection, SearchProducts } from "../utils/storefront/queries"; +import { type ProductShopify, toProduct } from "../utils/transform"; +import type { Metafield } from "../utils/types"; +import { + type CollectionSortKeys, + type SearchSortKeys, + searchSortShopify, + sortShopify, +} from "../utils/utils"; + +export interface QueryProps { + query: string; + count: number; + sort?: SearchSortKeys; +} + +export interface CollectionProps { + collection: string; + count: number; + sort?: CollectionSortKeys; +} + +export interface FilterProps { + tags?: string[]; + productTypes?: string[]; + productVendors?: string[]; + priceMin?: number; + priceMax?: number; + variantOptions?: { name: string; value: string }[]; +} + +export type Props = { + props: QueryProps | CollectionProps; + filters?: FilterProps; + metafields?: Metafield[]; +}; + +const isQueryList = (p: QueryProps | CollectionProps): p is QueryProps => + "query" in p && typeof p.query === "string" && typeof p.count === "number"; + +export default async function productListLoader( + expandedProps: Props, + url?: URL, +): Promise { + const client = getShopifyClient(); + + const props = expandedProps.props ?? (expandedProps as unknown as Props["props"]); + + const count = props.count ?? 12; + const metafields = expandedProps.metafields || []; + const sort = props.sort ?? ""; + + const filters: Record[] = []; + for (const tag of expandedProps.filters?.tags ?? []) { + filters.push({ tag }); + } + for (const productType of expandedProps.filters?.productTypes ?? []) { + filters.push({ productType }); + } + for (const productVendor of expandedProps.filters?.productVendors ?? []) { + filters.push({ productVendor }); + } + if (expandedProps.filters?.priceMin != null) + filters.push({ price: { min: expandedProps.filters.priceMin } }); + if (expandedProps.filters?.priceMax != null) + filters.push({ price: { max: expandedProps.filters.priceMax } }); + for (const variantOption of expandedProps.filters?.variantOptions ?? []) { + filters.push({ variantOption }); + } + + let shopifyProducts: { nodes: ProductShopify[] } | undefined; + + if (isQueryList(props)) { + const data = await client.query<{ search: { nodes: ProductShopify[] } }>(SearchProducts, { + first: count, + query: props.query, + productFilters: filters, + identifiers: metafields, + ...searchSortShopify[sort], + }); + shopifyProducts = data.search; + } else { + const data = await client.query<{ + collection?: { products: { nodes: ProductShopify[] } }; + }>(ProductsByCollection, { + first: count, + handle: (props as CollectionProps).collection, + filters, + identifiers: metafields, + ...sortShopify[sort], + }); + shopifyProducts = data.collection?.products; + } + + const baseUrl = url ?? new URL("https://localhost"); + + const products = shopifyProducts?.nodes.map((p) => toProduct(p, p.variants.nodes[0], baseUrl)); + + return products ?? []; +} diff --git a/packages/apps-shopify/src/loaders/ProductListingPage.ts b/packages/apps-shopify/src/loaders/ProductListingPage.ts new file mode 100644 index 0000000..bbe9246 --- /dev/null +++ b/packages/apps-shopify/src/loaders/ProductListingPage.ts @@ -0,0 +1,172 @@ +import type { ProductListingPage } from "@decocms/apps-commerce/types"; +import { getShopifyClient } from "../client"; +import { ProductsByCollection, SearchProducts } from "../utils/storefront/queries"; +import { type ProductShopify, toFilter, toProduct } from "../utils/transform"; +import type { Metafield } from "../utils/types"; +import { + getFiltersByUrl, + searchSortOptions, + searchSortShopify, + sortOptions, + sortShopify, +} from "../utils/utils"; + +interface PageInfo { + hasNextPage: boolean; + hasPreviousPage: boolean; + endCursor?: string; + startCursor?: string; +} + +interface FilterNode { + id: string; + label: string; + type: string; + values: Array<{ id: string; label: string; count: number; input: string }>; +} + +interface ProductConnection { + nodes: ProductShopify[]; + pageInfo: PageInfo; + filters?: FilterNode[]; +} + +export interface Props { + query?: string; + collectionName?: string; + count: number; + metafields?: Metafield[]; + pageOffset?: number; + page?: number; + startCursor?: string; + endCursor?: string; + pageHref?: string; +} + +export default async function productListingPageLoader( + props: Props, + url?: URL, +): Promise { + const pageUrl = url ?? new URL(props.pageHref || "https://localhost"); + const client = getShopifyClient(); + + const count = props.count ?? 12; + const query = props.query || pageUrl.searchParams.get("q") || ""; + const currentPageoffset = props.pageOffset ?? 1; + const pageParam = pageUrl.searchParams.get("page") + ? Number(pageUrl.searchParams.get("page")) - currentPageoffset + : 0; + const page = props.page || pageParam; + const endCursor = props.endCursor || pageUrl.searchParams.get("endCursor") || ""; + const startCursor = props.startCursor || pageUrl.searchParams.get("startCursor") || ""; + const metafields = props.metafields || []; + + const isSearch = Boolean(query); + let hasNextPage = false; + let hasPreviousPage = false; + let shopifyProducts: ProductConnection | undefined; + let shopifyFilters: FilterNode[] | undefined; + let records: number | undefined; + let collectionTitle: string | undefined; + let collectionDescription: string | undefined; + + const sort = pageUrl.searchParams.get("sort") ?? ""; + + if (isSearch) { + const data = await client.query<{ + search: ProductConnection & { totalCount?: number; productFilters?: FilterNode[] }; + }>(SearchProducts, { + ...(!endCursor && { first: count }), + ...(endCursor && { last: count }), + ...(startCursor && { after: startCursor }), + ...(endCursor && { before: endCursor }), + query, + productFilters: getFiltersByUrl(pageUrl), + identifiers: metafields, + ...searchSortShopify[sort], + }); + + shopifyProducts = data.search; + shopifyFilters = data.search?.productFilters; + records = data.search?.totalCount; + hasNextPage = Boolean(data.search?.pageInfo.hasNextPage); + hasPreviousPage = Boolean(data.search?.pageInfo.hasPreviousPage); + } else { + const pathname = props.collectionName || pageUrl.pathname.split("/")[1]; + + const data = await client.query<{ + collection?: { + title?: string; + description?: string; + products: ProductConnection; + }; + }>(ProductsByCollection, { + ...(!endCursor && { first: count }), + ...(endCursor && { last: count }), + ...(startCursor && { after: startCursor }), + ...(endCursor && { before: endCursor }), + identifiers: metafields, + handle: pathname, + filters: getFiltersByUrl(pageUrl), + ...sortShopify[sort], + }); + + shopifyProducts = data.collection?.products; + shopifyFilters = data.collection?.products?.filters; + hasNextPage = Boolean(data.collection?.products.pageInfo.hasNextPage); + hasPreviousPage = Boolean(data.collection?.products.pageInfo.hasPreviousPage); + collectionTitle = data.collection?.title; + collectionDescription = data.collection?.description; + } + + const products = shopifyProducts?.nodes?.map((p) => toProduct(p, p.variants.nodes[0], pageUrl)); + + const nextPage = new URLSearchParams(pageUrl.searchParams); + const previousPage = new URLSearchParams(pageUrl.searchParams); + + if (hasNextPage) { + nextPage.set("page", (page + currentPageoffset + 1).toString()); + nextPage.set("startCursor", shopifyProducts?.pageInfo.endCursor ?? ""); + nextPage.delete("endCursor"); + } + + if (hasPreviousPage) { + previousPage.set("page", (page + currentPageoffset - 1).toString()); + previousPage.set("endCursor", shopifyProducts?.pageInfo.startCursor ?? ""); + previousPage.delete("startCursor"); + } + + const filters = shopifyFilters?.map((filter) => toFilter(filter, pageUrl)); + const currentPage = page + currentPageoffset; + + return { + "@type": "ProductListingPage", + breadcrumb: { + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem" as const, + name: isSearch ? query : pageUrl.pathname.split("/")[1], + item: isSearch ? pageUrl.href : pageUrl.pathname, + position: 2, + }, + ], + numberOfItems: 1, + }, + filters: filters ?? [], + products: products ?? [], + pageInfo: { + nextPage: hasNextPage ? `?${nextPage}` : undefined, + previousPage: hasPreviousPage ? `?${previousPage}` : undefined, + currentPage, + records, + recordPerPage: count, + }, + sortOptions: isSearch ? searchSortOptions : sortOptions, + seo: { + title: collectionTitle || "", + description: collectionDescription || "", + canonical: `${pageUrl.origin}${pageUrl.pathname}${page >= 1 ? `?page=${page}` : ""}`, + }, + }; +} diff --git a/packages/apps-shopify/src/loaders/RelatedProducts.ts b/packages/apps-shopify/src/loaders/RelatedProducts.ts new file mode 100644 index 0000000..f2652b4 --- /dev/null +++ b/packages/apps-shopify/src/loaders/RelatedProducts.ts @@ -0,0 +1,42 @@ +import type { Product } from "@decocms/apps-commerce/types"; +import { getShopifyClient } from "../client"; +import { GetProduct, ProductRecommendations } from "../utils/storefront/queries"; +import { type ProductShopify, toProduct } from "../utils/transform"; +import type { Metafield } from "../utils/types"; + +export interface Props { + slug: string; + count?: number; + metafields?: Metafield[]; +} + +export default async function relatedProductsLoader( + props: Props, + url?: URL, +): Promise { + const client = getShopifyClient(); + const { slug, count = 10, metafields = [] } = props; + + const splitted = slug?.split("-") ?? []; + const maybeSkuId = Number(splitted[splitted.length - 1]); + const handle = splitted.slice(0, maybeSkuId ? -1 : undefined).join("-"); + + const productData = await client.query<{ product?: ProductShopify }>(GetProduct, { + handle, + identifiers: metafields, + }); + + if (!productData?.product) return []; + + const data = await client.query<{ + productRecommendations?: ProductShopify[]; + }>(ProductRecommendations, { productId: productData.product.id, identifiers: metafields }); + + if (!data?.productRecommendations) return []; + + const baseUrl = url ?? new URL("https://localhost"); + + return data.productRecommendations + .map((p) => toProduct(p, p.variants.nodes[0], baseUrl)) + .slice(0, count); +} diff --git a/packages/apps-shopify/src/loaders/cart.ts b/packages/apps-shopify/src/loaders/cart.ts new file mode 100644 index 0000000..fa66417 --- /dev/null +++ b/packages/apps-shopify/src/loaders/cart.ts @@ -0,0 +1,74 @@ +import { getShopifyClient } from "../client"; +import { getCartCookie, setCartCookie } from "../utils/cart"; +import { CreateCart, GetCart } from "../utils/storefront/queries"; + +export interface CartLine { + id: string; + quantity: number; + merchandise: { + id: string; + title: string; + image?: { url: string; altText?: string | null } | null; + product: { title: string; handle: string; onlineStoreUrl?: string | null }; + price: { amount: string; currencyCode: string }; + }; + discountAllocations?: Array<{ + code?: string; + discountedAmount?: { amount: string; currencyCode: string }; + }>; + cost?: { + totalAmount: { amount: string; currencyCode: string }; + subtotalAmount: { amount: string; currencyCode: string }; + amountPerQuantity?: { amount: string; currencyCode: string }; + compareAtAmountPerQuantity?: { amount: string; currencyCode: string } | null; + }; +} + +export interface ShopifyCart { + id: string; + checkoutUrl: string; + totalQuantity: number; + lines: { nodes: CartLine[] }; + cost: { + totalTaxAmount?: { amount: string; currencyCode: string }; + subtotalAmount: { amount: string; currencyCode: string }; + totalAmount: { amount: string; currencyCode: string }; + checkoutChargeAmount?: { amount: string; currencyCode: string }; + }; + discountCodes?: Array<{ applicable: boolean; code: string }>; + discountAllocations?: Array<{ + discountedAmount: { amount: string; currencyCode: string }; + }>; +} + +export async function getCart( + requestHeaders: Headers, + responseHeaders?: Headers, +): Promise { + const client = getShopifyClient(); + const maybeCartId = getCartCookie(requestHeaders); + + const cartId = + maybeCartId ?? + (await client + .query<{ payload?: { cart?: { id: string } } }>(CreateCart) + .then((data) => data.payload?.cart?.id)); + + if (!cartId) throw new Error("Missing cart id"); + + const cart = await client + .query<{ cart?: ShopifyCart }>(GetCart, { id: decodeURIComponent(cartId) }) + .then((data) => data.cart ?? null); + + if (responseHeaders) { + setCartCookie(responseHeaders, cartId); + } + + return cart; +} + +export async function createCart(): Promise { + const client = getShopifyClient(); + const data = await client.query<{ payload?: { cart?: { id: string } } }>(CreateCart); + return data?.payload?.cart?.id ?? null; +} diff --git a/packages/apps-shopify/src/loaders/shop.ts b/packages/apps-shopify/src/loaders/shop.ts new file mode 100644 index 0000000..5eeff0f --- /dev/null +++ b/packages/apps-shopify/src/loaders/shop.ts @@ -0,0 +1,35 @@ +import { getShopifyClient } from "../client"; +import { GetShopInfo } from "../utils/storefront/queries"; +import type { Metafield } from "../utils/types"; + +export interface Shop { + name: string; + description?: string; + privacyPolicy?: { title: string; body: string }; + refundPolicy?: { title: string; body: string }; + shippingPolicy?: { title: string; body: string }; + subscriptionPolicy?: { title: string; body: string }; + termsOfService?: { title: string; body: string }; + metafields?: Array<{ + description?: string | null; + key: string; + namespace: string; + type: string; + value: string; + reference?: { image?: { url: string } } | null; + references?: { edges: Array<{ node: { image?: { url: string } } }> } | null; + } | null>; +} + +export interface Props { + metafields?: Metafield[]; +} + +export default async function shopLoader(props?: Props): Promise { + const client = getShopifyClient(); + const metafields = props?.metafields || []; + + const data = await client.query<{ shop: Shop }>(GetShopInfo, { identifiers: metafields }); + + return data.shop; +} diff --git a/packages/apps-shopify/src/loaders/user.ts b/packages/apps-shopify/src/loaders/user.ts new file mode 100644 index 0000000..ab9445f --- /dev/null +++ b/packages/apps-shopify/src/loaders/user.ts @@ -0,0 +1,39 @@ +import { getShopifyClient } from "../client"; +import { FetchCustomerInfo } from "../utils/storefront/queries"; +import { getUserCookie } from "../utils/user"; + +export interface ShopifyUser { + "@id": string; + email: string; + givenName: string; + familyName: string; +} + +export default async function userLoader(requestHeaders: Headers): Promise { + const client = getShopifyClient(); + const customerAccessToken = getUserCookie(requestHeaders); + + if (!customerAccessToken) return null; + + try { + const data = await client.query<{ + customer?: { + id: string; + email?: string | null; + firstName?: string | null; + lastName?: string | null; + }; + }>(FetchCustomerInfo, { customerAccessToken }); + + if (!data.customer) return null; + + return { + "@id": data.customer.id, + email: data.customer.email ?? "", + givenName: data.customer.firstName ?? "", + familyName: data.customer.lastName ?? "", + }; + } catch { + return null; + } +} diff --git a/packages/apps-shopify/src/manifest.gen.ts b/packages/apps-shopify/src/manifest.gen.ts new file mode 100644 index 0000000..8771406 --- /dev/null +++ b/packages/apps-shopify/src/manifest.gen.ts @@ -0,0 +1,39 @@ +// AUTO-GENERATED by scripts/generate-manifests.ts — DO NOT EDIT +// This file is checked into source control and updated via: npm run generate:manifests + +import * as actions_cart_addItems from "./actions/cart/addItems"; +import * as actions_cart_updateCoupons from "./actions/cart/updateCoupons"; +import * as actions_cart_updateItems from "./actions/cart/updateItems"; +import * as actions_user_signIn from "./actions/user/signIn"; +import * as actions_user_signUp from "./actions/user/signUp"; +import * as loaders_cart from "./loaders/cart"; +import * as loaders_ProductDetailsPage from "./loaders/ProductDetailsPage"; +import * as loaders_ProductList from "./loaders/ProductList"; +import * as loaders_ProductListingPage from "./loaders/ProductListingPage"; +import * as loaders_RelatedProducts from "./loaders/RelatedProducts"; +import * as loaders_shop from "./loaders/shop"; +import * as loaders_user from "./loaders/user"; + +const manifest = { + name: "shopify", + loaders: { + "shopify/loaders/ProductDetailsPage": loaders_ProductDetailsPage, + "shopify/loaders/ProductList": loaders_ProductList, + "shopify/loaders/ProductListingPage": loaders_ProductListingPage, + "shopify/loaders/RelatedProducts": loaders_RelatedProducts, + "shopify/loaders/cart": loaders_cart, + "shopify/loaders/shop": loaders_shop, + "shopify/loaders/user": loaders_user, + }, + actions: { + "shopify/actions/cart/addItems": actions_cart_addItems, + "shopify/actions/cart/updateCoupons": actions_cart_updateCoupons, + "shopify/actions/cart/updateItems": actions_cart_updateItems, + "shopify/actions/user/signIn": actions_user_signIn, + "shopify/actions/user/signUp": actions_user_signUp, + }, + sections: {}, +} as const; + +export type Manifest = typeof manifest; +export default manifest; diff --git a/packages/apps-shopify/src/mod.ts b/packages/apps-shopify/src/mod.ts new file mode 100644 index 0000000..cfd3986 --- /dev/null +++ b/packages/apps-shopify/src/mod.ts @@ -0,0 +1,67 @@ +/** + * Shopify app module — standard autoconfig contract. + * + * Exports `configure` following the AppModContract pattern. + * The framework's `autoconfigApps()` calls these generically. + * + * @example + * ```ts + * import * as shopifyApp from "@decocms/apps/shopify/mod"; + * + * const app = await shopifyApp.configure(blocks["deco-shopify"], resolveSecret); + * if (app) { + * // app.manifest, app.state are available + * } + * ``` + */ + +import type { AppDefinition, ResolveSecretFn } from "@decocms/apps-commerce/app-types"; +import { configureShopify, type ShopifyConfig } from "./client"; +import manifest from "./manifest.gen"; + +// ------------------------------------------------------------------------- +// State +// ------------------------------------------------------------------------- + +export interface ShopifyState { + config: ShopifyConfig; +} + +// ------------------------------------------------------------------------- +// Configure +// ------------------------------------------------------------------------- + +/** + * Configure the Shopify app from CMS block data. + * Returns an AppDefinition or null if required fields are missing. + */ +export async function configure( + block: Record, + resolveSecret: ResolveSecretFn, +): Promise | null> { + if (!block?.storeName) return null; + + const storefrontAccessToken = + (await resolveSecret(block.storefrontAccessToken, "SHOPIFY_STOREFRONT_TOKEN")) ?? + (typeof block.storefrontAccessToken === "string" ? block.storefrontAccessToken : null); + + if (!storefrontAccessToken) return null; + + const config: ShopifyConfig = { + storeName: block.storeName as string, + storefrontAccessToken, + publicUrl: block.publicUrl as string | undefined, + }; + + // Bridge: maintain global singleton for backward compat + configureShopify(config); + + return { + name: "shopify", + manifest, + state: { config }, + }; +} + +/** Placeholder preview for CMS editor — evolves when admin supports it. */ +export const preview = undefined; diff --git a/packages/apps-shopify/src/registry.ts b/packages/apps-shopify/src/registry.ts new file mode 100644 index 0000000..56cf82b --- /dev/null +++ b/packages/apps-shopify/src/registry.ts @@ -0,0 +1,9 @@ +import type { AppRegistryEntry } from "@decocms/apps-commerce/registry"; + +export const SHOPIFY_REGISTRY_ENTRY: AppRegistryEntry = { + blockKey: "deco-shopify", + module: () => import("./mod"), + displayName: "Shopify", + category: "commerce", + description: "Shopify Storefront API commerce integration", +}; diff --git a/packages/apps-shopify/src/utils/__tests__/graphqlOperationName.test.ts b/packages/apps-shopify/src/utils/__tests__/graphqlOperationName.test.ts new file mode 100644 index 0000000..5eda77d --- /dev/null +++ b/packages/apps-shopify/src/utils/__tests__/graphqlOperationName.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { extractGraphqlOperationName } from "../graphqlOperationName"; + +describe("extractGraphqlOperationName", () => { + it("returns the explicit name when provided, regardless of body content", () => { + expect(extractGraphqlOperationName("query Whatever { x }", "ForcedName")).toBe("ForcedName"); + expect(extractGraphqlOperationName("", "Override")).toBe("Override"); + }); + + it("extracts a single named query", () => { + expect( + extractGraphqlOperationName("query ProductBySlug($slug: String!) { product { id } }"), + ).toBe("ProductBySlug"); + }); + + it("extracts a single named mutation", () => { + expect( + extractGraphqlOperationName("mutation CartLinesAdd($cartId: ID!) { cartLinesAdd { } }"), + ).toBe("CartLinesAdd"); + }); + + it("extracts a single named subscription", () => { + expect(extractGraphqlOperationName("subscription OrderEvents { orderUpdated { id } }")).toBe( + "OrderEvents", + ); + }); + + it("returns undefined for anonymous operations", () => { + expect(extractGraphqlOperationName("{ product { id } }")).toBeUndefined(); + expect(extractGraphqlOperationName("query { product { id } }")).toBeUndefined(); + }); + + it("returns undefined when document has more than one named operation (caller must disambiguate)", () => { + const multi = ` + query OpA { a } + query OpB { b } + `; + expect(extractGraphqlOperationName(multi)).toBeUndefined(); + }); + + it("ignores the words query/mutation/subscription inside string literals", () => { + const docWithStringy = `query RealName { thing(arg: "this query mutation subscription is a string") }`; + expect(extractGraphqlOperationName(docWithStringy)).toBe("RealName"); + }); + + it("ignores the words inside block strings (triple-quoted)", () => { + const doc = ` + """ + This block string mentions query Inner and mutation Inner2. + """ + query OuterReal { x } + `; + expect(extractGraphqlOperationName(doc)).toBe("OuterReal"); + }); + + it("ignores the words inside # comments", () => { + const doc = ` + # query CommentedOut { x } + query Active { y } + `; + expect(extractGraphqlOperationName(doc)).toBe("Active"); + }); + + it("returns undefined on an empty / nullish body", () => { + expect(extractGraphqlOperationName("")).toBeUndefined(); + expect(extractGraphqlOperationName(" \n\t ")).toBeUndefined(); + }); + + it("handles a real-world Shopify storefront query shape", () => { + const doc = ` + query ProductDetails($handle: String!, $country: CountryCode!) @inContext(country: $country) { + product(handle: $handle) { + id + title + } + } + `; + expect(extractGraphqlOperationName(doc)).toBe("ProductDetails"); + }); +}); diff --git a/packages/apps-shopify/src/utils/__tests__/instrumentedFetch.test.ts b/packages/apps-shopify/src/utils/__tests__/instrumentedFetch.test.ts new file mode 100644 index 0000000..31e0926 --- /dev/null +++ b/packages/apps-shopify/src/utils/__tests__/instrumentedFetch.test.ts @@ -0,0 +1,84 @@ +/** + * Smoke tests for the pre-wired Shopify fetch factory. Same wiring + * assertions as `vtex/utils/__tests__/instrumentedFetch.test.ts`. + */ + +import { configureMeter, type MeterAdapter } from "@decocms/blocks/sdk/observability"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createShopifyFetch } from "../instrumentedFetch"; + +type Labels = Record; + +function captureHistogram(): { + calls: { name: string; value: number; attrs: Labels }[]; + meter: MeterAdapter; +} { + const calls: { name: string; value: number; attrs: Labels }[] = []; + const meter: MeterAdapter = { + counterInc: vi.fn(), + gaugeSet: vi.fn(), + histogramRecord: (name, value, attrs) => { + calls.push({ name, value, attrs: attrs ?? {} }); + }, + }; + return { calls, meter }; +} + +describe("createShopifyFetch", () => { + afterEach(() => { + vi.restoreAllMocks(); + configureMeter({ + counterInc: () => {}, + gaugeSet: () => {}, + histogramRecord: () => {}, + }); + }); + + it("emits http.client.request.duration with provider=shopify on success", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => new Response("{}", { status: 200 })); + const fetchFn = createShopifyFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", { method: "POST" }); + + expect(calls).toHaveLength(1); + expect(calls[0].name).toBe("http.client.request.duration"); + expect(calls[0].attrs).toMatchObject({ + provider: "shopify", + operation: "storefront.graphql", + status_class: "2xx", + }); + }); + + it("honors init.operation (used by the GraphQL client to stamp )", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => new Response("{}", { status: 200 })); + const fetchFn = createShopifyFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", { + method: "POST", + operation: "ProductBySlug", + }); + + expect(calls[0].attrs.operation).toBe("ProductBySlug"); + }); + + it("skips histogram emission when disableHistogram is true", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => new Response("{}", { status: 200 })); + const fetchFn = createShopifyFetch({ + baseFetch: baseFetch as typeof fetch, + disableHistogram: true, + }); + + await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", { method: "POST" }); + + expect(calls).toHaveLength(0); + }); +}); diff --git a/packages/apps-shopify/src/utils/__tests__/operationRouter.test.ts b/packages/apps-shopify/src/utils/__tests__/operationRouter.test.ts new file mode 100644 index 0000000..df2e2d8 --- /dev/null +++ b/packages/apps-shopify/src/utils/__tests__/operationRouter.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { shopifyOperationRouter } from "../operationRouter"; + +describe("shopifyOperationRouter", () => { + const store = "https://acme.myshopify.com"; + + it("recognizes the storefront GraphQL endpoint", () => { + expect(shopifyOperationRouter(`${store}/api/2025-04/graphql.json`, "POST")).toBe( + "storefront.graphql", + ); + }); + + it("recognizes the admin GraphQL endpoint", () => { + expect(shopifyOperationRouter(`${store}/admin/api/2025-04/graphql.json`, "POST")).toBe( + "admin.graphql", + ); + }); + + it("maps admin REST product / order / customer / inventory endpoints", () => { + expect(shopifyOperationRouter(`${store}/admin/api/2025-04/products.json`, "GET")).toBe( + "admin.products", + ); + expect(shopifyOperationRouter(`${store}/admin/api/2025-04/orders/123.json`, "GET")).toBe( + "admin.orders", + ); + expect(shopifyOperationRouter(`${store}/admin/api/2025-04/customers.json`, "GET")).toBe( + "admin.customers", + ); + expect(shopifyOperationRouter(`${store}/admin/api/2025-04/inventory_levels.json`, "GET")).toBe( + "admin.inventory", + ); + }); + + it("maps storefront checkout + cart endpoints", () => { + expect(shopifyOperationRouter(`${store}/api/2025-04/checkouts/abc.json`, "POST")).toBe( + "storefront.checkout", + ); + expect(shopifyOperationRouter(`${store}/cart.js`, "GET")).toBe("storefront.cart"); + expect(shopifyOperationRouter(`${store}/cart/add.js`, "POST")).toBe("storefront.cart"); + }); + + it("returns undefined for unrecognized paths", () => { + expect(shopifyOperationRouter(`${store}/random/path`, "GET")).toBeUndefined(); + }); + + it("does not throw on unparseable URLs", () => { + expect(shopifyOperationRouter("not-a-url", "GET")).toBeUndefined(); + expect(shopifyOperationRouter("/api/2025-04/graphql.json", "POST")).toBe("storefront.graphql"); + }); + + it("is case-insensitive on method (no behavioral impact today; future-proofing)", () => { + expect(shopifyOperationRouter(`${store}/api/2025-04/graphql.json`, "post")).toBe( + "storefront.graphql", + ); + }); +}); diff --git a/packages/apps-shopify/src/utils/__tests__/transform.test.ts b/packages/apps-shopify/src/utils/__tests__/transform.test.ts new file mode 100644 index 0000000..336d2be --- /dev/null +++ b/packages/apps-shopify/src/utils/__tests__/transform.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "vitest"; +import type { ProductShopify, SkuShopify } from "../transform"; +import { + toBreadcrumbItem, + toBreadcrumbList, + toFilter, + toProduct, + toProductPage, +} from "../transform"; + +const makeSku = (overrides?: Partial): SkuShopify => ({ + id: "gid://shopify/ProductVariant/12345", + title: "Default / Small", + availableForSale: true, + quantityAvailable: 10, + barcode: "123456789", + sku: "SKU-001", + image: { url: "https://cdn.shopify.com/img.jpg", altText: "Product image" }, + price: { amount: "99.90", currencyCode: "BRL" }, + compareAtPrice: { amount: "129.90", currencyCode: "BRL" }, + selectedOptions: [{ name: "Size", value: "Small" }], + ...overrides, +}); + +const makeProduct = (overrides?: Partial): ProductShopify => ({ + id: "gid://shopify/Product/1", + handle: "test-product", + title: "Test Product", + description: "A test product", + descriptionHtml: "

A test product

", + createdAt: "2024-01-01", + tags: ["sale", "new"], + vendor: "TestBrand", + productType: "Clothing", + seo: { title: "Test Product SEO", description: "SEO description" }, + images: { nodes: [{ url: "https://cdn.shopify.com/img.jpg", altText: "Product" }] }, + media: { nodes: [] }, + variants: { nodes: [makeSku()] }, + collections: { + nodes: [{ handle: "clothing", title: "Clothing", id: "col1" }], + }, + ...overrides, +}); + +describe("toBreadcrumbItem", () => { + it("creates breadcrumb item", () => { + const result = toBreadcrumbItem({ name: "Home", position: 1, item: "/" }); + expect(result).toEqual({ + "@type": "ListItem", + name: "Home", + position: 1, + item: "/", + }); + }); + + it("decodes URI-encoded names", () => { + const result = toBreadcrumbItem({ name: "Cal%C3%A7ados", position: 1, item: "/calcados" }); + expect(result.name).toBe("Calçados"); + }); +}); + +describe("toBreadcrumbList", () => { + it("includes collection in breadcrumb when present", () => { + const product = makeProduct(); + const sku = makeSku(); + const result = toBreadcrumbList(product, sku); + expect(result.itemListElement).toHaveLength(2); + expect(result.itemListElement[0].name).toBe("Clothing"); + expect(result.itemListElement[1].name).toBe("Test Product"); + expect(result.numberOfItems).toBe(2); + }); + + it("omits collection when not present", () => { + const product = makeProduct({ collections: undefined }); + const sku = makeSku(); + const result = toBreadcrumbList(product, sku); + expect(result.itemListElement).toHaveLength(1); + expect(result.itemListElement[0].name).toBe("Test Product"); + }); +}); + +describe("toProduct", () => { + it("transforms Shopify product to schema.org Product", () => { + const product = makeProduct(); + const sku = makeSku(); + const url = new URL("https://example.com/products/test-product-12345"); + const result = toProduct(product, sku, url); + + expect(result["@type"]).toBe("Product"); + expect(result.productID).toBe("gid://shopify/ProductVariant/12345"); + expect(result.name).toBe("Default / Small"); + expect(result.description).toBe("A test product"); + expect(result.brand).toEqual({ "@type": "Brand", name: "TestBrand" }); + expect(result.offers?.priceCurrency).toBe("BRL"); + expect(result.offers?.lowPrice).toBe(99.9); + expect(result.offers?.highPrice).toBe(129.9); + }); + + it("sets availability based on availableForSale", () => { + const product = makeProduct(); + const sku = makeSku({ availableForSale: false }); + const url = new URL("https://example.com"); + const result = toProduct(product, sku, url); + expect(result.offers?.offers[0]?.availability).toBe("https://schema.org/OutOfStock"); + }); + + it("uses sale price as high price when no compare at price", () => { + const product = makeProduct(); + const sku = makeSku({ compareAtPrice: null }); + const url = new URL("https://example.com"); + const result = toProduct(product, sku, url); + expect(result.offers?.highPrice).toBe(99.9); + }); + + it("includes variants at level 0", () => { + const product = makeProduct(); + const sku = makeSku(); + const url = new URL("https://example.com"); + const result = toProduct(product, sku, url, 0); + expect(result.isVariantOf?.hasVariant).toHaveLength(1); + }); + + it("does not include variants at level 1", () => { + const product = makeProduct(); + const sku = makeSku(); + const url = new URL("https://example.com"); + const result = toProduct(product, sku, url, 1); + expect(result.isVariantOf?.hasVariant).toEqual([]); + }); +}); + +describe("toProductPage", () => { + it("creates a full ProductDetailsPage", () => { + const product = makeProduct(); + const url = new URL("https://example.com/products/test-product-12345"); + const result = toProductPage(product, url); + + expect(result["@type"]).toBe("ProductDetailsPage"); + expect(result.breadcrumbList["@type"]).toBe("BreadcrumbList"); + expect(result.product["@type"]).toBe("Product"); + expect(result.seo?.title).toBe("Test Product SEO"); + expect(result.seo?.description).toBe("SEO description"); + }); + + it("falls back to product title/description for SEO", () => { + const product = makeProduct({ seo: { title: null, description: null } }); + const url = new URL("https://example.com"); + const result = toProductPage(product, url); + expect(result.seo?.title).toBe("Test Product"); + expect(result.seo?.description).toBe("A test product"); + }); + + it("selects specified variant by ID", () => { + const sku1 = makeSku({ id: "gid://shopify/ProductVariant/111", title: "Red" }); + const sku2 = makeSku({ id: "gid://shopify/ProductVariant/222", title: "Blue" }); + const product = makeProduct({ variants: { nodes: [sku1, sku2] } }); + const url = new URL("https://example.com"); + const result = toProductPage(product, url, 222); + expect(result.product.name).toBe("Blue"); + }); +}); + +describe("toFilter", () => { + it("creates toggle filter", () => { + const filter = { + id: "filter.v.color", + label: "Color", + type: "LIST", + values: [ + { id: "1", label: "Red", count: 5, input: '{"productVendor":"Red"}' }, + { id: "2", label: "Blue", count: 3, input: '{"productVendor":"Blue"}' }, + ], + }; + const url = new URL("https://example.com/collections/all"); + const result = toFilter(filter, url); + + expect(result["@type"]).toBe("FilterToggle"); + expect(result.label).toBe("Color"); + if (result["@type"] === "FilterToggle") { + expect(result.values).toHaveLength(2); + expect(result.values[0].label).toBe("Red"); + expect(result.values[0].selected).toBe(false); + } + }); + + it("creates range filter for PRICE_RANGE type", () => { + const filter = { + id: "filter.v.price", + label: "Price", + type: "PRICE_RANGE", + values: [{ id: "1", label: "0-100", count: 10, input: '{"min":0,"max":100}' }], + }; + const url = new URL("https://example.com"); + const result = toFilter(filter, url); + + expect(result["@type"]).toBe("FilterRange"); + if (result["@type"] === "FilterRange") { + expect(result.values.min).toBe(0); + expect(result.values.max).toBe(100); + } + }); + + it("marks filter as selected when URL contains it", () => { + const filter = { + id: "filter.v.vendor", + label: "Brand", + type: "LIST", + values: [{ id: "1", label: "Nike", count: 5, input: '{"productVendor":"Nike"}' }], + }; + const url = new URL("https://example.com?filter.v.vendor=Nike"); + const result = toFilter(filter, url); + if (result["@type"] === "FilterToggle") { + expect(result.values[0].selected).toBe(true); + } + }); +}); diff --git a/packages/apps-shopify/src/utils/admin/admin.ts b/packages/apps-shopify/src/utils/admin/admin.ts new file mode 100644 index 0000000..6397133 --- /dev/null +++ b/packages/apps-shopify/src/utils/admin/admin.ts @@ -0,0 +1,57 @@ +// deno-fmt-ignore-file +// deno-lint-ignore-file no-explicit-any ban-types ban-unused-ignore +// biome-ignore-all lint/suspicious/noExplicitAny: generated GraphQL scalar types + +export type Maybe = T | null; + +export type Scalars = { + ID: { input: string; output: string }; + String: { input: string; output: string }; + Boolean: { input: boolean; output: boolean }; + Int: { input: number; output: number }; + Float: { input: number; output: number }; + ARN: { input: any; output: any }; + Date: { input: any; output: any }; + DateTime: { input: any; output: any }; + Decimal: { input: any; output: any }; + FormattedString: { input: any; output: any }; + HTML: { input: any; output: any }; + JSON: { input: any; output: any }; + Money: { input: any; output: any }; + StorefrontID: { input: any; output: any }; + URL: { input: any; output: any }; + UnsignedInt64: { input: any; output: any }; + UtcOffset: { input: any; output: any }; +}; + +/** Return type for `draftOrderCalculate` mutation. */ +export type DraftOrderCalculatePayload = { + /** The calculated properties for a draft order. */ + calculatedDraftOrder?: Maybe; +}; + +export interface CalculatedDraftOrder { + /** The available shipping rates for the draft order. Requires a customer with a valid shipping address and at least one line item. */ + availableShippingRates: Array; +} + +export type ShippingRate = { + /** Human-readable unique identifier for this shipping rate. */ + handle: Scalars["String"]["output"]; + /** The cost associated with the shipping rate. */ + price: MoneyV2; + /** The name of the shipping rate. */ + title: Scalars["String"]["output"]; +}; + +export type MoneyV2 = { + /** Decimal money amount. */ + amount: Scalars["Decimal"]["output"]; + /** Currency of the money. */ + currencyCode: CurrencyCode; +}; + +export enum CurrencyCode { + CAD = "CAD", + Usd = "USD", +} diff --git a/packages/apps-shopify/src/utils/admin/queries.ts b/packages/apps-shopify/src/utils/admin/queries.ts new file mode 100644 index 0000000..88f7f77 --- /dev/null +++ b/packages/apps-shopify/src/utils/admin/queries.ts @@ -0,0 +1,29 @@ +import { gql } from "../graphql"; + +// Fixme: This is to avoid typescript generation errors +// because it does not accept generating an empty schema +// TODO: Remove this once you add any other query +export const Noop = { + query: gql`query Noop { app(id: "") { description } }`, +}; + +export const draftOrderCalculate = { + query: gql` mutation draftOrderCalculate($input: DraftOrderInput!) { + calculatedDraftOrder: draftOrderCalculate(input: $input) { + calculatedDraftOrder { + availableShippingRates { + title + handle + price { + amount + } + } + } + userErrors { + field + message + } + } + } + `, +}; diff --git a/packages/apps-shopify/src/utils/cart.ts b/packages/apps-shopify/src/utils/cart.ts new file mode 100644 index 0000000..fceb8e2 --- /dev/null +++ b/packages/apps-shopify/src/utils/cart.ts @@ -0,0 +1,32 @@ +import { getCookies, setCookie } from "./cookies"; + +const CART_COOKIE = "cart"; +const SHOPIFY_PREFIX = "gid://shopify/Cart/"; + +const ONE_WEEK_MS = 7 * 24 * 3600 * 1_000; + +export const getCartCookie = (headers: Headers): string | null => { + const cookies = getCookies(headers); + + if (!cookies[CART_COOKIE]) { + return null; + } + + try { + return decodeURIComponent(`${SHOPIFY_PREFIX}${cookies[CART_COOKIE]}`); + } catch { + return null; + } +}; + +export const setCartCookie = (headers: Headers, cartId: string) => { + setCookie(headers, { + name: CART_COOKIE, + value: cartId.replace(SHOPIFY_PREFIX, ""), + path: "/", + expires: new Date(Date.now() + ONE_WEEK_MS), + httpOnly: true, + secure: true, + sameSite: "Lax", + }); +}; diff --git a/packages/apps-shopify/src/utils/cookies.ts b/packages/apps-shopify/src/utils/cookies.ts new file mode 100644 index 0000000..025f11c --- /dev/null +++ b/packages/apps-shopify/src/utils/cookies.ts @@ -0,0 +1,82 @@ +/** + * Node.js-compatible cookie helpers. + * Replaces Deno's std/http/cookie.ts. + */ + +export function getCookies(headers: Headers): Record { + const cookieHeader = headers.get("cookie") || ""; + const cookies: Record = {}; + for (const pair of cookieHeader.split(";")) { + const [key, ...rest] = pair.trim().split("="); + if (key) { + cookies[key.trim()] = decodeURIComponent(rest.join("=").trim()); + } + } + return cookies; +} + +export function setCookie( + headers: Headers, + options: { + name: string; + value: string; + path?: string; + expires?: Date; + maxAge?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: "Strict" | "Lax" | "None"; + }, +) { + const parts = [`${options.name}=${encodeURIComponent(options.value)}`]; + if (options.path) parts.push(`Path=${options.path}`); + if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`); + if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`); + if (options.httpOnly) parts.push("HttpOnly"); + if (options.secure) parts.push("Secure"); + if (options.sameSite) parts.push(`SameSite=${options.sameSite}`); + + headers.append("Set-Cookie", parts.join("; ")); +} + +export interface Cookie { + name: string; + value: string; + path?: string; + expires?: Date; + maxAge?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: "Strict" | "Lax" | "None"; +} + +export function getSetCookies(headers: Headers): Cookie[] { + const cookies: Cookie[] = []; + const setCookieHeaders = headers.getSetCookie?.() ?? []; + for (const header of setCookieHeaders) { + const parts = header.split(";").map((p) => p.trim()); + const [nameValue, ...attrs] = parts; + const [name, ...rest] = nameValue.split("="); + const value = rest.join("="); + const cookie: Cookie = { name: name.trim(), value }; + for (const attr of attrs) { + const [k, v] = attr.split("="); + const key = k.trim().toLowerCase(); + if (key === "path") cookie.path = v?.trim(); + if (key === "httponly") cookie.httpOnly = true; + if (key === "secure") cookie.secure = true; + if (key === "samesite") cookie.sameSite = v?.trim() as any; + if (key === "max-age") cookie.maxAge = Number(v?.trim()); + } + cookies.push(cookie); + } + return cookies; +} + +export function debounce void>(fn: T, delay: number): T { + let timer: ReturnType; + return ((...args: any[]) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }) as T; +} diff --git a/packages/apps-shopify/src/utils/enums.ts b/packages/apps-shopify/src/utils/enums.ts new file mode 100644 index 0000000..3cb1017 --- /dev/null +++ b/packages/apps-shopify/src/utils/enums.ts @@ -0,0 +1,438 @@ +export enum CurrencyCode { + AED = "AED", + AFN = "AFN", + ALL = "ALL", + AMD = "AMD", + ANG = "ANG", + AOA = "AOA", + ARS = "ARS", + AUD = "AUD", + AWG = "AWG", + AZN = "AZN", + BAM = "BAM", + BBD = "BBD", + BDT = "BDT", + BGN = "BGN", + BHD = "BHD", + BIF = "BIF", + BMD = "BMD", + BND = "BND", + BOB = "BOB", + BRL = "BRL", + BSD = "BSD", + BTN = "BTN", + BWP = "BWP", + BYN = "BYN", + BZD = "BZD", + CAD = "CAD", + CDF = "CDF", + CHF = "CHF", + CLP = "CLP", + CNY = "CNY", + COP = "COP", + CRC = "CRC", + CVE = "CVE", + CZK = "CZK", + DJF = "DJF", + DKK = "DKK", + DOP = "DOP", + DZD = "DZD", + EGP = "EGP", + ERN = "ERN", + ETB = "ETB", + EUR = "EUR", + FJD = "FJD", + FKP = "FKP", + GBP = "GBP", + GEL = "GEL", + GHS = "GHS", + GIP = "GIP", + GMD = "GMD", + GNF = "GNF", + GTQ = "GTQ", + GYD = "GYD", + HKD = "HKD", + HNL = "HNL", + HRK = "HRK", + HTG = "HTG", + HUF = "HUF", + IDR = "IDR", + ILS = "ILS", + INR = "INR", + IQD = "IQD", + IRR = "IRR", + ISK = "ISK", + JEP = "JEP", + JMD = "JMD", + JOD = "JOD", + JPY = "JPY", + KES = "KES", + KGS = "KGS", + KHR = "KHR", + KID = "KID", + KMF = "KMF", + KRW = "KRW", + KWD = "KWD", + KYD = "KYD", + KZT = "KZT", + LAK = "LAK", + LBP = "LBP", + LKR = "LKR", + LRD = "LRD", + LSL = "LSL", + LTL = "LTL", + LVL = "LVL", + LYD = "LYD", + MAD = "MAD", + MDL = "MDL", + MGA = "MGA", + MKD = "MKD", + MMK = "MMK", + MNT = "MNT", + MOP = "MOP", + MRU = "MRU", + MUR = "MUR", + MVR = "MVR", + MWK = "MWK", + MXN = "MXN", + MYR = "MYR", + MZN = "MZN", + NAD = "NAD", + NGN = "NGN", + NIO = "NIO", + NOK = "NOK", + NPR = "NPR", + NZD = "NZD", + OMR = "OMR", + PAB = "PAB", + PEN = "PEN", + PGK = "PGK", + PHP = "PHP", + PKR = "PKR", + PLN = "PLN", + PYG = "PYG", + QAR = "QAR", + RON = "RON", + RSD = "RSD", + RUB = "RUB", + RWF = "RWF", + SAR = "SAR", + SBD = "SBD", + SCR = "SCR", + SDG = "SDG", + SEK = "SEK", + SGD = "SGD", + SHP = "SHP", + SLL = "SLL", + SOS = "SOS", + SRD = "SRD", + SSP = "SSP", + STN = "STN", + SYP = "SYP", + SZL = "SZL", + THB = "THB", + TJS = "TJS", + TMT = "TMT", + TND = "TND", + TOP = "TOP", + TRY = "TRY", + TTD = "TTD", + TWD = "TWD", + TZS = "TZS", + UAH = "UAH", + UGX = "UGX", + USD = "USD", + UYU = "UYU", + UZS = "UZS", + VED = "VED", + VES = "VES", + VND = "VND", + VUV = "VUV", + WST = "WST", + XAF = "XAF", + XCD = "XCD", + XOF = "XOF", + XPF = "XPF", + XXX = "XXX", + YER = "YER", + ZAR = "ZAR", + ZMW = "ZMW", +} + +export enum CountryCode { + AC = "AC", + AD = "AD", + AE = "AE", + AF = "AF", + AG = "AG", + AI = "AI", + AL = "AL", + AM = "AM", + AN = "AN", + AO = "AO", + AR = "AR", + AT = "AT", + AU = "AU", + AW = "AW", + AX = "AX", + AZ = "AZ", + BA = "BA", + BB = "BB", + BD = "BD", + BE = "BE", + BF = "BF", + BG = "BG", + BH = "BH", + BI = "BI", + BJ = "BJ", + BL = "BL", + BM = "BM", + BN = "BN", + BO = "BO", + BQ = "BQ", + BR = "BR", + BS = "BS", + BT = "BT", + BV = "BV", + BW = "BW", + BY = "BY", + BZ = "BZ", + CA = "CA", + CC = "CC", + CD = "CD", + CF = "CF", + CG = "CG", + CH = "CH", + CI = "CI", + CK = "CK", + CL = "CL", + CM = "CM", + CN = "CN", + CO = "CO", + CR = "CR", + CU = "CU", + CV = "CV", + CW = "CW", + CX = "CX", + CY = "CY", + CZ = "CZ", + DE = "DE", + DJ = "DJ", + DK = "DK", + DM = "DM", + DO = "DO", + DZ = "DZ", + EC = "EC", + EE = "EE", + EG = "EG", + EH = "EH", + ER = "ER", + ES = "ES", + ET = "ET", + FI = "FI", + FJ = "FJ", + FK = "FK", + FO = "FO", + FR = "FR", + GA = "GA", + GB = "GB", + GD = "GD", + GE = "GE", + GF = "GF", + GG = "GG", + GH = "GH", + GI = "GI", + GL = "GL", + GM = "GM", + GN = "GN", + GP = "GP", + GQ = "GQ", + GR = "GR", + GS = "GS", + GT = "GT", + GW = "GW", + GY = "GY", + HK = "HK", + HM = "HM", + HN = "HN", + HR = "HR", + HT = "HT", + HU = "HU", + ID = "ID", + IE = "IE", + IL = "IL", + IM = "IM", + IN = "IN", + IO = "IO", + IQ = "IQ", + IR = "IR", + IS = "IS", + IT = "IT", + JE = "JE", + JM = "JM", + JO = "JO", + JP = "JP", + KE = "KE", + KG = "KG", + KH = "KH", + KI = "KI", + KM = "KM", + KN = "KN", + KP = "KP", + KR = "KR", + KW = "KW", + KY = "KY", + KZ = "KZ", + LA = "LA", + LB = "LB", + LC = "LC", + LI = "LI", + LK = "LK", + LR = "LR", + LS = "LS", + LT = "LT", + LU = "LU", + LV = "LV", + LY = "LY", + MA = "MA", + MC = "MC", + MD = "MD", + ME = "ME", + MF = "MF", + MG = "MG", + MK = "MK", + ML = "ML", + MM = "MM", + MN = "MN", + MO = "MO", + MQ = "MQ", + MR = "MR", + MS = "MS", + MT = "MT", + MU = "MU", + MV = "MV", + MW = "MW", + MX = "MX", + MY = "MY", + MZ = "MZ", + NA = "NA", + NC = "NC", + NE = "NE", + NF = "NF", + NG = "NG", + NI = "NI", + NL = "NL", + NO = "NO", + NP = "NP", + NR = "NR", + NU = "NU", + NZ = "NZ", + OM = "OM", + PA = "PA", + PE = "PE", + PF = "PF", + PG = "PG", + PH = "PH", + PK = "PK", + PL = "PL", + PM = "PM", + PN = "PN", + PS = "PS", + PT = "PT", + PY = "PY", + QA = "QA", + RE = "RE", + RO = "RO", + RS = "RS", + RU = "RU", + RW = "RW", + SA = "SA", + SB = "SB", + SC = "SC", + SD = "SD", + SE = "SE", + SG = "SG", + SH = "SH", + SI = "SI", + SJ = "SJ", + SK = "SK", + SL = "SL", + SM = "SM", + SN = "SN", + SO = "SO", + SR = "SR", + SS = "SS", + ST = "ST", + SV = "SV", + SX = "SX", + SY = "SY", + SZ = "SZ", + TA = "TA", + TC = "TC", + TD = "TD", + TF = "TF", + TG = "TG", + TH = "TH", + TJ = "TJ", + TK = "TK", + TL = "TL", + TM = "TM", + TN = "TN", + TO = "TO", + TR = "TR", + TT = "TT", + TV = "TV", + TW = "TW", + TZ = "TZ", + UA = "UA", + UG = "UG", + UM = "UM", + US = "US", + UY = "UY", + UZ = "UZ", + VA = "VA", + VC = "VC", + VE = "VE", + VG = "VG", + VN = "VN", + VU = "VU", + WF = "WF", + WS = "WS", + XK = "XK", + YE = "YE", + YT = "YT", + ZA = "ZA", + ZM = "ZM", + ZW = "ZW", + ZZ = "ZZ", +} + +export enum OrderCancelReason { + CUSTOMER = "CUSTOMER", + DECLINED = "DECLINED", + FRAUD = "FRAUD", + INVENTORY = "INVENTORY", + OTHER = "OTHER", +} + +export enum OrderFinancialStatus { + AUTHORIZED = "AUTHORIZED", + PAID = "PAID", + PARTIALLY_PAID = "PARTIALLY_PAID", + PARTIALLY_REFUNDED = "PARTIALLY_REFUNDED", + PENDING = "PENDING", + REFUNDED = "REFUNDED", + VOIDED = "VOIDED", +} + +export enum OrderFulfillmentStatus { + FULFILLED = "FULFILLED", + IN_PROGRESS = "IN_PROGRESS", + ON_HOLD = "ON_HOLD", + OPEN = "OPEN", + PARTIALLY_FULFILLED = "PARTIALLY_FULFILLED", + PENDING_FULFILLMENT = "PENDING_FULFILLMENT", + RESTOCKED = "RESTOCKED", + SCHEDULED = "SCHEDULED", + UNFULFILLED = "UNFULFILLED", +} diff --git a/packages/apps-shopify/src/utils/graphql.ts b/packages/apps-shopify/src/utils/graphql.ts new file mode 100644 index 0000000..d2be436 --- /dev/null +++ b/packages/apps-shopify/src/utils/graphql.ts @@ -0,0 +1,69 @@ +import type { InstrumentedFetchInit } from "@decocms/blocks/sdk/instrumentedFetch"; +import { extractGraphqlOperationName } from "./graphqlOperationName"; + +export function gql(strings: TemplateStringsArray, ...values: unknown[]): string { + return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), ""); +} + +export interface QueryDefinition { + fragments?: string[]; + query: string; +} + +export function buildQuery(def: QueryDefinition): string { + const fragments = def.fragments?.join("\n") ?? ""; + return fragments ? `${fragments}\n${def.query}` : def.query; +} + +export interface GraphQLClient { + query(query: string | QueryDefinition, variables?: Record): Promise; +} + +export function createGraphqlClient( + endpoint: string, + headers: Record, + fetchFn?: typeof fetch, +): GraphQLClient { + const _fetch = fetchFn ?? globalThis.fetch; + return { + async query( + queryOrDef: string | QueryDefinition, + variables?: Record, + ): Promise { + const query = typeof queryOrDef === "string" ? queryOrDef : buildQuery(queryOrDef); + + // Stamp the GraphQL operation as init.operation so the framework's + // span name becomes `shopify.` instead of the + // generic `shopify.storefront.graphql` from the URL router. The + // extra field is silently dropped by plain `fetch` and read by + // any `InstrumentedFetch` configured via `setShopifyFetch`. + const operation = extractGraphqlOperationName(query); + const init: InstrumentedFetchInit = { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify({ query, variables }), + ...(operation ? { operation } : {}), + }; + const response = await _fetch(endpoint, init); + + if (!response.ok) { + throw new Error(`Shopify GraphQL error: ${response.status} ${response.statusText}`); + } + + const json = (await response.json()) as { data?: T; errors?: Array<{ message: string }> }; + + if (json.errors?.length) { + throw new Error(`Shopify GraphQL errors: ${json.errors.map((e) => e.message).join(", ")}`); + } + + if (json.data === undefined) { + throw new Error("Shopify GraphQL response missing data"); + } + + return json.data; + }, + }; +} diff --git a/packages/apps-shopify/src/utils/graphqlOperationName.ts b/packages/apps-shopify/src/utils/graphqlOperationName.ts new file mode 100644 index 0000000..40f6324 --- /dev/null +++ b/packages/apps-shopify/src/utils/graphqlOperationName.ts @@ -0,0 +1,67 @@ +/** + * Extract a semantic operation name from a GraphQL document. + * + * Used at the Shopify GraphQL client layer to stamp `init.operation` + * on the outbound fetch. The framework then suffixes the integration + * name (`shopify.`) onto the span and uses the same string + * as the `fetch.operation` attribute + histogram label. + * + * Resolution order: + * + * 1. An explicit `operationName` argument (e.g. when the client + * received one alongside a multi-operation document) wins. + * 2. If the document has exactly one named operation, that name + * is used. + * 3. If the document has zero or many anonymous operations, we + * return `undefined` so the caller can fall back (typically to + * the URL-derived `storefront.graphql` / `admin.graphql`). + * + * The parser is deliberately a small regex pass, not a full GraphQL + * tokenizer: + * + * - GraphQL operation definitions live at the top level of the + * document, never nested inside other operations, fragments, or + * selection sets, so positional context isn't required to find + * them — only to not match the literal words `query` / + * `mutation` / `subscription` inside string values. + * - We strip block strings (`""" … """`), string literals + * (`"…"`), and `# …` comments before matching, which is enough + * to make false-positive matches inside comments / docs vanish. + * + * If a Shopify operation is ever sufficiently mis-named to break + * this (unlikely, since the storefront SDK names them deliberately), + * the caller can always set `init.operation` explicitly. + */ + +const OPERATION_RE = /\b(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/g; + +const stripCommentsAndStrings = (doc: string): string => + doc + .replace(/"""[\s\S]*?"""/g, '""') + .replace(/"(?:\\.|[^"\\])*"/g, '""') + .replace(/#[^\n]*/g, ""); + +export function extractGraphqlOperationName( + document: string, + explicit?: string, +): string | undefined { + if (explicit) return explicit; + if (!document) return undefined; + + const stripped = stripCommentsAndStrings(document); + const names: string[] = []; + + OPERATION_RE.lastIndex = 0; + for ( + let match = OPERATION_RE.exec(stripped); + match !== null; + match = OPERATION_RE.exec(stripped) + ) { + const [, name] = match; + if (name) names.push(name); + if (names.length > 1) break; + } + + if (names.length === 1) return names[0]; + return undefined; +} diff --git a/packages/apps-shopify/src/utils/instrumentedFetch.ts b/packages/apps-shopify/src/utils/instrumentedFetch.ts new file mode 100644 index 0000000..65715a0 --- /dev/null +++ b/packages/apps-shopify/src/utils/instrumentedFetch.ts @@ -0,0 +1,55 @@ +/** + * Pre-wired instrumented fetch factory for Shopify. + * + * Mirrors `vtex/utils/instrumentedFetch.ts`. Bundles: + * + * 1. `createInstrumentedFetch` from `@decocms/start` (spans, + * traceparent, URL redaction). + * 2. `shopifyOperationRouter` as the URL fallback for non-GraphQL + * and unnamed-GraphQL calls. + * 3. An `onComplete` that records the canonical + * `http.client.request.duration` histogram (via the framework's + * `recordCommerceMetric(...)` helper) with `provider: "shopify"`. + * + * Sites do: + * + * ```ts + * import { setShopifyFetch, createShopifyFetch } from "@decocms/apps/shopify"; + * setShopifyFetch(createShopifyFetch()); + * ``` + * + * Per-call operation names come from `extractGraphqlOperationName` + * (wired in `./graphql.ts`); the URL router fires only when the + * extractor returns `undefined`. + */ + +import { + createInstrumentedFetch, + type InstrumentedFetch, +} from "@decocms/blocks/sdk/instrumentedFetch"; +import { recordCommerceMetric } from "@decocms/blocks/sdk/observability"; +import { shopifyOperationRouter } from "./operationRouter"; + +export interface CreateShopifyFetchOptions { + baseFetch?: typeof fetch; + disableHistogram?: boolean; +} + +export function createShopifyFetch(options: CreateShopifyFetchOptions = {}): InstrumentedFetch { + const { baseFetch, disableHistogram = false } = options; + return createInstrumentedFetch({ + name: "shopify", + baseFetch, + resolveOperation: shopifyOperationRouter, + onComplete: disableHistogram + ? undefined + : ({ operation, status, durationMs, cached }) => { + recordCommerceMetric(durationMs, { + provider: "shopify", + operation, + status_class: `${Math.floor(status / 100)}xx`, + cached, + }); + }, + }); +} diff --git a/packages/apps-shopify/src/utils/operationRouter.ts b/packages/apps-shopify/src/utils/operationRouter.ts new file mode 100644 index 0000000..3366da5 --- /dev/null +++ b/packages/apps-shopify/src/utils/operationRouter.ts @@ -0,0 +1,70 @@ +/** + * URL-derived operation name router for Shopify API calls. + * + * Plugged into `@decocms/start`'s `createInstrumentedFetch` via the + * `resolveOperation(url, method)` option. Mirrors the shape of the + * VTEX router in `../../vtex/utils/operationRouter.ts`. + * + * Shopify's API surface from this repo is overwhelmingly GraphQL — + * a single endpoint per environment (storefront vs admin). That means + * the URL alone can only tell us *which GraphQL surface* a call is + * hitting, not what the call actually does. The semantic operation + * name lives in the GraphQL document itself (`query Foo { ... }`), + * and is extracted by `extractGraphqlOperationName` (see + * `./graphqlOperationName.ts`) at the client layer and stamped as + * `init.operation`, which always wins over this router. + * + * So this router exists for: + * + * - non-GraphQL Shopify REST endpoints we may add later + * (cart API, customer accounts, billing, etc.); + * - giving the GraphQL endpoints a *fallback* operation when the + * extractor can't parse a name (anonymous queries, missing body). + */ + +type OperationResolver = string | ((match: RegExpMatchArray, method: string) => string); + +interface Matcher { + pattern: RegExp; + operation: OperationResolver; +} + +const m = (pattern: RegExp, operation: OperationResolver): Matcher => ({ pattern, operation }); + +const MATCHERS: ReadonlyArray = [ + m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/graphql\.json/, "admin.graphql"), + m(/^\/api\/[0-9]{4}-[0-9]{2}\/graphql\.json/, "storefront.graphql"), + + m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/products/, "admin.products"), + m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/orders/, "admin.orders"), + m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/customers/, "admin.customers"), + m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/inventory/, "admin.inventory"), + + m(/^\/api\/[0-9]{4}-[0-9]{2}\/checkouts/, "storefront.checkout"), + m(/^\/cart(?:\/|\.js|$)/, "storefront.cart"), +]; + +/** + * Resolve an operation name for a Shopify URL. Returns `undefined` + * if no matcher fires, which causes the framework to fall back to + * `shopify.fetch`. + */ +export function shopifyOperationRouter(url: string, method: string): string | undefined { + let pathname: string; + try { + pathname = new URL(url).pathname; + } catch { + const qs = url.indexOf("?"); + const hash = url.indexOf("#"); + const end = [qs, hash].filter((i) => i >= 0).sort((a, b) => a - b)[0]; + pathname = end === undefined ? url : url.slice(0, end); + } + + const upperMethod = method.toUpperCase(); + for (const { pattern, operation } of MATCHERS) { + const match = pathname.match(pattern); + if (!match) continue; + return typeof operation === "function" ? operation(match, upperMethod) : operation; + } + return undefined; +} diff --git a/packages/apps-shopify/src/utils/storefront/queries.ts b/packages/apps-shopify/src/utils/storefront/queries.ts new file mode 100644 index 0000000..cde1d98 --- /dev/null +++ b/packages/apps-shopify/src/utils/storefront/queries.ts @@ -0,0 +1,525 @@ +import { gql } from "../graphql"; + +const ProductVariant = gql` +fragment ProductVariant on ProductVariant { + availableForSale + barcode + compareAtPrice { + amount + currencyCode + } + currentlyNotInStock + id + image { + altText + url + } + price { + amount + currencyCode + } + quantityAvailable + requiresShipping + selectedOptions { + name + value + } + sku + title + unitPrice { + amount + currencyCode + } + unitPriceMeasurement { + measuredType + quantityValue + referenceUnit + quantityUnit + } + weight + weightUnit +} +`; + +const Collection = gql` +fragment Collection on Collection { + description + descriptionHtml + handle + id + image { + altText + url + } + title + updatedAt +}`; + +const Product = gql` +fragment Product on Product { + availableForSale + createdAt + description + descriptionHtml + featuredImage { + altText + url + } + handle + id + images(first: 10) { + nodes { + altText + url + } + } + isGiftCard + media(first: 10) { + nodes { + alt + previewImage { + altText + url + } + mediaContentType + ... on Video { + alt + sources { + url + } + } + } + } + onlineStoreUrl + options { + name + values + } + priceRange { + minVariantPrice { + amount + currencyCode + } + maxVariantPrice { + amount + currencyCode + } + } + productType + publishedAt + requiresSellingPlan + seo { + title + description + } + tags + title + totalInventory + updatedAt + variants(first: 250) { + nodes { + ...ProductVariant + } + } + vendor + collections(first: 250) { + nodes { + ...Collection + } + } + metafields(identifiers: $identifiers) { + description + key + namespace + type + value + reference { + ... on MediaImage { + image { + url + } + } + } + references(first: 250) { + edges { + node { + ... on MediaImage { + image { + url + } + } + } + } + } + } +} +`; + +const Filter = gql` +fragment Filter on Filter{ + id + label + type + values { + count + id + input + label + } +} +`; + +const Cart = gql` +fragment Cart on Cart { + id + checkoutUrl + totalQuantity + lines(first: 100) { + nodes { + id + quantity + merchandise { + ...on ProductVariant { + id + title + image { + url + altText + } + product { + title + onlineStoreUrl + handle + } + price { + amount + currencyCode + } + } + } + discountAllocations { + ...on CartCodeDiscountAllocation { + code + discountedAmount { + amount + currencyCode + } + } + } + cost { + totalAmount { + amount + currencyCode + } + subtotalAmount { + amount + currencyCode + } + amountPerQuantity { + amount + currencyCode + } + compareAtAmountPerQuantity { + amount + currencyCode + } + } + } + } + cost { + totalTaxAmount { + amount + currencyCode + } + subtotalAmount { + amount + currencyCode + } + totalAmount { + amount + currencyCode + } + checkoutChargeAmount { + amount + currencyCode + } + } + discountCodes { + code + applicable + } + discountAllocations { + discountedAmount { + amount + currencyCode + } + } +}`; + +const Customer = gql` + fragment Customer on Customer { + id + email + firstName + lastName + } +`; + +export const CreateCart = { + query: gql`mutation CreateCart { + payload: cartCreate { + cart { id } + } + }`, +}; + +export const GetCart = { + fragments: [Cart], + query: gql`query GetCart($id: ID!) { cart(id: $id) { ...Cart } }`, +}; + +export const GetProduct = { + fragments: [Product, ProductVariant, Collection], + query: gql`query GetProduct($handle: String, $identifiers: [HasMetafieldsIdentifier!]!) { + product(handle: $handle) { ...Product } + }`, +}; + +export const ListProducts = { + fragments: [Product, ProductVariant, Collection], + query: gql`query ListProducts($first: Int, $after: String, $query: String, $identifiers: [HasMetafieldsIdentifier!]!) { + products(first: $first, after: $after, query: $query) { + nodes { + ...Product + } + } + }`, +}; + +export const SearchProducts = { + fragments: [Product, ProductVariant, Filter, Collection], + query: gql`query searchWithFilters( + $first: Int, + $last: Int, + $after: String, + $before: String, + $query: String!, + $productFilters: [ProductFilter!] + $sortKey: SearchSortKeys, + $reverse: Boolean, + $identifiers: [HasMetafieldsIdentifier!]! + ){ + search( + first: $first, + last: $last, + after: $after, + before: $before, + query: $query, + productFilters: $productFilters, + types: PRODUCT, + sortKey: $sortKey, + reverse: $reverse, + ){ + totalCount + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + productFilters { + ...Filter + } + nodes { + ...Product + } + } + }`, +}; + +export const ProductsByCollection = { + fragments: [Product, ProductVariant, Collection, Filter], + query: gql`query AllProducts( + $first: Int, + $last: Int, + $after: String, + $before: String, + $handle: String, + $sortKey: ProductCollectionSortKeys, + $reverse: Boolean, + $filters: [ProductFilter!], + $identifiers: [HasMetafieldsIdentifier!]! + ){ + collection(handle: $handle) { + handle + description + title + products( + first: $first, + last: $last, + after: $after, + before: $before, + sortKey: $sortKey, + reverse: $reverse, + filters: $filters + ){ + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + filters { + ...Filter + } + nodes { + ...Product + } + } + } + }`, +}; + +export const ProductRecommendations = { + fragments: [Product, ProductVariant, Collection], + query: gql`query productRecommendations($productId: ID!, $identifiers: [HasMetafieldsIdentifier!]!) { + productRecommendations(productId: $productId) { + ...Product + } + }`, +}; + +export const GetShopInfo = { + query: gql`query GetShopInfo($identifiers: [HasMetafieldsIdentifier!]!) { + shop { + name + description + privacyPolicy { + title + body + } + refundPolicy { + title + body + } + shippingPolicy { + title + body + } + subscriptionPolicy { + title + body + } + termsOfService { + title + body + } + metafields(identifiers: $identifiers) { + description + key + namespace + type + value + reference { + ... on MediaImage { + image { + url + } + } + } + references(first: 250) { + edges { + node { + ... on MediaImage { + image { + url + } + } + } + } + } + } + } + }`, +}; + +export const FetchCustomerInfo = { + fragments: [Customer], + query: gql`query FetchCustomerInfo($customerAccessToken: String!) { + customer(customerAccessToken: $customerAccessToken) { + ...Customer + } + }`, +}; + +export const AddItemToCart = { + fragments: [Cart], + query: gql`mutation AddItemToCart($cartId: ID!, $lines: [CartLineInput!]!) { + payload: cartLinesAdd(cartId: $cartId, lines: $lines) { + cart { ...Cart } + } + }`, +}; + +export const RegisterAccount = { + query: gql`mutation RegisterAccount( + $email: String!, + $password: String!, + $firstName: String, + $lastName: String, + $acceptsMarketing: Boolean = false + ) { + customerCreate(input: { + email: $email, + password: $password, + firstName: $firstName, + lastName: $lastName, + acceptsMarketing: $acceptsMarketing, + }) { + customer { + id + } + customerUserErrors { + code + message + } + } + }`, +}; + +export const AddCoupon = { + fragments: [Cart], + query: gql`mutation AddCoupon($cartId: ID!, $discountCodes: [String!]!) { + payload: cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) { + cart { ...Cart } + userErrors { + field + message + } + } + }`, +}; + +export const UpdateItems = { + fragments: [Cart], + query: gql`mutation UpdateItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) { + payload: cartLinesUpdate(cartId: $cartId, lines: $lines) { + cart { ...Cart } + } + }`, +}; + +export const SignInWithEmailAndPassword = { + query: gql`mutation SignInWithEmailAndPassword($email: String!, $password: String!) { + customerAccessTokenCreate(input: { email: $email, password: $password }) { + customerAccessToken { + accessToken + expiresAt + } + customerUserErrors { + code + message + } + } + }`, +}; diff --git a/packages/apps-shopify/src/utils/transform.ts b/packages/apps-shopify/src/utils/transform.ts new file mode 100644 index 0000000..efb4a0d --- /dev/null +++ b/packages/apps-shopify/src/utils/transform.ts @@ -0,0 +1,430 @@ +import type { + BreadcrumbList, + Filter, + ListItem, + Product, + ProductDetailsPage, + PropertyValue, + UnitPriceSpecification, +} from "@decocms/apps-commerce/types"; +import { DEFAULT_IMAGE } from "@decocms/apps-commerce/utils/constants"; + +type MoneyV2 = { amount: string; currencyCode: string }; +type ImageShopify = { url: string; altText?: string | null }; +type VideoSource = { url: string }; + +interface MediaNode { + alt?: string | null; + previewImage?: ImageShopify | null; + mediaContentType: string; + sources?: VideoSource[]; +} + +export type SkuShopify = { + id: string; + title: string; + availableForSale: boolean; + quantityAvailable?: number; + barcode?: string | null; + sku?: string | null; + image?: ImageShopify | null; + price: MoneyV2; + compareAtPrice?: MoneyV2 | null; + selectedOptions: Array<{ name: string; value: string }>; +}; + +export type CollectionNode = { + handle: string; + title: string; + id?: string; + description?: string; + descriptionHtml?: string; + image?: ImageShopify | null; +}; + +export type ProductShopify = { + id: string; + handle: string; + title: string; + description: string; + descriptionHtml?: string; + createdAt?: string; + tags: string[]; + vendor: string; + productType: string; + seo?: { title?: string | null; description?: string | null }; + images: { nodes: ImageShopify[] }; + media: { nodes: MediaNode[] }; + variants: { nodes: SkuShopify[] }; + collections?: { nodes: CollectionNode[] }; + metafields?: Array<{ + key: string; + value: string; + namespace: string; + type: string; + description?: string | null; + reference?: { image?: { url: string } } | null; + references?: { + edges: Array<{ node: { image?: { url: string } } }>; + } | null; + } | null>; +}; + +type FilterValue = { id: string; label: string; count: number; input: string }; +type FilterShopify = { id: string; label: string; type: string; values: FilterValue[] }; + +const getPath = ({ handle }: ProductShopify, sku?: SkuShopify) => + sku ? `/products/${handle}-${getIdFromVariantId(sku.id)}` : `/products/${handle}`; + +const getIdFromVariantId = (x: string) => { + const splitted = x.split("/"); + return Number(splitted[splitted.length - 1]); +}; + +const getVariantIdFromId = (id: number) => `gid://shopify/ProductVariant/${id}`; + +const nonEmptyArray = (array: T[] | null | undefined) => + Array.isArray(array) && array.length > 0 ? array : null; + +export const toProductPage = ( + product: ProductShopify, + url: URL, + maybeSkuId?: number, +): ProductDetailsPage => { + const skuId = maybeSkuId ? getVariantIdFromId(maybeSkuId) : product.variants.nodes[0]?.id; + let sku = product.variants.nodes.find((node) => node.id === skuId); + + if (!sku) { + sku = product.variants.nodes[0]; + } + + return { + "@type": "ProductDetailsPage", + breadcrumbList: toBreadcrumbList(product, sku), + product: toProduct(product, sku, url), + seo: { + title: product.seo?.title ?? product.title, + description: product.seo?.description ?? product.description, + canonical: `${url.origin}${getPath(product, sku)}`, + }, + }; +}; + +export const toBreadcrumbItem = ({ + name, + position, + item, +}: { + name: string; + position: number; + item: string; +}): ListItem => ({ + "@type": "ListItem", + name: decodeURI(name), + position, + item, +}); + +export const toBreadcrumbList = (product: ProductShopify, sku: SkuShopify): BreadcrumbList => { + let list: ListItem[] = []; + const collection = product.collections?.nodes[0]; + + if (collection) { + list = [ + toBreadcrumbItem({ + name: collection.title, + position: 1, + item: `/${collection.handle}`, + }), + toBreadcrumbItem({ + name: product.title, + position: 2, + item: getPath(product, sku), + }), + ]; + } else { + list = [ + toBreadcrumbItem({ + name: product.title, + position: 2, + item: getPath(product, sku), + }), + ]; + } + + return { + "@type": "BreadcrumbList", + numberOfItems: list.length, + itemListElement: list, + }; +}; + +export const toProduct = ( + product: ProductShopify, + sku: SkuShopify, + url: URL, + level = 0, +): Product => { + const { + createdAt, + description, + images, + media, + id: productGroupID, + variants, + vendor, + productType, + } = product; + const { + id: productID, + barcode, + selectedOptions, + image, + price, + availableForSale, + quantityAvailable, + compareAtPrice, + } = sku; + + const descriptionHtml: PropertyValue = { + "@type": "PropertyValue", + name: "descriptionHtml", + value: product.descriptionHtml, + }; + + const productTypeValue: PropertyValue = { + "@type": "PropertyValue", + name: "productType", + value: productType, + }; + + const metafields = (product.metafields ?? []) + .filter( + (metafield): metafield is NonNullable => + metafield != null && metafield.key != null && metafield.value != null, + ) + .map((metafield): PropertyValue => { + const { key, value, reference, references } = metafield; + const hasReferenceImage = reference && "image" in reference; + const referenceImageUrl = hasReferenceImage ? reference.image?.url : null; + + const hasEdges = references?.edges && references.edges.length > 0; + const edgeImages = hasEdges + ? references!.edges.map((edge) => + edge.node && "image" in edge.node ? edge.node.image?.url : null, + ) + : null; + + const rawValue = referenceImageUrl || edgeImages || value; + const valueToReturn = Array.isArray(rawValue) + ? JSON.stringify(rawValue) + : (rawValue ?? undefined); + + return { + "@type": "PropertyValue", + name: key, + value: valueToReturn, + }; + }); + + const additionalProperty: PropertyValue[] = selectedOptions + .map(toPropertyValue) + .concat(descriptionHtml) + .concat(productTypeValue) + .concat(metafields); + + const skuImages = nonEmptyArray([image]); + const hasVariant = + level < 1 && variants.nodes.map((variant) => toProduct(product, variant, url, 1)); + const priceSpec: UnitPriceSpecification[] = [ + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/SalePrice", + price: Number(price.amount), + }, + ]; + + if (compareAtPrice) { + priceSpec.push({ + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/ListPrice", + price: Number(compareAtPrice.amount), + }); + } + + const collectionNodes = product.collections?.nodes ?? []; + + return { + "@type": "Product", + productID, + url: `${url.origin}${getPath(product, sku)}`, + name: sku.title, + description, + sku: productID, + gtin: barcode ?? undefined, + brand: { "@type": "Brand", name: vendor }, + releaseDate: createdAt, + additionalProperty, + isVariantOf: { + "@type": "ProductGroup", + productGroupID, + hasVariant: hasVariant || [], + url: `${url.origin}${getPath(product)}`, + name: product.title, + additionalProperty: [ + ...(product.tags ?? []).map((value) => toPropertyValue({ name: "TAG", value })), + ...collectionNodes.map((col) => + toPropertyValue({ + "@id": col.id, + name: "COLLECTION", + value: col.title, + valueReference: col.handle, + description: col.description, + disambiguatingDescription: col.descriptionHtml, + ...(col.image && { + image: [ + { + "@type": "ImageObject" as const, + encodingFormat: "image", + alternateName: col.image.altText ?? "", + url: col.image.url, + }, + ], + }), + }), + ), + ], + image: nonEmptyArray(images.nodes)?.map((img) => ({ + "@type": "ImageObject" as const, + encodingFormat: "image", + alternateName: img.altText ?? "", + url: img.url, + })), + }, + image: skuImages?.map((img) => ({ + "@type": "ImageObject" as const, + encodingFormat: "image", + alternateName: img?.altText ?? "", + url: img?.url ?? "", + })) ?? [DEFAULT_IMAGE], + video: media.nodes + .filter((m) => m.mediaContentType === "VIDEO") + .map((video) => ({ + "@type": "VideoObject" as const, + contentUrl: video.sources?.[0]?.url, + description: video.alt ?? undefined, + thumbnailUrl: video.previewImage?.url, + })), + offers: { + "@type": "AggregateOffer", + priceCurrency: price.currencyCode, + highPrice: compareAtPrice ? Number(compareAtPrice.amount) : Number(price.amount), + lowPrice: Number(price.amount), + offerCount: 1, + offers: [ + { + "@type": "Offer", + price: Number(price.amount), + availability: availableForSale + ? "https://schema.org/InStock" + : "https://schema.org/OutOfStock", + inventoryLevel: { value: quantityAvailable ?? 0 }, + priceSpecification: priceSpec, + }, + ], + }, + }; +}; + +const toPropertyValue = (option: Omit): PropertyValue => ({ + "@type": "PropertyValue", + ...option, +}); + +const isSelectedFilter = (filterValue: FilterValue, url: URL) => { + let isSelected = false; + const label = getFilterValue(filterValue); + + url.searchParams.forEach((value, key) => { + if (!key?.startsWith("filter")) return; + if (value === label) isSelected = true; + }); + return isSelected; +}; + +export const toFilter = (filter: FilterShopify, url: URL): Filter => { + if (!filter.type.includes("RANGE")) { + return { + "@type": "FilterToggle", + label: filter.label, + key: filter.id, + values: filter.values.map((value) => ({ + quantity: value.count, + label: value.label, + value: value.label, + selected: isSelectedFilter(value, url), + url: filtersURL(filter, value, url), + })), + quantity: filter.values.length, + }; + } else { + const min = JSON.parse(filter.values[0].input).min; + const max = JSON.parse(filter.values[0].input).max; + return { + "@type": "FilterRange", + label: filter.label, + key: filter.id, + values: { min, max }, + }; + } +}; + +const filtersURL = (filter: FilterShopify, value: FilterValue, _url: URL) => { + const url = new URL(_url.href); + const params = new URLSearchParams(url.search); + params.delete("page"); + params.delete("startCursor"); + params.delete("endCursor"); + + const label = getFilterValue(value); + + if (params.has(filter.id, label)) { + params.delete(filter.id, label); + } else { + params.append(filter.id, label); + } + + url.search = params.toString(); + return url.toString(); +}; + +const getFilterValue = (value: FilterValue) => { + try { + const parsed = JSON.parse(value.input); + + const fieldsToCheck = [ + ["productMetafield", "value"], + ["taxonomyMetafield", "value"], + ["productVendor"], + ["productType"], + ["category", "id"], + ]; + + for (const path of fieldsToCheck) { + let current: any = parsed; + for (const key of path) { + if (current && typeof current === "object" && key in current) { + current = current[key]; + } else { + current = null; + break; + } + } + if (current != null) return current; + } + } catch (error) { + console.error("Error parsing input JSON:", error); + } + + return value.label; +}; diff --git a/packages/apps-shopify/src/utils/types.ts b/packages/apps-shopify/src/utils/types.ts new file mode 100644 index 0000000..f24e17a --- /dev/null +++ b/packages/apps-shopify/src/utils/types.ts @@ -0,0 +1,191 @@ +import type { + CountryCode, + CurrencyCode, + OrderCancelReason, + OrderFinancialStatus, + OrderFulfillmentStatus, +} from "./enums"; + +type Attribute = { + key: string; + value?: string; +}; + +type MailingAddress = { + address1?: string; + address2?: string; + city?: string; + company?: string; + country?: string; + countryCodeV2?: CountryCode; + firstName?: string; + formattedArea?: string; + id: string; + lastName?: string; + latitude?: number; + longitude?: number; + name?: string; + phone?: string; + province?: string; + provinceCode?: string; + zip?: string; +}; + +type MoneyV2 = { + amount: number; + currencyCode: CurrencyCode; +}; + +type AppliedGiftCard = { + amountUsed: MoneyV2; + balance: MoneyV2; + id: string; + lastCharacters: string; + presentmentAmountUsed: MoneyV2; +}; + +type ShippingRate = { + handle: string; + price: MoneyV2; + title: string; +}; + +type AvailableShippingRates = { + ready: boolean; + shippingRates?: ShippingRate[]; +}; + +type CheckoutBuyerIdentity = { + countryCode: CountryCode; +}; + +type Order = { + billingAddress?: MailingAddress; + cancelReason?: OrderCancelReason; + canceledAt?: Date; + currencyCode: CurrencyCode; + currentSubtotalPrice: MoneyV2; + currentTotalDuties?: MoneyV2; + currentTotalPrice: MoneyV2; + currentTotalTax: MoneyV2; + customAttributes: Attribute[]; + customerLocale?: string; + customerUrl?: string; + edited: boolean; + email?: string; + financialStatus?: OrderFinancialStatus; + fulfillmentStatus: OrderFulfillmentStatus; + id: string; + name: string; + orderNumber: number; + originalTotalDuties?: MoneyV2; + originalTotalPrice: MoneyV2; + phone?: string; + processedAt: Date; + shippingAddress?: MailingAddress; +}; + +type Checkout = { + appliedGiftCards: AppliedGiftCard[]; + availableShippingRates?: AvailableShippingRates; + buyerIdentity: CheckoutBuyerIdentity; + completedAt?: Date; + createdAt: Date; + currencyCode: CurrencyCode; + customAttributes: Attribute[]; + email?: string; + id: string; + lineItemsSubtotalPrice: MoneyV2; + note: string; + order: Order; + orderStatusUrl: string; + paymentDue: MoneyV2; + ready: boolean; + requireShipping: boolean; + shippingAddress: MailingAddress; +}; + +type Customer = { + acceptsMarketing: boolean; + createdAt: Date; + defaultAddress: MailingAddress; + displayName: string; + email: string; + firstName: string; + id: string; + checkout: Checkout; +}; + +type CartBuyerIdentity = { + countryCode: string; + customer: Customer; +}; + +export type OldCart = { + attribute?: Attribute; + attributes?: Attribute[]; + buyerIdentity?: CartBuyerIdentity; + id: string; +}; + +export interface Money { + amount: number; + currencyCode: string; +} + +export interface Image { + url: string; + width: number; + height: number; + altText: string; +} + +export interface Media { + nodes: Media[]; +} + +export interface Media { + alt: string; + previewImage: Image; + mediaContentType: string; +} + +export interface Option { + name: string; + values: string[]; +} + +export interface PriceRange { + minVariantPrice: Price; + maxVariantPrice: Price; +} + +export interface Price { + amount: string; + currencyCode: string; +} + +export interface SEO { + title: string; + description: string; +} + +export interface SelectedOption { + name: string; + value: string; +} + +export interface UnitPriceMeasurement { + measuredType: null; + quantityValue: number; + referenceUnit: null; + quantityUnit: null; +} + +/** + * @title {{{key}}} + */ +export interface Metafield { + namespace: string; + key: string; +} diff --git a/packages/apps-shopify/src/utils/user.ts b/packages/apps-shopify/src/utils/user.ts new file mode 100644 index 0000000..a082166 --- /dev/null +++ b/packages/apps-shopify/src/utils/user.ts @@ -0,0 +1,23 @@ +import { getCookies, setCookie } from "./cookies"; + +const CUSTOMER_COOKIE = "secure_customer_sig"; + +const ONE_WEEK_MS = 7 * 24 * 3600 * 1_000; + +export const getUserCookie = (headers: Headers): string | undefined => { + const cookies = getCookies(headers); + + return cookies[CUSTOMER_COOKIE]; +}; + +export const setUserCookie = (headers: Headers, accessToken: string) => { + setCookie(headers, { + name: CUSTOMER_COOKIE, + value: accessToken, + path: "/", + expires: new Date(Date.now() + ONE_WEEK_MS), + httpOnly: true, + secure: true, + sameSite: "Lax", + }); +}; diff --git a/packages/apps-shopify/src/utils/utils.ts b/packages/apps-shopify/src/utils/utils.ts new file mode 100644 index 0000000..41de812 --- /dev/null +++ b/packages/apps-shopify/src/utils/utils.ts @@ -0,0 +1,159 @@ +import type { + InputMaybe, + ProductCollectionSortKeys, + ProductFilter, + SearchSortKeys as SearchSortKeysShopify, +} from "../utils/storefront/storefront.graphql.gen"; + +export const sortOptions = [ + { value: "", label: "relevance:desc" }, + { value: "price-ascending", label: "price:asc" }, + { value: "price-descending", label: "price:desc" }, + { value: "best-selling", label: "orders:desc" }, + { value: "title-ascending", label: "name:asc" }, + { value: "title-descending", label: "name:desc" }, + { value: "created-descending", label: "release:desc" }, +]; + +// only these sorts work for search at shopify +export const searchSortOptions = [ + { value: "", label: "relevance:desc" }, + { value: "price-ascending", label: "price:asc" }, + { value: "price-descending", label: "price:desc" }, +]; + +export type CollectionSortKeys = + | "" + | "price-descending" + | "price-ascending" + | "best-selling" + | "title-descending" + | "title-ascending" + | "created-descending"; + +export type SearchSortKeys = "" | "price-descending" | "price-ascending"; + +export const searchSortShopify: Record< + string, + { sortKey: SearchSortKeysShopify; reverse: boolean } +> = { + "": { + sortKey: "RELEVANCE", + reverse: false, + }, + "price-descending": { + sortKey: "PRICE", + reverse: true, + }, + "price-ascending": { + sortKey: "PRICE", + reverse: false, + }, +}; + +export const sortShopify: Record< + string, + { + sortKey: ProductCollectionSortKeys | SearchSortKeysShopify; + reverse: boolean; + } +> = { + "": { + sortKey: "RELEVANCE", + reverse: false, + }, + "price-descending": { + sortKey: "PRICE", + reverse: true, + }, + "price-ascending": { + sortKey: "PRICE", + reverse: false, + }, + "best-selling": { + sortKey: "BEST_SELLING", + reverse: false, + }, + "title-descending": { + sortKey: "TITLE", + reverse: true, + }, + "title-ascending": { + sortKey: "TITLE", + reverse: false, + }, + "created-descending": { + sortKey: "CREATED", + reverse: true, + }, +}; + +export const filterToObject = (type: string, filter: InputMaybe) => { + if (type === "tag") { + return { tag: filter?.tag }; + } + if (type === "productType") { + return { productType: filter?.productType }; + } + if (type === "productVendor") { + return { productVendor: filter?.productVendor }; + } + if (type === "priceMin") { + return { price: { min: filter?.price?.min } }; + } + if (type === "priceMax") { + return { price: { max: filter?.price?.max } }; + } + if (type === "variantOption") { + return { variantOption: filter?.variantOption }; + } + return undefined; +}; + +export const getFiltersByUrl = (url: URL) => { + const filters: InputMaybe | undefined = []; + url.searchParams.forEach((value, key) => { + if (key.startsWith("filter.v.option")) { + filters.push({ + variantOption: { name: key.split(".")[3], value: value }, + }); + } else if (key.startsWith("filter.p.tag")) { + filters.push({ tag: value }); + } else if (key.startsWith("filter.p.type")) { + filters.push({ productType: value }); + } else if (key.startsWith("filter.p.product_type")) { + filters.push({ productType: value }); + } else if (key.startsWith("filter.p.vendor")) { + filters.push({ productVendor: value }); + } else if (key.startsWith("filter.v.availability")) { + filters.push({ available: value.toLowerCase() === "in stock" }); + } else if (key.startsWith("filter.v.price.gte")) { + filters.push({ price: { min: Number(value) } }); + } else if (key.startsWith("filter.v.price.lte")) { + filters.push({ price: { max: Number(value) } }); + } else if (key.startsWith("filter.p.m.custom")) { + filters.push({ + productMetafield: { + namespace: "custom", + key: key.replace("filter.p.m.custom.", ""), + value: value, + }, + }); + } else if (key.startsWith("filter.v.t.shopify")) { + filters.push({ + taxonomyMetafield: { + namespace: "shopify", + key: key.replace("filter.v.t.shopify.", ""), + value: value, + }, + }); + } else if (key.startsWith("filter.p.t.category")) { + filters.push({ + category: { + id: value, + }, + }); + } + }); + return filters; +}; diff --git a/packages/apps-shopify/tsconfig.json b/packages/apps-shopify/tsconfig.json new file mode 100644 index 0000000..42386d3 --- /dev/null +++ b/packages/apps-shopify/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"] +} From d0b2555810e409cfdfa82c9f6b32c95d6aaa651f Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:14:56 -0300 Subject: [PATCH 33/85] chore: exempt shopify's storefront.graphql.gen.ts from the blanket *.gen.ts gitignore Same pattern as manifest.gen.ts: apps-start hand-authors this file as a simplified type-stub (its own header says so), not a build artifact, but it fell outside the manifest.gen.ts-only carve-out and got silently dropped by 'git add' during Task 8 -- confirmed via a review pass, not caught by the implementer's local typecheck/test run (both false-positive green since the file was still physically present on disk from cp -r, just never staged). Confirmed via a full apps-start source scan this is the ONLY non-manifest .gen.ts file across every platform -- no other future task should hit this. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index 1d5c5d2..ab00c51 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,15 @@ dist/ # below). Scoped narrowly so other packages' *.gen.ts stay ignored. !packages/apps-*/src/manifest.gen.ts +# Same story, one specific file: apps-start hand-authors this as a simplified +# type-stub replacement for real GraphQL codegen output (its own header says +# so), not a build artifact — confirmed the ONLY non-manifest *.gen.ts file +# across the whole apps-start source tree (checked every platform). Caught +# late: Task 8 (apps-shopify) committed without it, since it fell outside the +# manifest.gen.ts-only carve-out above and `git add` silently skipped it, +# breaking the package on a clean checkout. +!packages/apps-shopify/src/utils/storefront/storefront.graphql.gen.ts + .notes/ # Lockfiles — bun is canonical, prevent accidental drift From f66c63c4f1836afaf582269ed2322307e757a42e Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:15:27 -0300 Subject: [PATCH 34/85] fix(apps-shopify): commit storefront.graphql.gen.ts missed by blanket gitignore The file collided with the repo's blanket *.gen.ts gitignore rule (only manifest.gen.ts had a carve-out) so `git add packages/apps-shopify` in the prior commit silently skipped it. It's load-bearing: utils/utils.ts imports types from it, transitively pulled into loaders/ProductList.ts, loaders/ProductListingPage.ts, and index.ts. A clean checkout of the prior commit was broken; local typecheck/test only passed because the file was still physically on disk from the cp -r migration step. Root cause (scoped .gitignore exception) already fixed in d0b2555. Co-Authored-By: Claude Sonnet 5 --- .../storefront/storefront.graphql.gen.ts | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 packages/apps-shopify/src/utils/storefront/storefront.graphql.gen.ts diff --git a/packages/apps-shopify/src/utils/storefront/storefront.graphql.gen.ts b/packages/apps-shopify/src/utils/storefront/storefront.graphql.gen.ts new file mode 100644 index 0000000..65365d2 --- /dev/null +++ b/packages/apps-shopify/src/utils/storefront/storefront.graphql.gen.ts @@ -0,0 +1,129 @@ +/** + * Shopify Storefront API GraphQL type stubs. + * These replace the auto-generated types from the original deco-cx/apps. + * Types are simplified but maintain the interface contract. + */ + +// Cart types +export type CartFragment = { + id: string; + checkoutUrl: string; + totalQuantity: number; + lines: { + nodes: Array<{ + id: string; + quantity: number; + merchandise: { + __typename?: string; + id: string; + title: string; + image?: { url: string; altText?: string | null } | null; + product: { title: string; handle: string; onlineStoreUrl?: string | null }; + price: { amount: string; currencyCode: string }; + compareAtPrice?: { amount: string; currencyCode: string } | null; + }; + discountAllocations?: Array<{ + __typename?: string; + code?: string; + }>; + }>; + }; + cost: { + totalAmount: { amount: string; currencyCode: string }; + subtotalAmount: { amount: string; currencyCode: string }; + }; + discountCodes?: Array<{ applicable: boolean; code: string }>; +}; + +// Mutation types +export type AddItemToCartMutation = { cart?: CartFragment | null }; +export type AddItemToCartMutationVariables = { cartId: string; lines: unknown }; +export type UpdateItemsMutation = { cart?: CartFragment | null }; +export type UpdateItemsMutationVariables = { cartId: string; lines: unknown }; +export type AddCouponMutation = { cart?: CartFragment | null }; +export type AddCouponMutationVariables = { cartId: string; discountCodes: string[] }; + +// Product types — these are intentionally loose stubs because the +// real Shopify Storefront API GraphQL types are huge and only a tiny +// subset is consumed. `unknown` keeps consumers honest (forces a cast +// at the boundary) without exploding the type surface. +export type ProductFragment = unknown; +export type ProductVariantFragment = unknown; +export type GetProductQuery = { product?: unknown }; +export type GetProductQueryVariables = { handle?: string; identifiers?: unknown[] }; +export type ProductRecommendationsQuery = { productRecommendations?: unknown[] }; +export type ProductRecommendationsQueryVariables = { productId: string }; + +// Search/Collection types +export type InputMaybe = T | null | undefined; +export type ProductCollectionSortKeys = string; +export type SearchSortKeys = string; +// Loose shape derived from the only consumers in +// `shopify/utils/utils.ts` (filterToObject + getFiltersByUrl). Keeps +// the types honest without depending on Shopify's full GraphQL schema. +export type ProductFilter = { + tag?: string; + productType?: string; + productVendor?: string; + available?: boolean; + price?: { min?: number; max?: number }; + variantOption?: { name: string; value: string }; + productMetafield?: { namespace: string; key: string; value: string }; + taxonomyMetafield?: { namespace: string; key: string; value: string }; + category?: { id: string }; +}; + +// Customer types +export type Customer = { + id: string; + firstName?: string | null; + lastName?: string | null; + email?: string | null; + phone?: string | null; + acceptsMarketing?: boolean; + defaultAddress?: unknown; + addresses?: { nodes: unknown[] }; + orders?: { nodes: unknown[] }; +}; + +export type CustomerAccessTokenCreateInput = { + email: string; + password: string; +}; + +export type CustomerAccessTokenCreateWithMultipassPayload = { + customerAccessToken?: { accessToken: string; expiresAt: string } | null; + customerUserErrors?: Array<{ message: string; code?: string }>; +}; + +export type CustomerCreateInput = { + email: string; + password: string; + firstName?: string; + lastName?: string; + acceptsMarketing?: boolean; +}; + +export type CustomerCreatePayload = { + customer?: Customer | null; + customerUserErrors?: Array<{ message: string; code?: string }>; +}; + +// Shop types +export type Shop = { + name: string; + description?: string; + shipsToCountries?: string[]; + refundPolicy?: { body: string; title: string; url: string }; + privacyPolicy?: { body: string; title: string; url: string }; + termsOfService?: { body: string; title: string; url: string }; + metafields?: Array<{ key: string; value: string; namespace: string } | null>; +}; + +export type ShopMetafieldsArgs = { + identifiers: Array<{ namespace: string; key: string }>; +}; + +// Order/Admin types +export type CountryCode = string; +export type Maybe = T | null; From 0d6b50744d1e3c348ceead6589014927227144ca Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:19:51 -0300 Subject: [PATCH 35/85] feat(apps-magento): migrate magento/ from apps-start --- packages/apps-magento/package.json | 37 ++ packages/apps-magento/src/README.md | 65 ++++ .../apps-magento/src/__tests__/cart.test.ts | 139 +++++++ .../apps-magento/src/__tests__/client.test.ts | 252 +++++++++++++ .../src/__tests__/features.test.ts | 51 +++ .../src/__tests__/graphql.test.ts | 183 +++++++++ .../__tests__/newsletter-subscribe.test.ts | 86 +++++ .../src/__tests__/product-stockAlert.test.ts | 80 ++++ .../__tests__/stringifySearchCriteria.test.ts | 56 +++ .../src/__tests__/transform.test.ts | 346 ++++++++++++++++++ .../src/__tests__/user-loader.test.ts | 134 +++++++ .../src/__tests__/wishlist-actions.test.ts | 140 +++++++ .../src/__tests__/wishlist-loader.test.ts | 97 +++++ .../src/actions/newsletter/subscribe.ts | 40 ++ .../src/actions/product/stockAlert.ts | 67 ++++ .../src/actions/wishlist/addItem.ts | 57 +++ .../src/actions/wishlist/removeItem.ts | 59 +++ packages/apps-magento/src/client.ts | 228 ++++++++++++ packages/apps-magento/src/index.ts | 11 + packages/apps-magento/src/loaders/cart.ts | 64 ++++ packages/apps-magento/src/loaders/features.ts | 16 + packages/apps-magento/src/loaders/user.ts | 60 +++ packages/apps-magento/src/loaders/wishlist.ts | 29 ++ packages/apps-magento/src/middleware.ts | 19 + packages/apps-magento/src/types.ts | 51 +++ .../src/utils/cacheTimeControl.ts | 69 ++++ .../apps-magento/src/utils/client/types.ts | 270 ++++++++++++++ packages/apps-magento/src/utils/constants.ts | 101 +++++ .../apps-magento/src/utils/graphql-types.ts | 99 +++++ packages/apps-magento/src/utils/graphql.ts | 155 ++++++++ .../src/utils/stringifySearchCriteria.ts | 62 ++++ packages/apps-magento/src/utils/transform.ts | 283 ++++++++++++++ packages/apps-magento/src/utils/user.ts | 16 + packages/apps-magento/tsconfig.json | 7 + 34 files changed, 3429 insertions(+) create mode 100644 packages/apps-magento/package.json create mode 100644 packages/apps-magento/src/README.md create mode 100644 packages/apps-magento/src/__tests__/cart.test.ts create mode 100644 packages/apps-magento/src/__tests__/client.test.ts create mode 100644 packages/apps-magento/src/__tests__/features.test.ts create mode 100644 packages/apps-magento/src/__tests__/graphql.test.ts create mode 100644 packages/apps-magento/src/__tests__/newsletter-subscribe.test.ts create mode 100644 packages/apps-magento/src/__tests__/product-stockAlert.test.ts create mode 100644 packages/apps-magento/src/__tests__/stringifySearchCriteria.test.ts create mode 100644 packages/apps-magento/src/__tests__/transform.test.ts create mode 100644 packages/apps-magento/src/__tests__/user-loader.test.ts create mode 100644 packages/apps-magento/src/__tests__/wishlist-actions.test.ts create mode 100644 packages/apps-magento/src/__tests__/wishlist-loader.test.ts create mode 100644 packages/apps-magento/src/actions/newsletter/subscribe.ts create mode 100644 packages/apps-magento/src/actions/product/stockAlert.ts create mode 100644 packages/apps-magento/src/actions/wishlist/addItem.ts create mode 100644 packages/apps-magento/src/actions/wishlist/removeItem.ts create mode 100644 packages/apps-magento/src/client.ts create mode 100644 packages/apps-magento/src/index.ts create mode 100644 packages/apps-magento/src/loaders/cart.ts create mode 100644 packages/apps-magento/src/loaders/features.ts create mode 100644 packages/apps-magento/src/loaders/user.ts create mode 100644 packages/apps-magento/src/loaders/wishlist.ts create mode 100644 packages/apps-magento/src/middleware.ts create mode 100644 packages/apps-magento/src/types.ts create mode 100644 packages/apps-magento/src/utils/cacheTimeControl.ts create mode 100644 packages/apps-magento/src/utils/client/types.ts create mode 100644 packages/apps-magento/src/utils/constants.ts create mode 100644 packages/apps-magento/src/utils/graphql-types.ts create mode 100644 packages/apps-magento/src/utils/graphql.ts create mode 100644 packages/apps-magento/src/utils/stringifySearchCriteria.ts create mode 100644 packages/apps-magento/src/utils/transform.ts create mode 100644 packages/apps-magento/src/utils/user.ts create mode 100644 packages/apps-magento/tsconfig.json diff --git a/packages/apps-magento/package.json b/packages/apps-magento/package.json new file mode 100644 index 0000000..e6129b0 --- /dev/null +++ b/packages/apps-magento/package.json @@ -0,0 +1,37 @@ +{ + "name": "@decocms/apps-magento", + "version": "0.0.0", + "type": "module", + "description": "Deco commerce app: Magento integration", + "repository": { "type": "git", "url": "https://github.com/decocms/blocks.git", "directory": "packages/apps-magento" }, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts", + "./types": "./src/types.ts", + "./middleware": "./src/middleware.ts", + "./loaders/*": "./src/loaders/*.ts", + "./actions/*": "./src/actions/*.ts", + "./utils/*": "./src/utils/*.ts", + "./hooks/*": "./src/hooks/*.ts" + }, + "scripts": { + "build": "tsc", + "test": "vitest run --root ../.. packages/apps-magento/", + "typecheck": "tsc --noEmit", + "lint:unused": "knip" + }, + "dependencies": { + "@decocms/blocks": "workspace:*", + "@decocms/apps-commerce": "workspace:*", + "@decocms/tanstack": "workspace:*" + }, + "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "knip": "^5.86.0", + "typescript": "^5.9.0" + }, + "publishConfig": { "registry": "https://registry.npmjs.org", "access": "public" } +} diff --git a/packages/apps-magento/src/README.md b/packages/apps-magento/src/README.md new file mode 100644 index 0000000..177de53 --- /dev/null +++ b/packages/apps-magento/src/README.md @@ -0,0 +1,65 @@ +# Magento app — initial scaffold + +This folder ports the Magento integration from `deco-cx/apps/magento` +(Fresh/Deno) to `@decocms/apps/magento` (TanStack Start/Node), following +the same shape as the existing `vtex/` and `shopify/` packages. + +## Status + +**Initial scaffold** — covers the configure/client surface and 2 reference +loaders (`features`, `cart`) so that downstream sites can wire magento at +all. The remaining 20+ loaders/actions exist as production-grade code in +the original deco-cx/apps repo and need adaptation passes (Deno → Node, +ctx-based to client-based state access, cookie helpers from +`@decocms/start/sdk/cookie`). + +A real-world consumer (deco-sites/granadobr-tanstack) is wiring magento +in-site today using a thin adapter that wraps the legacy `magento/mod.ts` +shape. Their adapter is the migration target — once this package covers +the surface area they need, the in-site copy goes away. + +## What's here + +- `client.ts` — `configureMagento({ baseUrl, apiKey, storeId, ... })` + + `getMagentoConfig()` global accessor. Mirrors the `configureVtex` + pattern. +- `types.ts` — request/response shapes shared between loaders. +- `loaders/features.ts` — returns the feature flags block. The simplest + loader, used as a smoke test. +- `loaders/cart.ts` — fetches the customer's active cart by cookie. + Pulls cookies via `@decocms/start/sdk/cookie`. +- `middleware.ts` — passthrough today; real-world cart-id reconciliation + lives in the consumer site for now. +- `index.ts` — re-export entry. + +## Pending port (PR follow-ups) + +| Path | Original location | +|---|---| +| `loaders/product/{detailsPage,detailsPageGQL,listingPage,list,relatedProducts}.ts` | `deco-cx/apps/magento/loaders/product/*` | +| `loaders/{proxy,user,wishlist}.ts` | `deco-cx/apps/magento/loaders/*` | +| `loaders/routes/getRouteType.ts` | idem | +| `actions/cart/{addCoupon,addItem,removeCoupon,removeItem,setSimulation,simulation,updateItem}.ts` | `deco-cx/apps/magento/actions/cart/*` | +| `actions/newsletter/subscribe.ts` | idem | +| `actions/product/stockAlert.ts` | idem | +| `actions/wishlist/{addItem,removeItem}.ts` | idem | +| `utils/{clientGraphql,client,transform,cache,graphql,cart}.ts` | `deco-cx/apps/magento/utils/*` | +| `hooks/{useCart,useUser,useWishlist}.ts` | `deco-cx/apps/magento/hooks/*` (refactor to react-query, like `vtex/hooks/`) | +| `inline-loaders/*` | new, follow `vtex/inline-loaders/` shape | + +**Site-specific extensions (Livelo, Amasty)** in the deco-cx/apps repo +should stay out of this package — they belong in consumer sites via the +`ExtensionOf` pattern. Preserving that pattern in the generic loaders +above is a hard requirement of this port. + +## Why a stub now + +The deco-sites/granadobr-tanstack migration hit a HIGH parity finding: + +``` +invoke(magento/loaders/features) failed: handler not found +``` + +…because no `@decocms/apps/magento/*` resolver existed. The site is +working around it locally; this PR begins the upstream fix so future +magento sites don't have to repeat the in-site adapter. diff --git a/packages/apps-magento/src/__tests__/cart.test.ts b/packages/apps-magento/src/__tests__/cart.test.ts new file mode 100644 index 0000000..acfcbc7 --- /dev/null +++ b/packages/apps-magento/src/__tests__/cart.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for the cart loader. + * + * Parity goals against deco-cx/apps/magento/loaders/cart.ts (Fresh/Deno, + * prod): + * + * - Reads `dataservices_cart_id` cookie when caller doesn't supply + * `cartId` in props (matches `getCartCookie(req.headers)` in prod). + * - Honors props.cartId override (matches `_cartId ?? getCartCookie()`). + * - Hits /rest/:site/V1/carts/:cartId — the /rest/ prefix is what the + * Magento REST API exposes; the legacy clientAdmin's typed key was + * "GET /rest/:site/V1/carts/:cartId". + * - Encodes site + cartId so a malicious cookie can't escape the path + * segment and reach another admin endpoint with the Bearer token. + * - Returns null when no cookie (anonymous visitor — matches prod's + * `if (!cartId) return null`). + * - Returns null on 404 (expired cookie — prod hits this via the same + * branch but throws on other non-2xx; we surface a plain Error). + * + * NOT covered by this initial port (and intentionally not tested here): + * - Pre-cart `GET /rest/:site/V1/carts/mine` warm-up for logged-in + * users (the original does it inside a try/catch and discards the + * result — it's a cache primer, not a correctness invariant). + * - Parallel /totals fetch + image-pipeline transform — those land in + * follow-up PRs that port utils/cart.ts + utils/cache.ts. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { configureMagento } from "../client"; +import cart from "../loaders/cart"; + +function mockResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function requestWithCookie(cookie: string): Request { + const r = new Request("http://localhost/"); + // Headers in a freshly-constructed Request enter "request" guard mode + // which silently drops `cookie`. Build a Headers manually then attach. + const headers = new Headers(); + headers.set("cookie", cookie); + Object.defineProperty(r, "headers", { value: headers, configurable: true }); + return r; +} + +describe("cart loader", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + configureMagento({ + baseUrl: "https://loja.example.com/", + apiKey: "secret", + storeId: 1, + site: "example", + }); + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns null when no cart cookie is present", async () => { + const req = new Request("http://localhost/"); + const result = await cart(undefined, req); + expect(result).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("reads cartId from the dataservices_cart_id cookie (JSON-encoded)", async () => { + fetchSpy.mockResolvedValue(mockResponse({ id: "abc123", items: [] })); + const req = requestWithCookie('dataservices_cart_id="abc123"'); + await cart(undefined, req); + const [target] = fetchSpy.mock.calls[0] as [URL]; + expect(target.toString()).toBe("https://loja.example.com/rest/example/V1/carts/abc123"); + }); + + it("reads cartId from the cookie when it's not JSON-quoted", async () => { + fetchSpy.mockResolvedValue(mockResponse({ id: "raw-id", items: [] })); + const req = requestWithCookie("dataservices_cart_id=raw-id"); + await cart(undefined, req); + const [target] = fetchSpy.mock.calls[0] as [URL]; + expect(target.toString()).toBe("https://loja.example.com/rest/example/V1/carts/raw-id"); + }); + + it("honors props.cartId override (cookie ignored)", async () => { + fetchSpy.mockResolvedValue(mockResponse({ id: "from-props", items: [] })); + const req = requestWithCookie('dataservices_cart_id="from-cookie"'); + await cart({ cartId: "from-props" }, req); + const [target] = fetchSpy.mock.calls[0] as [URL]; + expect(target.toString()).toContain("/rest/example/V1/carts/from-props"); + }); + + it("URL-encodes the cartId to prevent path injection", async () => { + // A malicious cookie value tries to break out of the path segment + // and reach a different admin endpoint with the Bearer attached. + fetchSpy.mockResolvedValue(mockResponse(null, 404)); + const req = requestWithCookie('dataservices_cart_id="../admin/leak?secret=1"'); + await cart(undefined, req); + const [target] = fetchSpy.mock.calls[0] as [URL]; + // Encoded: %2F (slash), %3F (?), %3D (=). The trailing + // "/admin/leak?secret=1" stays inside the path segment. + expect(target.pathname).toMatch( + /^\/rest\/example\/V1\/carts\/(\.\.|%2E%2E)%2Fadmin%2Fleak%3Fsecret%3D1$/i, + ); + }); + + it("returns null on 404 (expired cart cookie)", async () => { + fetchSpy.mockResolvedValue(mockResponse(null, 404)); + const req = requestWithCookie('dataservices_cart_id="stale"'); + const result = await cart(undefined, req); + expect(result).toBeNull(); + }); + + it("throws on non-404 errors", async () => { + fetchSpy.mockResolvedValue(mockResponse(null, 500)); + const req = requestWithCookie('dataservices_cart_id="abc"'); + await expect(cart(undefined, req)).rejects.toThrow(/cart loader: 500/); + }); + + it("attaches Bearer + x-origin-header (same-origin) on the cart fetch", async () => { + configureMagento({ + baseUrl: "https://loja.example.com/", + apiKey: "secret", + storeId: 1, + site: "example", + originHeader: "origin-secret", + }); + fetchSpy.mockResolvedValue(mockResponse({ id: "abc", items: [] })); + const req = requestWithCookie('dataservices_cart_id="abc"'); + await cart(undefined, req); + const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + const headers = init.headers as Headers; + expect(headers.get("authorization")).toBe("Bearer secret"); + expect(headers.get("x-origin-header")).toBe("origin-secret"); + }); +}); diff --git a/packages/apps-magento/src/__tests__/client.test.ts b/packages/apps-magento/src/__tests__/client.test.ts new file mode 100644 index 0000000..122dff1 --- /dev/null +++ b/packages/apps-magento/src/__tests__/client.test.ts @@ -0,0 +1,252 @@ +/** + * Tests for the Magento client config + magentoFetch wrapper. + * + * Parity goals — behavior these tests pin down so the port stays aligned + * with deco-cx/apps/magento (Fresh/Deno, prod): + * + * - configureMagento / getMagentoConfig is a write-once-read-many global + * (mirrors configureVtex). getMagentoConfig() throws before configure(). + * - magentoFetch: + * • Same-origin: attaches Authorization (Bearer apiKey), + * x-origin-header (when configured), and a forced Referer pointing + * at baseUrl — exactly the headers the Fresh `clientAdmin` was + * built with at App() time. + * • Cross-origin: strips ALL Magento-only identity headers so the + * admin Bearer + origin secret never leak to a third party. Caller + * headers pass through. + * • authenticated:false opt-out drops Bearer even on same-origin. + * • Relative paths resolve against baseUrl with proper "/" handling. + * • Absolute https://… paths bypass baseUrl entirely. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { configureMagento, getMagentoConfig, magentoFetch } from "../client"; + +// Reset module-global config between tests by re-importing. +beforeEach(() => { + vi.resetModules(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("configureMagento / getMagentoConfig", () => { + it("throws before configureMagento() is called", async () => { + const { getMagentoConfig: freshGet } = await import("../client"); + expect(() => freshGet()).toThrow(/configureMagento\(\) must be called/); + }); + + it("returns the configured value after configureMagento()", async () => { + const { configureMagento: c, getMagentoConfig: g } = await import("../client"); + c({ + baseUrl: "https://loja.example.com/", + apiKey: "test-key", + storeId: 1, + site: "example", + }); + expect(g().baseUrl).toBe("https://loja.example.com/"); + expect(g().apiKey).toBe("test-key"); + }); +}); + +describe("magentoFetch — same-origin (configured baseUrl)", () => { + const baseUrl = "https://loja.example.com/"; + let fetchSpy: ReturnType; + + beforeEach(() => { + configureMagento({ + baseUrl, + apiKey: "secret-bearer", + storeId: 1, + site: "example", + originHeader: "origin-secret", + }); + fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response("{}", { status: 200, headers: { "content-type": "application/json" } }), + ); + }); + + it("attaches Authorization, x-origin-header, and forced Referer", async () => { + await magentoFetch("/rest/example/V1/carts/123"); + const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + const headers = init.headers as Headers; + expect(headers.get("authorization")).toBe("Bearer secret-bearer"); + expect(headers.get("x-origin-header")).toBe("origin-secret"); + expect(headers.get("referer")).toBe(baseUrl); + }); + + it("resolves relative path against baseUrl with correct slash handling", async () => { + await magentoFetch("rest/example/V1/carts/123"); // no leading slash + const [target] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + expect(target.toString()).toBe("https://loja.example.com/rest/example/V1/carts/123"); + }); + + it("authenticated:false suppresses Bearer even on same-origin", async () => { + await magentoFetch("/rest/example/V1/carts/123", { authenticated: false }); + const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + const headers = init.headers as Headers; + expect(headers.get("authorization")).toBeNull(); + // Same-origin still gets the other Magento-identity headers. + expect(headers.get("x-origin-header")).toBe("origin-secret"); + }); + + it("preserves caller-supplied Referer (no force-overwrite when caller set one)", async () => { + await magentoFetch("/rest/example/V1/carts/123", { + headers: { Referer: "https://caller.example/page" }, + }); + const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + const headers = init.headers as Headers; + expect(headers.get("referer")).toBe("https://caller.example/page"); + }); +}); + +describe("magentoFetch — cross-origin guard", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + configureMagento({ + baseUrl: "https://loja.example.com/", + apiKey: "secret-bearer", + storeId: 1, + site: "example", + originHeader: "origin-secret", + }); + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 200 })); + }); + + it("strips Bearer when fetching a non-Magento host", async () => { + await magentoFetch("https://attacker.example/api"); + const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + const headers = init.headers as Headers; + expect(headers.get("authorization")).toBeNull(); + }); + + it("strips x-origin-header when fetching a non-Magento host", async () => { + // Regression for cubic review: the previous fix only dropped Bearer + // while x-origin-header and Referer still leaked. + await magentoFetch("https://attacker.example/api"); + const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + const headers = init.headers as Headers; + expect(headers.get("x-origin-header")).toBeNull(); + }); + + it("strips forced Referer when fetching a non-Magento host", async () => { + await magentoFetch("https://attacker.example/api"); + const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + const headers = init.headers as Headers; + // Referer to https://loja.example.com/ would broadcast the Magento + // host to the third party — must not be set by us. + expect(headers.get("referer")).toBeNull(); + }); + + it("still forwards caller-supplied headers cross-origin", async () => { + await magentoFetch("https://partner.example/api", { + headers: { "x-correlation-id": "abc123" }, + }); + const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + const headers = init.headers as Headers; + expect(headers.get("x-correlation-id")).toBe("abc123"); + }); + + it("treats an absolute URL with the same origin as same-origin", async () => { + await magentoFetch("https://loja.example.com/rest/example/V1/carts/123"); + const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + expect(target.toString()).toBe("https://loja.example.com/rest/example/V1/carts/123"); + expect((init.headers as Headers).get("authorization")).toBe("Bearer secret-bearer"); + }); +}); + +describe("initMagentoFromBlocks — secret resolution", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("returns early when the `magento` block is absent", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { initMagentoFromBlocks, getMagentoConfig } = await import("../client"); + await initMagentoFromBlocks({}); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("No `magento` block")); + expect(() => getMagentoConfig()).toThrow(/configureMagento\(\) must be called/); + }); + + it("reads plain-string apiKey directly", async () => { + const { initMagentoFromBlocks, getMagentoConfig } = await import("../client"); + await initMagentoFromBlocks({ + magento: { + apiConfig: { + baseUrl: "https://loja.example.com/", + apiKey: "plain-string-key", + site: "example", + storeId: 1, + }, + }, + }); + expect(getMagentoConfig().apiKey).toBe("plain-string-key"); + }); + + it("dereferences a Secret-shaped apiKey via process.env (the env fallback path)", async () => { + process.env.TEST_MAGENTO_KEY = "from-env"; + const { initMagentoFromBlocks, getMagentoConfig } = await import("../client"); + await initMagentoFromBlocks({ + magento: { + apiConfig: { + baseUrl: "https://loja.example.com/", + apiKey: { + __resolveType: "website/loaders/secret.ts", + name: "TEST_MAGENTO_KEY", + }, + site: "example", + storeId: 1, + }, + }, + }); + expect(getMagentoConfig().apiKey).toBe("from-env"); + delete process.env.TEST_MAGENTO_KEY; + }); + + it("falls back to empty string when secret is unresolvable", async () => { + // no DECO_CRYPTO_KEY, no env var with this name, no decrypt + // → resolveSecret returns null → init writes "". + delete process.env.DECO_CRYPTO_KEY; + const { initMagentoFromBlocks, getMagentoConfig } = await import("../client"); + await initMagentoFromBlocks({ + magento: { + apiConfig: { + baseUrl: "https://loja.example.com/", + apiKey: { + __resolveType: "website/loaders/secret.ts", + encrypted: "deadbeef", + name: "UNDEFINED_ENV_VAR_DO_NOT_SET", + }, + site: "example", + storeId: 1, + }, + }, + }); + expect(getMagentoConfig().apiKey).toBe(""); + }); + + it("resolves both apiKey and originHeader independently", async () => { + process.env.TEST_API_KEY = "api-from-env"; + process.env.TEST_ORIGIN = "origin-from-env"; + const { initMagentoFromBlocks, getMagentoConfig } = await import("../client"); + await initMagentoFromBlocks({ + magento: { + apiConfig: { + baseUrl: "https://loja.example.com/", + apiKey: { name: "TEST_API_KEY" }, + originHeader: { name: "TEST_ORIGIN" }, + site: "example", + storeId: 1, + }, + }, + }); + const cfg = getMagentoConfig(); + expect(cfg.apiKey).toBe("api-from-env"); + expect(cfg.originHeader).toBe("origin-from-env"); + delete process.env.TEST_API_KEY; + delete process.env.TEST_ORIGIN; + }); +}); diff --git a/packages/apps-magento/src/__tests__/features.test.ts b/packages/apps-magento/src/__tests__/features.test.ts new file mode 100644 index 0000000..1975f6c --- /dev/null +++ b/packages/apps-magento/src/__tests__/features.test.ts @@ -0,0 +1,51 @@ +/** + * Tests for the features loader. + * + * Parity with deco-cx/apps/magento/loaders/features.ts (Fresh/Deno, prod): + * the legacy loader was a 3-arg `(_props, _req, ctx) => ctx.features`. + * The ported version uses the module-global config instead of a per- + * request ctx, but the surface area downstream is the same — a plain + * object pulled from the resolved magento CMS block. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { configureMagento } from "../client"; +import features from "../loaders/features"; + +describe("features loader", () => { + beforeEach(() => { + configureMagento({ + baseUrl: "https://loja.example.com/", + apiKey: "key", + storeId: 1, + site: "example", + features: { + dangerouslyDisableWishlist: false, + dangerouslyDisableOnLoadUpdate: true, + dangerouslyReturnNullAfterAction: true, + dangerouslyDontReturnCartAfterAction: true, + dangerouslyDisableOnVisibilityChangeUpdate: true, + }, + }); + }); + + it("returns the feature flags object from the config", () => { + expect(features()).toEqual({ + dangerouslyDisableWishlist: false, + dangerouslyDisableOnLoadUpdate: true, + dangerouslyReturnNullAfterAction: true, + dangerouslyDontReturnCartAfterAction: true, + dangerouslyDisableOnVisibilityChangeUpdate: true, + }); + }); + + it("returns an empty object when features are not configured", () => { + configureMagento({ + baseUrl: "https://loja.example.com/", + apiKey: "key", + storeId: 1, + site: "example", + // features omitted + }); + expect(features()).toEqual({}); + }); +}); diff --git a/packages/apps-magento/src/__tests__/graphql.test.ts b/packages/apps-magento/src/__tests__/graphql.test.ts new file mode 100644 index 0000000..813b3fa --- /dev/null +++ b/packages/apps-magento/src/__tests__/graphql.test.ts @@ -0,0 +1,183 @@ +/** + * Tests for the GraphQL helpers used by product loaders. + * + * Parity with deco-cx/apps/magento/utils/graphql.ts — pure functions, + * so behavior is exhaustively pinnable. Each test mirrors a real call + * site in the (still-unported) product loaders. + */ +import { describe, expect, it } from "vitest"; +import { + filtersFromLoaderGraphQL, + filtersFromUrlGraphQL, + formatUrlSuffix, + getCustomFields, + transformFilterGraphQL, + transformFilterValueGraphQL, + transformSortGraphQL, +} from "../utils/graphql"; + +describe("transformSortGraphQL", () => { + it("returns undefined when sortBy is absent", () => { + expect(transformSortGraphQL({})).toBeUndefined(); + expect(transformSortGraphQL({ order: "DESC" })).toBeUndefined(); + }); + + it("defaults order to ASC when not provided", () => { + expect(transformSortGraphQL({ sortBy: { value: "price" } })).toEqual({ + price: "ASC", + }); + }); + + it("respects DESC order", () => { + expect(transformSortGraphQL({ sortBy: { value: "name" }, order: "DESC" })).toEqual({ + name: "DESC", + }); + }); + + it("supports custom sort options (any string value)", () => { + expect(transformSortGraphQL({ sortBy: { value: "best_seller_rank" } as any })).toEqual({ + best_seller_rank: "ASC", + }); + }); +}); + +describe("transformFilterValueGraphQL", () => { + it("EQUAL → { eq: value }", () => { + expect(transformFilterValueGraphQL("ABC", "EQUAL")).toEqual({ eq: "ABC" }); + }); + + it("MATCH → { match: value }", () => { + expect(transformFilterValueGraphQL("foo bar", "MATCH")).toEqual({ + match: "foo bar", + }); + }); + + it("RANGE → { from, to } split on the FIRST underscore", () => { + expect(transformFilterValueGraphQL("10_50", "RANGE")).toEqual({ + from: "10", + to: "50", + }); + }); + + it("RANGE with multi-underscore value: only first underscore splits", () => { + // Magento's URL param `price=10_50_extra` is unusual but pinned + // to substring(0, idx) + substring(idx+1) which keeps the trailing + // underscore on the `to` side. + expect(transformFilterValueGraphQL("10_50_extra", "RANGE")).toEqual({ + from: "10", + to: "50_extra", + }); + }); +}); + +describe("filtersFromUrlGraphQL", () => { + it("picks up known filter keys from URL searchParams", () => { + const url = new URL("https://x.test/?sku=ABC&color=red&unknown=zzz"); + expect(filtersFromUrlGraphQL(url)).toEqual({ + sku: { eq: "ABC" }, + color: { eq: "red" }, + }); + }); + + it("handles RANGE filters (price)", () => { + const url = new URL("https://x.test/?price=10_50"); + expect(filtersFromUrlGraphQL(url)).toEqual({ + price: { from: "10", to: "50" }, + }); + }); + + it("handles MATCH filters (name, description)", () => { + const url = new URL("https://x.test/?name=foo+bar"); + expect(filtersFromUrlGraphQL(url)).toEqual({ + name: { match: "foo bar" }, + }); + }); + + it("layers customFilters on top of defaults", () => { + const url = new URL("https://x.test/?tag__phebo=fragrancia&sku=X"); + expect(filtersFromUrlGraphQL(url, [{ value: "tag__phebo", type: "EQUAL" }])).toEqual({ + tag__phebo: { eq: "fragrancia" }, + sku: { eq: "X" }, + }); + }); + + it("returns empty object when no filterable param is set", () => { + expect(filtersFromUrlGraphQL(new URL("https://x.test/?totally=other"))).toEqual({}); + }); +}); + +describe("filtersFromLoaderGraphQL", () => { + it("returns empty object when undefined", () => { + expect(filtersFromLoaderGraphQL(undefined)).toEqual({}); + }); + + it("collapses array of FilterProps into a keyed object", () => { + expect( + filtersFromLoaderGraphQL([ + { name: "sku", type: { eq: "A" } }, + { name: "color", type: { in: ["red", "blue"] } }, + ]), + ).toEqual({ + sku: { eq: "A" }, + color: { in: ["red", "blue"] }, + }); + }); +}); + +describe("transformFilterGraphQL — merge order", () => { + it("loader-derived filters override URL-derived ones on key collisions", () => { + // A section hard-coding `sale=true` should ignore any conflicting URL hint. + const url = new URL("https://x.test/?sale=false"); + expect( + transformFilterGraphQL(url, undefined, [{ name: "sale", type: { eq: "true" } }]), + ).toEqual({ + sale: { eq: "true" }, + }); + }); + + it("non-colliding sources merge cleanly", () => { + const url = new URL("https://x.test/?sku=ABC"); + expect( + transformFilterGraphQL(url, undefined, [{ name: "category_id", type: { eq: "42" } }]), + ).toEqual({ + sku: { eq: "ABC" }, + category_id: { eq: "42" }, + }); + }); +}); + +describe("formatUrlSuffix", () => { + it("strips a single leading slash", () => { + expect(formatUrlSuffix("/granado/")).toBe("granado/"); + }); + + it("appends trailing slash when missing", () => { + expect(formatUrlSuffix("granado")).toBe("granado/"); + }); + + it("leaves trailing slash alone", () => { + expect(formatUrlSuffix("granado/")).toBe("granado/"); + }); +}); + +describe("getCustomFields", () => { + it("returns undefined when active=false", () => { + expect(getCustomFields({ active: false }, ["a", "b"])).toBeUndefined(); + }); + + it("returns overrideList when present (ignores fallback)", () => { + expect(getCustomFields({ active: true, overrideList: ["x", "y"] }, ["a", "b"])).toEqual([ + "x", + "y", + ]); + }); + + it("falls back to provided customFields when overrideList is empty/absent", () => { + expect(getCustomFields({ active: true, overrideList: [] }, ["a", "b"])).toEqual(["a", "b"]); + expect(getCustomFields({ active: true }, ["a", "b"])).toEqual(["a", "b"]); + }); + + it("defaults config to {active:false} when omitted", () => { + expect(getCustomFields(undefined, ["a"])).toBeUndefined(); + }); +}); diff --git a/packages/apps-magento/src/__tests__/newsletter-subscribe.test.ts b/packages/apps-magento/src/__tests__/newsletter-subscribe.test.ts new file mode 100644 index 0000000..50f7769 --- /dev/null +++ b/packages/apps-magento/src/__tests__/newsletter-subscribe.test.ts @@ -0,0 +1,86 @@ +/** + * Tests for the newsletter/subscribe action. + * + * Parity with deco-cx/apps/magento/actions/newsletter/subscribe.ts: + * - POSTs to /rest/:site/V1/newsletter/subscribed + * - Body shape: { email, store_id: number } + * - storeId is coerced to number even if the CMS block holds it as string + * - Returns null on non-2xx or when `success === false`, the payload otherwise + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import subscribe from "../actions/newsletter/subscribe"; +import { configureMagento } from "../client"; + +function mockResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("newsletter/subscribe", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + configureMagento({ + baseUrl: "https://loja.example.com/", + apiKey: "secret", + storeId: 21, + site: "example", + }); + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("POSTs to the correct REST path with the encoded site", async () => { + fetchSpy.mockResolvedValue(mockResponse({ success: true, message: "ok" })); + await subscribe({ email: "a@b.com" }); + const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + expect(target.toString()).toBe( + "https://loja.example.com/rest/example/V1/newsletter/subscribed", + ); + expect(init.method).toBe("POST"); + }); + + it("sends { email, store_id (number) } as JSON body", async () => { + fetchSpy.mockResolvedValue(mockResponse({ success: true, message: "ok" })); + await subscribe({ email: "a@b.com" }); + const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ email: "a@b.com", store_id: 21 }); + }); + + it("coerces storeId to number even if config holds a string-shaped value", async () => { + // Some CMS blocks store storeId as a string. The prod loader did + // `Number(storeId)` defensively; we keep that behavior so the + // Magento backend never receives a string for an int field. + configureMagento({ + baseUrl: "https://loja.example.com/", + apiKey: "secret", + storeId: "42" as unknown as number, + site: "example", + }); + fetchSpy.mockResolvedValue(mockResponse({ success: true, message: "ok" })); + await subscribe({ email: "a@b.com" }); + const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + expect(JSON.parse(init.body as string).store_id).toBe(42); + }); + + it("returns the payload on success", async () => { + const payload = { success: true, message: "ok" }; + fetchSpy.mockResolvedValue(mockResponse(payload)); + expect(await subscribe({ email: "a@b.com" })).toEqual(payload); + }); + + it("returns null on success:false (Magento failure shape)", async () => { + fetchSpy.mockResolvedValue(mockResponse({ success: false, message: "no" })); + expect(await subscribe({ email: "a@b.com" })).toBeNull(); + }); + + it("returns null on non-2xx HTTP status", async () => { + fetchSpy.mockResolvedValue(mockResponse(null, 500)); + expect(await subscribe({ email: "a@b.com" })).toBeNull(); + }); +}); diff --git a/packages/apps-magento/src/__tests__/product-stockAlert.test.ts b/packages/apps-magento/src/__tests__/product-stockAlert.test.ts new file mode 100644 index 0000000..acd4a8a --- /dev/null +++ b/packages/apps-magento/src/__tests__/product-stockAlert.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for the product/stockAlert action. + * + * Parity with deco-cx/apps/magento/actions/product/stockAlert.ts: + * - Calls Magento's GraphQL endpoint at /graphql. + * - Sends operationName ProductStockAlert + variables {product_id, name, email}. + * - Returns { data: { productStockAlert } } on success. + * - Returns { error } on thrown exceptions / missing payload. + * + * The legacy code passed a STALE cache hint to clientGraphql.query but + * mutations are never cached server-side, so the TanStack port omits it + * — behavior is observationally identical from a consumer perspective. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import stockAlert from "../actions/product/stockAlert"; +import { configureMagento } from "../client"; + +function mockResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("product/stockAlert", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + configureMagento({ + baseUrl: "https://loja.example.com/", + apiKey: "secret", + storeId: 1, + site: "example", + }); + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("POSTs to /graphql with the ProductStockAlert mutation", async () => { + fetchSpy.mockResolvedValue( + mockResponse({ + data: { productStockAlert: { message: "ok", status: true } }, + }), + ); + await stockAlert({ product_id: 42, name: "Alice", email: "a@b.com" }); + const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + expect(target.toString()).toBe("https://loja.example.com/graphql"); + expect(init.method).toBe("POST"); + + const body = JSON.parse(init.body as string); + expect(body.operationName).toBe("ProductStockAlert"); + expect(body.variables).toEqual({ product_id: 42, name: "Alice", email: "a@b.com" }); + expect(body.query).toMatch(/mutation ProductStockAlert/); + }); + + it("returns { data: { productStockAlert } } on success", async () => { + fetchSpy.mockResolvedValue( + mockResponse({ + data: { productStockAlert: { message: "added", status: true } }, + }), + ); + const out = await stockAlert({ product_id: 1, name: "n", email: "e@e.com" }); + expect(out).toEqual({ data: { productStockAlert: { message: "added", status: true } } }); + }); + + it("returns { error } when the GraphQL response lacks productStockAlert", async () => { + fetchSpy.mockResolvedValue(mockResponse({ data: {} })); + const out = await stockAlert({ product_id: 1, name: "n", email: "e@e.com" }); + expect(out).toHaveProperty("error"); + }); + + it("returns { error } when fetch throws (network error)", async () => { + fetchSpy.mockRejectedValue(new Error("ECONNREFUSED")); + const out = await stockAlert({ product_id: 1, name: "n", email: "e@e.com" }); + expect(out).toEqual({ error: "ECONNREFUSED" }); + }); +}); diff --git a/packages/apps-magento/src/__tests__/stringifySearchCriteria.test.ts b/packages/apps-magento/src/__tests__/stringifySearchCriteria.test.ts new file mode 100644 index 0000000..834b02f --- /dev/null +++ b/packages/apps-magento/src/__tests__/stringifySearchCriteria.test.ts @@ -0,0 +1,56 @@ +/** + * Tests for stringifySearchCriteria. + * + * Parity goal with deco-cx/apps/magento/utils/stringifySearchCriteria.ts + * — the Fresh implementation produces query-string keys that Magento's + * REST API depends on byte-for-byte. These tests pin the exact bracket + * layout so the port can't silently drift from prod. + */ +import { describe, expect, it } from "vitest"; +import stringifySearchCriteria from "../utils/stringifySearchCriteria"; + +describe("stringifySearchCriteria", () => { + it("flattens a top-level scalar field into a bracketed key", () => { + expect( + stringifySearchCriteria({ + pageSize: 20, + }), + ).toEqual({ + "searchCriteria[pageSize]": 20, + }); + }); + + it("flattens nested objects with the [key] path style", () => { + expect( + stringifySearchCriteria({ + filterGroups: [ + { + filters: [{ field: "sku", value: "ABC" }], + }, + ], + }), + ).toEqual({ + "searchCriteria[filterGroups][0][filters][0][field]": "sku", + "searchCriteria[filterGroups][0][filters][0][value]": "ABC", + }); + }); + + it("handles multiple filterGroups (OR semantics in Magento)", () => { + const out = stringifySearchCriteria({ + filterGroups: [ + { filters: [{ field: "sku", value: "A" }] }, + { filters: [{ field: "sku", value: "B" }] }, + ], + }); + expect(out).toEqual({ + "searchCriteria[filterGroups][0][filters][0][field]": "sku", + "searchCriteria[filterGroups][0][filters][0][value]": "A", + "searchCriteria[filterGroups][1][filters][0][field]": "sku", + "searchCriteria[filterGroups][1][filters][0][value]": "B", + }); + }); + + it("returns an empty object for empty input", () => { + expect(stringifySearchCriteria({})).toEqual({}); + }); +}); diff --git a/packages/apps-magento/src/__tests__/transform.test.ts b/packages/apps-magento/src/__tests__/transform.test.ts new file mode 100644 index 0000000..006a97f --- /dev/null +++ b/packages/apps-magento/src/__tests__/transform.test.ts @@ -0,0 +1,346 @@ +/** + * Tests for utils/transform.ts — the schema.org mapping layer. + * + * Parity goals against deco-cx/apps/magento/utils/transform.ts: + * each helper is exercised against the same input shapes the legacy + * code handled and we assert on the same output shapes the storefront + * sections render against. The goal is to keep this file as a + * regression dam so future refactors don't silently shift output + * structure on real consumer sites. + */ +import { describe, expect, it } from "vitest"; +import type { MagentoProduct } from "../utils/client/types"; +import { toBreadcrumbList, toImages, toOffer, toProduct, toSeo, toURL } from "../utils/transform"; + +const baseProduct = (): MagentoProduct => ({ + id: 42, + sku: "SKU-42", + name: "Test Product", + price: 100, + status: 1, + visibility: 4, + type_id: "simple", + created_at: "", + updated_at: "", + weight: 0, + url: "https://loja.example.com/p/test", + extension_attributes: { + category_links: [], + stock_item: { + item_id: 1, + product_id: 42, + stock_id: 1, + is_in_stock: true, + qty: 7, + }, + }, + custom_attributes: [ + { attribute_code: "title", value: "Test Title" }, + { attribute_code: "meta_title", value: "Test Meta Title" }, + { attribute_code: "meta_description", value: "Test Meta Description" }, + ], + currency_code: "BRL", + price_info: { + final_price: 80, + max_price: 100, + max_regular_price: 100, + minimal_regular_price: 100, + special_price: null, + minimal_price: 80, + regular_price: 100, + formatted_prices: { + final_price: "R$ 80,00", + max_price: "R$ 100,00", + minimal_price: "R$ 80,00", + max_regular_price: "R$ 100,00", + minimal_regular_price: null, + special_price: null, + regular_price: "R$ 100,00", + }, + extension_attributes: { + msrp: { + msrp_price: "", + is_applicable: "", + is_shown_price_on_gesture: "", + msrp_message: "", + explanation_message: "", + }, + tax_adjustments: { + final_price: 80, + max_price: 100, + max_regular_price: 100, + minimal_regular_price: 100, + special_price: 80, + minimal_price: 80, + regular_price: 100, + formatted_prices: { + final_price: "R$ 80,00", + max_price: "R$ 100,00", + minimal_price: "R$ 80,00", + max_regular_price: "R$ 100,00", + minimal_regular_price: null, + special_price: "R$ 80,00", + regular_price: "R$ 100,00", + }, + }, + weee_attributes: [], + weee_adjustment: "", + }, + }, +}); + +describe("toURL", () => { + it("promotes protocol-relative URLs to https", () => { + expect(toURL("//cdn.example.com/img.jpg")).toBe("https://cdn.example.com/img.jpg"); + }); + + it("leaves https URLs untouched", () => { + expect(toURL("https://x.com/i.jpg")).toBe("https://x.com/i.jpg"); + }); + + it("leaves http URLs untouched (no upgrade)", () => { + // Prod only promotes the // form; http:// is passed through to match. + expect(toURL("http://x.com/i.jpg")).toBe("http://x.com/i.jpg"); + }); +}); + +describe("toSeo", () => { + it("prefers meta_title over title", () => { + const seo = toSeo( + [ + { attribute_code: "title", value: "A" }, + { attribute_code: "meta_title", value: "B" }, + { attribute_code: "meta_description", value: "D" }, + ], + "https://x.test/p", + ); + expect(seo).toEqual({ title: "B", description: "D", canonical: "https://x.test/p" }); + }); + + it("falls back to title when meta_title is absent", () => { + const seo = toSeo([{ attribute_code: "title", value: "A" }], "https://x.test/p"); + expect(seo.title).toBe("A"); + }); + + it("returns empty title/description when neither attr is present", () => { + const seo = toSeo([], "https://x.test/p"); + expect(seo).toEqual({ title: "", description: "", canonical: "https://x.test/p" }); + }); + + it("joins string-array attribute values with comma+space", () => { + const seo = toSeo( + [{ attribute_code: "meta_description", value: ["a", "b", "c"] }], + "https://x.test/p", + ); + expect(seo.description).toBe("a, b, c"); + }); +}); + +describe("toOffer", () => { + it("returns [] when price_info is absent", () => { + const p = baseProduct(); + p.price_info = undefined; + expect(toOffer(p, 30, 10)).toEqual([]); + }); + + it("maps InStock availability when stock_item.is_in_stock=true", () => { + const offers = toOffer(baseProduct(), 30, 10); + expect(offers[0].availability).toBe("https://schema.org/InStock"); + expect(offers[0].inventoryLevel).toEqual({ value: 7 }); + }); + + it("maps OutOfStock availability when stock_item.is_in_stock=false", () => { + const p = baseProduct(); + p.extension_attributes.stock_item!.is_in_stock = false; + const offers = toOffer(p, 30, 10); + expect(offers[0].availability).toBe("https://schema.org/OutOfStock"); + expect(offers[0].inventoryLevel).toEqual({ value: 0 }); + }); + + it("emits ListPrice + SalePrice in priceSpecification", () => { + const offers = toOffer(baseProduct(), 30, 10); + const types = (offers[0].priceSpecification ?? []).map((s: any) => s.priceType); + expect(types).toContain("https://schema.org/ListPrice"); + expect(types).toContain("https://schema.org/SalePrice"); + }); + + it("calculates installments capped by maxInstallments AND minInstallmentValue", () => { + // finalPrice 80, minInstallmentValue 30 → floor(80/30) = 2 possible + // maxInstallments 10 → bounded by 2 + const offers = toOffer(baseProduct(), 30, 10); + const installments = (offers[0].priceSpecification ?? []).filter( + (s: any) => s.priceComponentType === "https://schema.org/Installment", + ); + expect(installments.length).toBe(2); + expect((installments[0] as any).description).toBe("À vista"); + expect((installments[1] as any).description).toBe("2x sem juros"); + }); + + it("always emits at least 1 installment (À vista) when finalPrice < minInstallmentValue", () => { + // finalPrice 80, minInstallmentValue 200 → floor = 0 → || 1 + const offers = toOffer(baseProduct(), 200, 10); + const installments = (offers[0].priceSpecification ?? []).filter( + (s: any) => s.priceComponentType === "https://schema.org/Installment", + ); + expect(installments.length).toBe(1); + expect((installments[0] as any).description).toBe("À vista"); + }); +}); + +describe("toImages", () => { + it("maps media_gallery_entries when imagesUrl is provided", () => { + const p = baseProduct(); + p.media_gallery_entries = [ + { + id: 1, + media_type: "image", + label: null, + position: 1, + disabled: false, + types: [], + file: "/x/y.jpg", + }, + ]; + const imgs = toImages(p, "https://cdn.example/media"); + expect(imgs).toEqual([ + { + "@type": "ImageObject", + encodingFormat: "image", + alternateName: "/x/y.jpg", + url: "https://cdn.example/media/x/y.jpg", + disabled: false, + }, + ]); + }); + + it("falls back to `images` when imagesUrl is empty", () => { + const p = baseProduct(); + p.images = [ + { + url: "https://x.test/i.jpg", + code: "image", + height: 1, + width: 1, + label: "x", + resized_width: 1, + resized_height: 1, + disabled: false, + }, + ]; + const imgs = toImages(p, ""); + expect(imgs?.[0]).toMatchObject({ url: "https://x.test/i.jpg", alternateName: "x" }); + }); + + it("promotes // URLs to https via toURL inside the prefix", () => { + const p = baseProduct(); + p.media_gallery_entries = [ + { + id: 1, + media_type: "image", + label: null, + position: 1, + disabled: false, + types: [], + file: "/x.jpg", + }, + ]; + const imgs = toImages(p, "//cdn.example/m"); + expect(imgs?.[0].url).toBe("https://cdn.example/m/x.jpg"); + }); +}); + +describe("toBreadcrumbList", () => { + const PRODUCT_URL = new URL("https://x.test/produto/abc"); + + it("returns a single ListItem with the product name when categories=[] and flag=true", () => { + const out = toBreadcrumbList( + [], + true, + { "@type": "Product", productID: "1", sku: "1", name: "Foo" } as any, + PRODUCT_URL, + ); + expect(out).toEqual([ + { + "@type": "ListItem", + name: "Foo", + position: 1, + item: "https://x.test/Foo", + }, + ]); + }); + + it("maps each valid category to a ListItem ordered by position", () => { + const out = toBreadcrumbList( + [{ id: 1, name: "A", position: 1 } as any, { id: 2, name: "B", position: 2 } as any], + false, + { name: "Foo" } as any, + PRODUCT_URL, + ); + expect(out).toEqual([ + { "@type": "ListItem", name: "A", position: 1, item: "https://x.test/A" }, + { "@type": "ListItem", name: "B", position: 2, item: "https://x.test/B" }, + ]); + }); + + it("filters out null/empty-name/zero-position categories", () => { + const out = toBreadcrumbList( + [ + null, + { id: 1, name: "", position: 1 } as any, + { id: 2, name: "B", position: 0 } as any, + { id: 3, name: "C", position: 3 } as any, + ], + false, + { name: "Foo" } as any, + PRODUCT_URL, + ); + expect(out).toEqual([ + { "@type": "ListItem", name: "C", position: 3, item: "https://x.test/C" }, + ]); + }); +}); + +describe("toProduct", () => { + it("maps a base product into a schema.org Product with offers, image, additionalProperty", () => { + const out = toProduct({ + product: baseProduct(), + options: { + currencyCode: "BRL", + imagesUrl: "", + maxInstallments: 10, + minInstallmentValue: 30, + }, + }); + + expect(out["@type"]).toBe("Product"); + expect(out.productID).toBe("42"); + expect(out.sku).toBe("SKU-42"); + expect(out.name).toBe("Test Product"); + expect(out.url).toBe("https://loja.example.com/p/test"); + expect((out.offers as any).highPrice).toBe(100); + expect((out.offers as any).lowPrice).toBe(80); + expect(out.additionalProperty?.length).toBe(3); + }); + + it("wraps the product in isVariantOf with the same productID", () => { + const out = toProduct({ + product: baseProduct(), + options: { maxInstallments: 10, minInstallmentValue: 30 }, + }); + expect(out.isVariantOf?.productGroupID).toBe("42"); + expect(out.isVariantOf?.hasVariant?.[0].productID).toBe("42"); + }); + + it("falls back to product.price when price_info is absent for highPrice/lowPrice", () => { + const p = baseProduct(); + p.price_info = undefined; + const out = toProduct({ + product: p, + options: { maxInstallments: 10, minInstallmentValue: 30 }, + }); + expect((out.offers as any).highPrice).toBe(100); + expect((out.offers as any).lowPrice).toBe(100); + // And offers array is empty when price_info is absent (toOffer returns []) + expect((out.offers as any).offerCount).toBe(0); + }); +}); diff --git a/packages/apps-magento/src/__tests__/user-loader.test.ts b/packages/apps-magento/src/__tests__/user-loader.test.ts new file mode 100644 index 0000000..2226a88 --- /dev/null +++ b/packages/apps-magento/src/__tests__/user-loader.test.ts @@ -0,0 +1,134 @@ +/** + * Tests for the user loader. + * + * Parity goals against deco-cx/apps/magento/loaders/user.ts: + * - Returns null when no PHPSESSID cookie is present (anonymous). + * - Calls /customer/section/load?sections=customer,carbono-customer + * with the Cookie header set from PHPSESSID. + * - Maps the bundle into schema.org Person { @id, email, givenName, + * familyName? }. + * - Derives familyName by stripping firstname from fullname (matches + * prod's `fullname.replace(firstname, "").trim()`). + * - Returns null when carbono-customer.data_id is missing OR when + * customer slice is absent. + * - Swallows fetch errors and returns null (the storefront re-renders + * the logged-out UI rather than crashing). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { configureMagento } from "../client"; +import user from "../loaders/user"; + +function mockResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function requestWithCookie(cookie: string): Request { + const r = new Request("http://localhost/"); + const headers = new Headers(); + headers.set("cookie", cookie); + Object.defineProperty(r, "headers", { value: headers, configurable: true }); + return r; +} + +describe("user loader", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + configureMagento({ + baseUrl: "https://loja.example.com/", + apiKey: "secret", + storeId: 1, + site: "example", + }); + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns null when no PHPSESSID cookie is present", async () => { + const result = await user(null, new Request("http://localhost/")); + expect(result).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("requests /customer/section/load with the Cookie header", async () => { + fetchSpy.mockResolvedValue( + mockResponse({ + customer: { data_id: 1, fullname: "Alice Doe", firstname: "Alice" }, + "carbono-customer": { data_id: 1, customerId: "c1", email: "a@b.com" }, + }), + ); + await user(null, requestWithCookie("PHPSESSID=abc")); + const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + expect(target.toString()).toBe( + "https://loja.example.com/example/customer/section/load?sections=customer,carbono-customer", + ); + const cookieHeader = new Headers(init.headers).get("cookie"); + expect(cookieHeader).toBe("PHPSESSID=abc"); + }); + + it("maps a happy-path bundle into a schema.org Person", async () => { + fetchSpy.mockResolvedValue( + mockResponse({ + customer: { data_id: 1, fullname: "Alice Doe", firstname: "Alice" }, + "carbono-customer": { data_id: 1, customerId: "c1", email: "a@b.com" }, + }), + ); + const out = await user(null, requestWithCookie("PHPSESSID=abc")); + expect(out).toEqual({ + "@id": "c1", + email: "a@b.com", + givenName: "Alice", + familyName: "Doe", + }); + }); + + it("omits familyName when fullname is just firstname (no surname)", async () => { + fetchSpy.mockResolvedValue( + mockResponse({ + customer: { data_id: 1, fullname: "Alice", firstname: "Alice" }, + "carbono-customer": { data_id: 1, customerId: "c1", email: "a@b.com" }, + }), + ); + const out = await user(null, requestWithCookie("PHPSESSID=abc")); + expect(out).toMatchObject({ givenName: "Alice" }); + // Magento returns fullname=firstname; prod's `replace(firstname, "").trim()` + // yields "" — we include the key with an empty string. Test the + // surface, not the empty-vs-missing detail. + expect(out?.familyName ?? "").toBe(""); + }); + + it("returns null when carbono-customer.data_id is missing", async () => { + fetchSpy.mockResolvedValue( + mockResponse({ + customer: { data_id: 1, fullname: "Alice Doe", firstname: "Alice" }, + "carbono-customer": { customerId: "c1", email: "a@b.com" }, // no data_id + }), + ); + expect(await user(null, requestWithCookie("PHPSESSID=abc"))).toBeNull(); + }); + + it("returns null when customer slice is absent", async () => { + fetchSpy.mockResolvedValue( + mockResponse({ + "carbono-customer": { data_id: 1, customerId: "c1", email: "a@b.com" }, + }), + ); + expect(await user(null, requestWithCookie("PHPSESSID=abc"))).toBeNull(); + }); + + it("returns null when fetch throws", async () => { + fetchSpy.mockRejectedValue(new Error("ECONNREFUSED")); + expect(await user(null, requestWithCookie("PHPSESSID=abc"))).toBeNull(); + }); + + it("returns null on non-2xx response", async () => { + fetchSpy.mockResolvedValue(mockResponse(null, 500)); + expect(await user(null, requestWithCookie("PHPSESSID=abc"))).toBeNull(); + }); +}); diff --git a/packages/apps-magento/src/__tests__/wishlist-actions.test.ts b/packages/apps-magento/src/__tests__/wishlist-actions.test.ts new file mode 100644 index 0000000..d61ccb9 --- /dev/null +++ b/packages/apps-magento/src/__tests__/wishlist-actions.test.ts @@ -0,0 +1,140 @@ +/** + * Tests for the wishlist add/remove actions. + * + * Parity goals against deco-cx/apps/magento/actions/wishlist/{addItem,removeItem}: + * - Both require PHPSESSID + form_key cookies; return null when either absent. + * - addItem POSTs FormData {product, form_key} to /wishlist/index/add/. + * - removeItem POSTs FormData {item, uenc:"", form_key} to /wishlist/index/remove/. + * - On success ({success:true}) both delegate to the wishlist loader to + * fetch the refreshed list. + * - Failure / thrown / success:false → null. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import addItem from "../actions/wishlist/addItem"; +import removeItem from "../actions/wishlist/removeItem"; +import { configureMagento } from "../client"; + +function mockResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function requestWithCookies(cookieHeader: string): Request { + const r = new Request("http://localhost/"); + const headers = new Headers(); + headers.set("cookie", cookieHeader); + Object.defineProperty(r, "headers", { value: headers, configurable: true }); + return r; +} + +const AUTHED = "PHPSESSID=abc; form_key=xyz"; + +describe("wishlist/addItem", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + configureMagento({ + baseUrl: "https://loja.example.com/", + apiKey: "secret", + storeId: 1, + site: "example", + }); + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("returns null when PHPSESSID is missing", async () => { + expect(await addItem({ productId: "1" }, requestWithCookies("form_key=xyz"))).toBeNull(); + }); + + it("returns null when form_key is missing", async () => { + expect(await addItem({ productId: "1" }, requestWithCookies("PHPSESSID=abc"))).toBeNull(); + }); + + it("POSTs FormData {product, form_key} to /wishlist/index/add/", async () => { + fetchSpy + .mockResolvedValueOnce(mockResponse({ success: true })) // POST add + .mockResolvedValueOnce( + mockResponse({ + wishlist: { counter: "1", items: [], counter_number: 1, data_id: 1 }, + }), + ); // wishlist loader fetch + + await addItem({ productId: "p42" }, requestWithCookies(AUTHED)); + + const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + expect(target.toString()).toBe("https://loja.example.com/example/wishlist/index/add/"); + expect(init.method).toBe("POST"); + + const fd = init.body as FormData; + expect(fd.get("product")).toBe("p42"); + expect(fd.get("form_key")).toBe("xyz"); + }); + + it("delegates to the wishlist loader on success", async () => { + const wl = { counter: "1", items: [], counter_number: 1, data_id: 1 }; + fetchSpy + .mockResolvedValueOnce(mockResponse({ success: true })) + .mockResolvedValueOnce(mockResponse({ wishlist: wl })); + + expect(await addItem({ productId: "p42" }, requestWithCookies(AUTHED))).toEqual(wl); + }); + + it("returns null when Magento responds with success:false", async () => { + fetchSpy.mockResolvedValueOnce(mockResponse({ success: false })); + expect(await addItem({ productId: "p42" }, requestWithCookies(AUTHED))).toBeNull(); + }); + + it("returns null on a thrown fetch", async () => { + fetchSpy.mockRejectedValueOnce(new Error("boom")); + expect(await addItem({ productId: "p42" }, requestWithCookies(AUTHED))).toBeNull(); + }); +}); + +describe("wishlist/removeItem", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + configureMagento({ + baseUrl: "https://loja.example.com/", + apiKey: "secret", + storeId: 1, + site: "example", + }); + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("POSTs FormData {item, uenc:'', form_key} to /wishlist/index/remove/", async () => { + fetchSpy.mockResolvedValueOnce(mockResponse({ success: true })).mockResolvedValueOnce( + mockResponse({ + wishlist: { counter: "0", items: [], counter_number: 0, data_id: 1 }, + }), + ); + + await removeItem({ productId: "row-42" }, requestWithCookies(AUTHED)); + + const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit]; + expect(target.toString()).toBe("https://loja.example.com/example/wishlist/index/remove/"); + const fd = init.body as FormData; + expect(fd.get("item")).toBe("row-42"); + expect(fd.get("uenc")).toBe(""); + expect(fd.get("form_key")).toBe("xyz"); + }); + + it("returns the refreshed wishlist on success", async () => { + const wl = { counter: "0", items: [], counter_number: 0, data_id: 1 }; + fetchSpy + .mockResolvedValueOnce(mockResponse({ success: true })) + .mockResolvedValueOnce(mockResponse({ wishlist: wl })); + expect(await removeItem({ productId: "row-42" }, requestWithCookies(AUTHED))).toEqual(wl); + }); + + it("returns null when PHPSESSID is missing", async () => { + expect(await removeItem({ productId: "x" }, requestWithCookies("form_key=xyz"))).toBeNull(); + }); +}); diff --git a/packages/apps-magento/src/__tests__/wishlist-loader.test.ts b/packages/apps-magento/src/__tests__/wishlist-loader.test.ts new file mode 100644 index 0000000..6ffa6eb --- /dev/null +++ b/packages/apps-magento/src/__tests__/wishlist-loader.test.ts @@ -0,0 +1,97 @@ +/** + * Tests for the wishlist loader. + * + * Parity goals against deco-cx/apps/magento/loaders/wishlist.ts: + * - Returns null when no PHPSESSID (anonymous). + * - Hits /customer/section/load?sections=wishlist with the Cookie header. + * - Returns the wishlist payload directly when present. + * - Returns null when the bundle has no wishlist slice. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { configureMagento } from "../client"; +import wishlist from "../loaders/wishlist"; + +function mockResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function requestWithCookie(cookie: string): Request { + const r = new Request("http://localhost/"); + const headers = new Headers(); + headers.set("cookie", cookie); + Object.defineProperty(r, "headers", { value: headers, configurable: true }); + return r; +} + +describe("wishlist loader", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + configureMagento({ + baseUrl: "https://loja.example.com/", + apiKey: "secret", + storeId: 1, + site: "example", + }); + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns null when no PHPSESSID cookie is present", async () => { + expect(await wishlist(null, new Request("http://localhost/"))).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("requests /customer/section/load?sections=wishlist", async () => { + fetchSpy.mockResolvedValue( + mockResponse({ + wishlist: { counter: "0", items: [], counter_number: 0, data_id: 1 }, + }), + ); + await wishlist(null, requestWithCookie("PHPSESSID=abc")); + const [target] = fetchSpy.mock.calls[0] as [URL]; + expect(target.toString()).toBe( + "https://loja.example.com/example/customer/section/load?sections=wishlist", + ); + }); + + it("returns the wishlist payload on success", async () => { + const wl = { + counter: "1", + counter_number: 1, + data_id: 1, + items: [ + { + image: { template: "", src: "", width: 0, height: 0, alt: "" }, + product_sku: "ABC", + product_id: "1", + product_url: "", + product_name: "n", + product_price: "10", + product_is_saleable_and_visible: true, + product_has_required_options: false, + add_to_cart_params: "", + delete_item_params: "", + }, + ], + }; + fetchSpy.mockResolvedValue(mockResponse({ wishlist: wl })); + expect(await wishlist(null, requestWithCookie("PHPSESSID=abc"))).toEqual(wl); + }); + + it("returns null when bundle lacks a wishlist slice", async () => { + fetchSpy.mockResolvedValue(mockResponse({})); + expect(await wishlist(null, requestWithCookie("PHPSESSID=abc"))).toBeNull(); + }); + + it("returns null on non-2xx response", async () => { + fetchSpy.mockResolvedValue(mockResponse(null, 500)); + expect(await wishlist(null, requestWithCookie("PHPSESSID=abc"))).toBeNull(); + }); +}); diff --git a/packages/apps-magento/src/actions/newsletter/subscribe.ts b/packages/apps-magento/src/actions/newsletter/subscribe.ts new file mode 100644 index 0000000..a09cf67 --- /dev/null +++ b/packages/apps-magento/src/actions/newsletter/subscribe.ts @@ -0,0 +1,40 @@ +/** + * Magento newsletter subscribe — POST a customer email to the + * `/V1/newsletter/subscribed` REST endpoint. + * + * Verbatim port of `deco-cx/apps/magento/actions/newsletter/subscribe.ts`: + * the legacy version took `(props, _req, ctx)` and pulled `storeId` / + * `clientAdmin` / `site` off the App() context. The TanStack/Node port + * reads the same fields from `getMagentoConfig()` and uses + * `magentoFetch` so auth/origin/Referer headers stay aligned with the + * rest of the magento app. Endpoint shape and request body are + * unchanged so the Magento backend doesn't need re-tuning. + */ +import { getMagentoConfig, magentoFetch } from "../../client"; +import type { NewsletterData } from "../../types"; + +export interface SubscribeProps { + /** + * @title Email + */ + email: string; +} + +export default async function subscribe(props: SubscribeProps): Promise { + const { site, storeId } = getMagentoConfig(); + const path = `/rest/${encodeURIComponent(site)}/V1/newsletter/subscribed`; + + const res = await magentoFetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: props.email, + store_id: Number(storeId), + }), + }); + + if (!res.ok) return null; + const result = (await res.json()) as NewsletterData | { success: false }; + if (!result || (result as NewsletterData).success === false) return null; + return result as NewsletterData; +} diff --git a/packages/apps-magento/src/actions/product/stockAlert.ts b/packages/apps-magento/src/actions/product/stockAlert.ts new file mode 100644 index 0000000..e2e5af2 --- /dev/null +++ b/packages/apps-magento/src/actions/product/stockAlert.ts @@ -0,0 +1,67 @@ +/** + * Magento product stock-alert subscribe — fires a GraphQL mutation so + * the customer gets notified when an out-of-stock SKU is replenished. + * + * Verbatim port of `deco-cx/apps/magento/actions/product/stockAlert.ts`. + * The Fresh version called `ctx.clientGraphql.query(...)` with a third + * `STALE` parameter that opted into a 1h SWR cache. The TanStack/Node + * port uses `magentoFetch` against `/graphql` directly; STALE was a + * cache hint only — the mutation is a write so no caching applies and + * we can drop the parameter (the original passed it but mutations are + * never cached server-side, so the behavior is identical). + * + * Response shape preserved: returns `{ data: { productStockAlert } }` + * on success or `{ error: string }` on failure. + */ +import { getMagentoConfig, magentoFetch } from "../../client"; +import type { ProductStockAlertResponse } from "../../types"; + +export interface StockAlertProps { + product_id: number; + name: string; + email: string; +} + +const MUTATION = `mutation ProductStockAlert($product_id: Int!, $name: String!, $email: String!) { + productStockAlert( + product_id: $product_id + name: $name + email: $email + ) { + message + status + } +}`; + +export default async function stockAlert( + props: StockAlertProps, +): Promise { + const { product_id, name, email } = props; + const { baseUrl } = getMagentoConfig(); + + try { + const res = await magentoFetch(`${baseUrl.replace(/\/$/, "")}/graphql`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + operationName: "ProductStockAlert", + variables: { product_id, name, email }, + query: MUTATION, + }), + }); + + const json = (await res.json()) as { + data?: { productStockAlert: { message: string; status: boolean } }; + }; + + if (!json.data?.productStockAlert) { + return { error: "productStockAlert payload missing in GraphQL response" }; + } + + return { data: { productStockAlert: json.data.productStockAlert } }; + } catch (error) { + return { + error: error instanceof Error ? error.message : "Erro desconhecido", + }; + } +} diff --git a/packages/apps-magento/src/actions/wishlist/addItem.ts b/packages/apps-magento/src/actions/wishlist/addItem.ts new file mode 100644 index 0000000..12d93a8 --- /dev/null +++ b/packages/apps-magento/src/actions/wishlist/addItem.ts @@ -0,0 +1,57 @@ +/** + * Adds a product to the customer's wishlist and returns the refreshed + * wishlist on success (or null on failure). + * + * Ported from `deco-cx/apps/magento/actions/wishlist/addItem.ts`. The + * Fresh version posted multipart FormData with `product` + `form_key` + * to `/wishlist/index/add/` and on success delegated to + * wishlistLoader to fetch the updated state. Same flow here — but the + * wishlistLoader is imported from the upstream port instead of the + * legacy in-site path. + */ +import { getCookies } from "@decocms/blocks/sdk/cookie"; +import { getMagentoConfig, magentoFetch } from "../../client"; +import wishlistLoader from "../../loaders/wishlist"; +import type { Wishlist } from "../../utils/client/types"; +import { FORM_KEY_COOKIE, SESSION_COOKIE } from "../../utils/constants"; +import { getUserCookie } from "../../utils/user"; + +export interface AddWishlistItemProps { + productId: string; +} + +export default async function addItem( + { productId }: AddWishlistItemProps, + req: Request, +): Promise { + try { + const sessionId = getUserCookie(req.headers); + if (!sessionId) return null; + + const cookies = getCookies(req.headers); + const formKey = cookies[FORM_KEY_COOKIE]; + if (!formKey) return null; + + const { site } = getMagentoConfig(); + const body = new FormData(); + body.append("product", productId); + body.append("form_key", formKey); + + const res = await magentoFetch(`/${encodeURIComponent(site)}/wishlist/index/add/`, { + method: "POST", + headers: { + Cookie: `${SESSION_COOKIE}=${sessionId}`, + "x-requested-with": "XMLHttpRequest", + }, + body, + }); + + if (!res.ok) return null; + const { success } = (await res.json()) as { success?: boolean }; + if (!success) return null; + + return wishlistLoader(null, req); + } catch { + return null; + } +} diff --git a/packages/apps-magento/src/actions/wishlist/removeItem.ts b/packages/apps-magento/src/actions/wishlist/removeItem.ts new file mode 100644 index 0000000..a0ebdc6 --- /dev/null +++ b/packages/apps-magento/src/actions/wishlist/removeItem.ts @@ -0,0 +1,59 @@ +/** + * Removes a wishlist item by its Magento item id and returns the + * refreshed wishlist on success (or null on failure). + * + * Ported from `deco-cx/apps/magento/actions/wishlist/removeItem.ts`. + * The legacy code also passed an empty `uenc=""` form param — kept + * here byte-for-byte because Magento's wishlist controller treats the + * absence of `uenc` differently from an empty value and we don't want + * a silent behavior shift. + */ +import { getCookies } from "@decocms/blocks/sdk/cookie"; +import { getMagentoConfig, magentoFetch } from "../../client"; +import wishlistLoader from "../../loaders/wishlist"; +import type { Wishlist } from "../../utils/client/types"; +import { FORM_KEY_COOKIE, SESSION_COOKIE } from "../../utils/constants"; +import { getUserCookie } from "../../utils/user"; + +export interface RemoveWishlistItemProps { + /** Magento wishlist item id (NOT the product id — the wishlist row id) */ + productId: string; +} + +export default async function removeItem( + { productId }: RemoveWishlistItemProps, + req: Request, +): Promise { + try { + const sessionId = getUserCookie(req.headers); + if (!sessionId) return null; + + const cookies = getCookies(req.headers); + const formKey = cookies[FORM_KEY_COOKIE]; + if (!formKey) return null; + + const { site } = getMagentoConfig(); + const body = new FormData(); + body.append("item", productId); + body.append("uenc", ""); + body.append("form_key", formKey); + + const res = await magentoFetch(`/${encodeURIComponent(site)}/wishlist/index/remove/`, { + method: "POST", + headers: { + Cookie: `${SESSION_COOKIE}=${sessionId}`, + "x-requested-with": "XMLHttpRequest", + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + + if (!res.ok) return null; + const { success } = (await res.json()) as { success?: boolean }; + if (!success) return null; + + return wishlistLoader(null, req); + } catch { + return null; + } +} diff --git a/packages/apps-magento/src/client.ts b/packages/apps-magento/src/client.ts new file mode 100644 index 0000000..f811592 --- /dev/null +++ b/packages/apps-magento/src/client.ts @@ -0,0 +1,228 @@ +/** + * Magento API client config — module-global, set once at app boot. + * + * Mirrors `vtex/client.ts`'s configureVtex/getVtexConfig pattern so the + * same wiring contract works across commerce apps. Sites should call + * `configureMagento(...)` once from their setup phase before any + * loader/action runs; loaders consume `getMagentoConfig()` to pick up + * baseUrl, auth, and feature toggles. + * + * Two reasons we don't pass config explicitly to every loader: + * 1. CMS-resolved loader instances don't know where the config block + * lives; the site's `initMagentoFromBlocks(blocks)` adapter is the + * single source of truth. + * 2. Matches the rest of @decocms/apps so a site touching VTEX and + * Magento has consistent muscle memory. + */ + +// --------------------------------------------------------------------------- +// Config shapes +// --------------------------------------------------------------------------- + +/** + * URL-search-param filter mapping consumed by GraphQL product loaders. + * Each entry pairs a Magento attribute slug (`value`) with the + * comparison operator the storefront's URL filters use (`type`). The + * default mapping lives in `utils/constants.ts:DEFAULT_GRAPHQL_FILTERS`; + * sites extend it via the `customFilters` prop on PLP/list loaders. + */ +export interface FiltersGraphQL { + value: string; + type: "EQUAL" | "MATCH" | "RANGE"; +} + +export interface MagentoFeatures { + dangerouslyDisableWishlist?: boolean; + dangerouslyDisableOnLoadUpdate?: boolean; + dangerouslyReturnNullAfterAction?: boolean; + dangerouslyDontReturnCartAfterAction?: boolean; + dangerouslyDisableOnVisibilityChangeUpdate?: boolean; +} + +export interface MagentoImagesConfig { + imagesQtd: number; + imagesUrl: string; +} + +export interface MagentoPricingConfig { + maxInstallments: number; + minInstallmentValue: number; +} + +export interface MagentoCartConfigs { + countProductImageInCart?: number; + changeCardIdAfterCheckout?: boolean; + cartErrorMessages?: string[]; +} + +export interface MagentoConfig { + /** Magento storefront base URL, e.g. `https://loja.granado.com.br/` */ + baseUrl: string; + /** Bearer token for `Authorization` header on admin REST calls */ + apiKey: string; + /** Store ID used in headers + path prefixes */ + storeId: number; + /** Site/site-code used in path segments */ + site: string; + /** Which Store header to send (default: "site") */ + storeHeader?: string; + /** Optional opaque header value sent as `x-origin-header` */ + originHeader?: string; + /** Currency code (e.g. "BRL") */ + currencyCode?: string; + /** Whether to append `_suffix` to admin endpoints */ + useSuffix?: boolean; + /** Behavior toggles surfaced to client hooks */ + features?: MagentoFeatures; + /** Cart-specific tunables */ + cartConfigs?: MagentoCartConfigs; + /** Images CDN config */ + imagesConfig?: MagentoImagesConfig; + /** Pricing rules for installments display */ + pricingConfig?: MagentoPricingConfig; +} + +// --------------------------------------------------------------------------- +// Module-global state +// --------------------------------------------------------------------------- + +let config: MagentoConfig | null = null; + +export function configureMagento(c: MagentoConfig): void { + config = c; +} + +export function getMagentoConfig(): MagentoConfig { + if (!config) { + throw new Error( + "[Magento] configureMagento() must be called before loaders run. " + + "Wire it in your site's setup, e.g. configureMagento(blocks.magento).", + ); + } + return config; +} + +/** + * Best-effort init from a CMS block — mirrors `initVtexFromBlocks`. + * + * Resolves secret references stored in the CMS block (`apiKey`, + * `originHeader`) in this priority: + * 1. Plain string (dev override) + * 2. `{ get: () => string }` object (legacy) + * 3. `{ encrypted: "" }` decrypted via `DECO_CRYPTO_KEY` (prod) + * 4. `{ name: "ENV_VAR" }` → `process.env[name]` (fallback) + * + * (3) is what the production Deco CMS actually stores — admin + * encrypts the secret with the site's `DECO_CRYPTO_KEY` so the value + * never leaves the worker in plain text. Previously this init only + * read `process.env[name]`, which silently produced `apiKey: ""` for + * any site that hadn't *also* set the named env var as a CF Worker + * secret. Result: `Authorization: Bearer ` header missing on every + * request → Magento 401 → minicart/cart-related loaders dead. The + * shared `resolveSecret` helper from `@decocms/blocks/sdk/crypto` + * handles the full chain, matching how VTEX and Shopify configure + * themselves. + * + * Because the AES-CBC decrypt step is async, this function is now + * `Promise` — site setups must `await` the call before any + * loader fires. + */ +export async function initMagentoFromBlocks(blocks: Record): Promise { + // Lazy-imported to keep `@decocms/blocks/sdk/crypto` out of the + // import graph for sites that wire Magento manually via + // `configureMagento({ apiKey: "..." })` without ever calling this + // helper (e.g. unit tests, CLI tools). + const { resolveSecret } = await import("@decocms/blocks/sdk/crypto"); + + const block = blocks.magento as Record | undefined; + if (!block) { + console.warn("[Magento] No `magento` block found in CMS; skipping init."); + return; + } + + const apiConfig = block.apiConfig ?? {}; + + // The env-var fallback names match the Secret block's `name` field + // when present. `resolveSecret` cycles through the chain documented + // above; an empty string here means every layer was empty, which we + // pass through verbatim so `buildHeaders` can detect it. + const extractEnvName = (value: unknown): string => { + if (value && typeof value === "object") { + const name = (value as { name?: unknown }).name; + if (typeof name === "string") return name; + } + return ""; + }; + const apiKeyEnvName = extractEnvName(apiConfig.apiKey); + const originHeaderEnvName = extractEnvName(apiConfig.originHeader); + + const apiKey = (await resolveSecret(apiConfig.apiKey, apiKeyEnvName)) ?? ""; + const originHeader = (await resolveSecret(apiConfig.originHeader, originHeaderEnvName)) ?? ""; + + configureMagento({ + baseUrl: apiConfig.baseUrl ?? "", + apiKey, + storeId: apiConfig.storeId ?? 1, + site: apiConfig.site ?? "", + storeHeader: apiConfig.storeHeader, + originHeader, + currencyCode: apiConfig.currencyCode, + useSuffix: apiConfig.useSuffix, + features: block.features, + cartConfigs: block.cartConfigs, + imagesConfig: block.imagesConfig, + pricingConfig: block.pricingConfig, + }); +} + +// --------------------------------------------------------------------------- +// HTTP helpers (thin wrappers over fetch with auth pre-applied) +// --------------------------------------------------------------------------- + +export interface MagentoFetchOpts extends RequestInit { + /** Whether to attach the admin Bearer token. Default true. */ + authenticated?: boolean; +} + +function buildHeaders( + opts: MagentoFetchOpts, + c: MagentoConfig, + attachMagentoIdentity: boolean, +): Headers { + const headers = new Headers(opts.headers ?? {}); + // `attachMagentoIdentity` is false when the request is going to a host + // that isn't the configured Magento backend. None of the Magento-only + // headers below should leak in that case: the Bearer is privileged, the + // `x-origin-header` is a secret that third parties shouldn't see, and a + // forced `Referer` would broadcast our Magento storefront URL. + if (!attachMagentoIdentity) return headers; + + if (opts.authenticated !== false && c.apiKey) { + headers.set("Authorization", `Bearer ${c.apiKey}`); + } + if (c.originHeader) { + headers.set("x-origin-header", c.originHeader); + } + if (!headers.has("Referer")) { + headers.set("Referer", c.baseUrl); + } + return headers; +} + +export function magentoFetch(path: string, opts: MagentoFetchOpts = {}): Promise { + const c = getMagentoConfig(); + const baseUrl = new URL(c.baseUrl); + const target = path.startsWith("http") + ? new URL(path) + : new URL(path.startsWith("/") ? path : `/${path}`, baseUrl); + + // Only attach Magento identity (Bearer, x-origin-header, forced Referer) + // when the request is going to the configured Magento host. An absolute + // URL to a different origin would otherwise leak the admin token *and* + // our origin/Referer secrets to that third party. Callers that genuinely + // want a third-party call must still pass `authenticated: false` for + // clarity at the call site. + const sameOrigin = target.origin === baseUrl.origin; + + return fetch(target, { ...opts, headers: buildHeaders(opts, c, sameOrigin) }); +} diff --git a/packages/apps-magento/src/index.ts b/packages/apps-magento/src/index.ts new file mode 100644 index 0000000..deaf4fe --- /dev/null +++ b/packages/apps-magento/src/index.ts @@ -0,0 +1,11 @@ +/** + * Magento app entry point for @decocms/apps. + * Re-exports client config + initializer. + * + * For actions/loaders/utils, use sub-path imports: + * import { features } from "@decocms/apps/magento/loaders/features" + * import { cart } from "@decocms/apps/magento/loaders/cart" + * import { magentoFetch } from "@decocms/apps/magento/client" + */ +export * from "./client"; +export type { MagentoCart } from "./types"; diff --git a/packages/apps-magento/src/loaders/cart.ts b/packages/apps-magento/src/loaders/cart.ts new file mode 100644 index 0000000..5e60fde --- /dev/null +++ b/packages/apps-magento/src/loaders/cart.ts @@ -0,0 +1,64 @@ +/** + * Magento cart loader — fetches the customer's active cart by cookie. + * + * Reads the `dataservices_cart_id` cookie from the request, calls the + * Magento admin REST endpoint, and returns the cart payload. Returns + * `null` when no cart cookie is present (anonymous visitor — expected). + * + * This is a minimal port of `deco-cx/apps/magento/loaders/cart.ts`. + * It omits, for now, the image-handling pipeline (`handleCartImages`) + * and the cart-items-with-images transform. Those depend on + * `utils/cache.ts` and `utils/cart.ts` from the original — both + * pending ports (see magento/README.md). + */ +import { getCookies } from "@decocms/blocks/sdk/cookie"; +import { getMagentoConfig, magentoFetch } from "../client"; +import type { MagentoCart } from "../types"; + +const CART_COOKIE = "dataservices_cart_id"; + +function readCartIdFromCookie(headers: Headers): string | null { + const cookies = getCookies(headers); + const raw = cookies[CART_COOKIE]; + if (!raw) return null; + // Magento sets the cookie as `""` (JSON-encoded). Try to parse; + // fall back to the raw string if it isn't quoted. + try { + const parsed = JSON.parse(raw); + return typeof parsed === "string" ? parsed : raw; + } catch { + return raw; + } +} + +export interface CartLoaderProps { + /** Override the cart id (used by checkout flows that already know it). */ + cartId?: string; +} + +export default async function cart( + props: CartLoaderProps | undefined, + req: Request, +): Promise { + const cartId = props?.cartId ?? readCartIdFromCookie(req.headers); + if (!cartId) return null; + + const { site } = getMagentoConfig(); + // Magento exposes the cart endpoint at /rest/:site/V1/carts/:cartId — the + // /rest/ prefix is mandatory and matches the Fresh/Deno original + // (deco-cx/apps/magento/loaders/cart.ts uses + // clientAdmin["GET /rest/:site/V1/carts/:cartId"]). + // + // cartId comes from the request cookie and is user-controlled. Both + // `site` and `cartId` are URL-encoded so neither can break out of its + // path segment and hit a different endpoint while the privileged + // Bearer token is still attached. + const path = `/rest/${encodeURIComponent(site)}/V1/carts/${encodeURIComponent(cartId)}`; + + const res = await magentoFetch(path); + if (!res.ok) { + if (res.status === 404) return null; // expired/invalid cart cookie + throw new Error(`[Magento] cart loader: ${res.status} ${res.statusText}`); + } + return (await res.json()) as MagentoCart; +} diff --git a/packages/apps-magento/src/loaders/features.ts b/packages/apps-magento/src/loaders/features.ts new file mode 100644 index 0000000..455846e --- /dev/null +++ b/packages/apps-magento/src/loaders/features.ts @@ -0,0 +1,16 @@ +/** + * Magento feature flags — returns the `features` block from the resolved + * Magento config. Sites read this to gate optional client-side behavior + * (cart on-load update, wishlist visibility, on-visibility-change update, + * etc.) without re-deploying. + * + * In the legacy deco-cx/apps shape this lived as a 3-arg loader + * `(_props, _req, ctx) => ctx.features`. The TanStack/Node port uses + * the module-global config set by `configureMagento(...)` instead of a + * per-request ctx, which matches the rest of @decocms/apps. + */ +import { getMagentoConfig, type MagentoFeatures } from "../client"; + +export default function features(): MagentoFeatures { + return getMagentoConfig().features ?? {}; +} diff --git a/packages/apps-magento/src/loaders/user.ts b/packages/apps-magento/src/loaders/user.ts new file mode 100644 index 0000000..c2c78e5 --- /dev/null +++ b/packages/apps-magento/src/loaders/user.ts @@ -0,0 +1,60 @@ +/** + * Resolves the current customer into a schema.org `Person` from + * Magento's `/customer/section/load?sections=customer,carbono-customer` + * endpoint, scoped by the visitor's PHPSESSID cookie. + * + * Ported from `deco-cx/apps/magento/loaders/user.ts`. The Fresh + * version used `clientAdmin["GET /:site/customer/section/load"]` with + * typed indexed routes — the TanStack/Node port uses `magentoFetch` + * (which already applies auth/origin headers for same-origin) plus an + * explicit `Cookie: PHPSESSID=…` header so Magento associates the + * request with the logged-in customer. + * + * Returns null when: + * - the session cookie is absent (anonymous visitor — expected), + * - the customer slice is missing or has no data_id (Magento returns + * {} for guest sessions), + * - the HTTP call throws or returns a non-2xx (defensive — the + * storefront just renders the logged-out UI). + */ +import type { Person } from "@decocms/apps-commerce/types"; +import { getMagentoConfig, magentoFetch } from "../client"; +import type { CustomerSectionLoad } from "../utils/client/types"; +import { SESSION_COOKIE } from "../utils/constants"; +import { getUserCookie } from "../utils/user"; + +export default async function user(_props: unknown, req: Request): Promise { + const sessionId = getUserCookie(req.headers); + if (!sessionId) return null; + + const { site } = getMagentoConfig(); + const path = `/${encodeURIComponent(site)}/customer/section/load?sections=customer,carbono-customer`; + + try { + const res = await magentoFetch(path, { + headers: { Cookie: `${SESSION_COOKIE}=${sessionId}` }, + }); + if (!res.ok) return null; + + const response = (await res.json()) as CustomerSectionLoad; + const carbono = response["carbono-customer"]; + const customer = response.customer; + + if (!carbono?.data_id || !customer) return null; + + const { customerId, email } = carbono; + const { fullname, firstname } = customer; + + return { + "@id": customerId, + email: email, + givenName: firstname, + ...(firstname && + fullname && { + familyName: fullname.replace(firstname, "").trim(), + }), + }; + } catch { + return null; + } +} diff --git a/packages/apps-magento/src/loaders/wishlist.ts b/packages/apps-magento/src/loaders/wishlist.ts new file mode 100644 index 0000000..919cdc4 --- /dev/null +++ b/packages/apps-magento/src/loaders/wishlist.ts @@ -0,0 +1,29 @@ +/** + * Resolves the visitor's saved wishlist via Magento's + * `/customer/section/load?sections=wishlist` endpoint, scoped by the + * PHPSESSID cookie. + * + * Ported from `deco-cx/apps/magento/loaders/wishlist.ts`. Returns null + * when the session cookie is absent or Magento reports no wishlist + * (e.g. a logged-in customer who never saved an item). + */ +import { getMagentoConfig, magentoFetch } from "../client"; +import type { CustomerSectionLoad, Wishlist } from "../utils/client/types"; +import { SESSION_COOKIE } from "../utils/constants"; +import { getUserCookie } from "../utils/user"; + +export default async function wishlist(_props: unknown, req: Request): Promise { + const sessionId = getUserCookie(req.headers); + if (!sessionId) return null; + + const { site } = getMagentoConfig(); + const path = `/${encodeURIComponent(site)}/customer/section/load?sections=wishlist`; + + const res = await magentoFetch(path, { + headers: { Cookie: `${SESSION_COOKIE}=${sessionId}` }, + }); + if (!res.ok) return null; + + const { wishlist } = (await res.json()) as CustomerSectionLoad; + return wishlist ?? null; +} diff --git a/packages/apps-magento/src/middleware.ts b/packages/apps-magento/src/middleware.ts new file mode 100644 index 0000000..0fd6612 --- /dev/null +++ b/packages/apps-magento/src/middleware.ts @@ -0,0 +1,19 @@ +/** + * Magento middleware — currently a passthrough. + * + * The legacy deco-cx/apps middleware reconciled the cart id cookie + * after checkout (`changeCardIdAfterCheckout`) and seeded the + * `form_key` for anonymous sessions. Both flows touched response + * headers and `customer/section/load` endpoints — non-trivial port, + * deferred to a follow-up PR. Today the consumer site (granadobr- + * tanstack) handles cart reconciliation on the client. + * + * Shape matches `@decocms/apps-commerce/app-types` so it can be + * plugged into the autoconfig pipeline once magento is registered + * there. + */ +import type { AppMiddleware } from "@decocms/apps-commerce/app-types"; + +export const magentoMiddleware: AppMiddleware = async (_request, next) => { + return next(); +}; diff --git a/packages/apps-magento/src/types.ts b/packages/apps-magento/src/types.ts new file mode 100644 index 0000000..6e42c66 --- /dev/null +++ b/packages/apps-magento/src/types.ts @@ -0,0 +1,51 @@ +/** + * Shared types for the Magento app — kept minimal in this initial port. + * Loader/action ports should extend this file rather than duplicating + * inline types. + */ +import type { MagentoFeatures } from "./client"; + +export interface MagentoCart { + id: string | null; + items: MagentoCartItem[]; + totals?: { + subtotal?: number; + grand_total?: number; + discount_amount?: number; + shipping_amount?: number; + }; + coupon_code?: string | null; +} + +export interface MagentoCartItem { + item_id: number; + sku: string; + name?: string; + qty: number; + price?: number; + row_total?: number; +} + +export type Features = MagentoFeatures; + +/** + * Magento newsletter subscription response shape — what + * `actions/newsletter/subscribe` returns to the storefront. + */ +export interface NewsletterData { + success: boolean; + message: string; +} + +/** + * Stock-alert mutation response from Magento's GraphQL endpoint — + * `actions/product/stockAlert` returns this (or `{ error }`). + */ +export interface ProductStockAlertResponse { + data?: { + productStockAlert: { + message: string; + status: boolean; + }; + }; +} diff --git a/packages/apps-magento/src/utils/cacheTimeControl.ts b/packages/apps-magento/src/utils/cacheTimeControl.ts new file mode 100644 index 0000000..09219d4 --- /dev/null +++ b/packages/apps-magento/src/utils/cacheTimeControl.ts @@ -0,0 +1,69 @@ +/** + * Cache-time-control helpers — verbatim port of + * `deco-cx/apps/magento/utils/cacheTimeControl.ts`. + * + * Magento product loaders pass the request URL through + * `filterSearchParamsFromURL` before using it as a cache key, so that + * tracking parameters (utm_*, gclid, fbclid, etc.) don't fragment the + * cache across thousands of variants for what is conceptually the + * same page. + */ + +export const DEFAULT_CACHE_MAX_AGE = 3600; // 1h + +/** + * Pattern list (each entry is a regex source) of URL search-param + * keys that get stripped before a Magento URL goes into the cache key + * or downstream search-criteria. Mirrors prod byte-for-byte — the + * `.*` suffixes intentionally match `utm_source`, `utm_medium`, etc. + */ +export const SEARCH_PARAMS_TO_IGNORE = [ + "gclid", + "gbraid", + "gdftrk", + "_ga", + "mc_.*", + "trk_.*", + "utm_.*", + "sc_.*", + "dm_i", + "_ke", + "fbclid", + "qitc", + "queryID", + "indexName", + "objectID", + "utm_source", + "utm_medium", + "utm_campaign", + "gad_.*", +]; + +/** + * Filter an array of [key, value] pairs, dropping any whose key matches + * one of the `SEARCH_PARAMS_TO_IGNORE` regex patterns. Pure function so + * callers can sort/transform after. + */ +export const filterSearchParams = (params: [string, string][]): [string, string][] => { + return params.filter( + ([key]) => !SEARCH_PARAMS_TO_IGNORE.find((matchKey) => new RegExp(matchKey).test(key)), + ); +}; + +/** + * Strip the tracking params from a URL (or URL string) and return the + * cleaned `href`. Used by Magento PDP/PLP loaders as the cache key + * basis so cache lookups don't depend on the visitor's referer chain. + */ +export const filterSearchParamsFromURL = (defaultURL: string | URL): string => { + const url = new URL(defaultURL); + const paramsArray = Array.from(url.searchParams.entries()); + const filteredParams = filterSearchParams(paramsArray); + + url.search = ""; + for (const [key, value] of filteredParams) { + url.searchParams.append(key, value); + } + + return url.href; +}; diff --git a/packages/apps-magento/src/utils/client/types.ts b/packages/apps-magento/src/utils/client/types.ts new file mode 100644 index 0000000..1c70ca7 --- /dev/null +++ b/packages/apps-magento/src/utils/client/types.ts @@ -0,0 +1,270 @@ +/** + * Magento REST API response shapes — the subset of types from + * `deco-cx/apps/magento/utils/client/types.ts` that the port has + * reached so far. Extended as more loaders/actions land. + * + * Keep field names **exactly** as Magento returns them (mostly + * snake_case, occasional camelCase from carbono-customer). Consumer + * sites already render against these shapes — any rename is a + * breaking change at the storefront, not just the API boundary. + */ + +// --------------------------------------------------------------------------- +// Customer / user section payloads (added by the user+wishlist port) +// --------------------------------------------------------------------------- + +/** + * `customer` slice of `/customer/section/load?sections=customer,…`. + * Magento returns this on every authenticated section call. + */ +export interface Customer { + data_id: number; + fullname?: string; + firstname?: string; +} + +/** + * `carbono-customer` slice. Granado-specific overlay that mirrors the + * `customer` slice plus a website/store id pair and a normalized email. + * Other magento sites that don't run the Carbono module will get this + * absent; loaders/user.ts checks for it before mapping to a Person. + */ +export interface CarbonoCustomer { + websiteId?: string; + email?: string; + customerId?: string; + data_id: number; +} + +/** + * `cart` slice of the customer section bundle — minimal projection of + * the cart that the minicart island renders before the full cart loader + * has resolved. Not the same as the full Cart payload from + * `/V1/carts/:cartId` (which lives in MagentoCart in types.ts). + */ +export interface CartUser { + summary_count: number; + subtotalAmount: number | null; + subtotal: string; + possible_onepage_checkout: boolean; + items: []; + isGuestCheckoutAllowed: boolean; + website_id: string; + storeId: string; + adyen_payment_methods: unknown[]; + extra_actions: string; + cart_empty_message: string; + subtotal_incl_tax: string; + subtotal_excl_tax: string; + mpFSBCartTotal: unknown | null; + data_id: number; + minicart_improvements: MinicartImprovements; +} + +export interface MinicartImprovements { + coupon_code: string | null; + country_id: string; + api_base_url: string; + is_logged_in: boolean; + quote_id: string; + base_url: string; +} + +/** + * Bundle shape returned by + * `GET /:site/customer/section/load?sections=customer,carbono-customer,wishlist,…`. + * Keys are optional because the caller picks which sections to request. + */ +export interface CustomerSectionLoad { + customer?: Customer; + "carbono-customer"?: CarbonoCustomer; + cart?: CartUser; + wishlist?: Wishlist; +} + +// --------------------------------------------------------------------------- +// Wishlist payloads +// --------------------------------------------------------------------------- + +export interface Wishlist { + counter: string; + items: WishlistItem[]; + counter_number: number; + data_id: number; +} + +export interface WishlistItem { + image: WishlistItemImage; + product_sku: string; + product_id: string; + product_url: string; + product_name: string; + product_price: string; + product_is_saleable_and_visible: boolean; + product_has_required_options: boolean; + add_to_cart_params: string; + delete_item_params: string; +} + +export interface WishlistItemImage { + template: string; + src: string; + width: number; + height: number; + alt: string; +} + +// --------------------------------------------------------------------------- +// Shared attribute/category shapes (added by the transform port) +// --------------------------------------------------------------------------- + +export interface CustomAttribute { + attribute_code: string; + value: string | string[]; +} + +export interface CategoryLink { + position: number; + category_id: string; +} + +export interface MagentoCategory { + id: number; + parent_id: number; + name: string; + is_active: boolean; + position: number; + level: number; + children: string; + created_at: string; + updated_at: string; + path: string; + include_in_menu: boolean; + custom_attributes: CustomAttribute[]; +} + +// --------------------------------------------------------------------------- +// Product detail shapes (used by PDP / PLP / list loaders) +// --------------------------------------------------------------------------- + +export interface MagentoPriceInfo { + final_price: number; + max_price: number; + max_regular_price: number; + minimal_regular_price: number; + special_price: number | null; + minimal_price: number; + regular_price: number; + formatted_prices: { + final_price: string; + max_price: string; + minimal_price: string; + max_regular_price: string; + minimal_regular_price: string | null; + special_price: string | null; + regular_price: string; + }; + extension_attributes: { + msrp: { + msrp_price: string; + is_applicable: string; + is_shown_price_on_gesture: string; + msrp_message: string; + explanation_message: string; + }; + tax_adjustments: { + final_price: number; + max_price: number; + max_regular_price: number; + minimal_regular_price: number; + special_price: number; + minimal_price: number; + regular_price: number; + formatted_prices: { + final_price: string; + max_price: string; + minimal_price: string; + max_regular_price: string; + minimal_regular_price: string | null; + special_price: string; + regular_price: string; + }; + }; + weee_attributes: unknown[]; + weee_adjustment: string; + }; +} + +export interface MagentoStock { + item_id: number; + product_id: number; + stock_id: number; + qty?: number; + is_in_stock?: boolean; + is_qty_decimal?: boolean; + show_default_notification_message?: boolean; + use_config_min_qty?: boolean; + min_qty?: number; + use_config_min_sale_qty?: boolean; + min_sale_qty?: number; + use_config_max_sale_qty?: boolean; + max_sale_qty?: number; + use_config_backorders?: boolean; + backorders?: number; + use_config_notify_stock_qty?: boolean; + notify_stock_qty?: number; + use_config_qty_increments?: boolean; + qty_increments?: number; + use_config_enable_qty_inc?: boolean; + enable_qty_increments?: boolean; + use_config_manage_stock?: boolean; + manage_stock?: boolean; + low_stock_date?: string | null; + is_decimal_divided?: boolean; + stock_status_changed_auto?: number; +} + +export interface MagentoImage { + url: string; + code: string; + height: number; + width: number; + label: string; + resized_width: number; + resized_height: number; + disabled: boolean; +} + +export interface MediaEntry { + id: number; + media_type: string; + label: string | null; + position: number; + disabled: boolean; + types: string[]; + file: string; +} + +export interface MagentoProduct { + id: number; + sku: string; + name: string; + price: number; + status: number; + visibility: number; + type_id: string; + created_at: string; + updated_at: string; + weight: number; + url: string; + extension_attributes: { + website_ids?: number[]; + category_links: CategoryLink[]; + stock_item?: MagentoStock; + }; + custom_attributes: CustomAttribute[]; + price_info?: MagentoPriceInfo; + currency_code?: string; + images?: MagentoImage[]; + media_gallery_entries?: MediaEntry[]; +} diff --git a/packages/apps-magento/src/utils/constants.ts b/packages/apps-magento/src/utils/constants.ts new file mode 100644 index 0000000..25a2757 --- /dev/null +++ b/packages/apps-magento/src/utils/constants.ts @@ -0,0 +1,101 @@ +/** + * Magento constants — ported verbatim from + * `deco-cx/apps/magento/utils/constants.ts`. + * + * Keep this file boring and append-only. Magento's REST and GraphQL + * payloads rely on these identifiers as-is (URL_KEY for PDP slug + * matching, GRAND_TOTAL/SUBTOTAL/… for the totals composition that + * cart.ts pulls, IN_STOCK/OUT_OF_STOCK for schema.org availability). + * Mutating any of these values would silently break consumer sites + * that already render against them. + */ + +import type { FiltersGraphQL } from "../client"; + +export const URL_KEY = "url_key"; + +// Schema.org availability mapping (used by utils/transform.ts to +// produce Offer.availability — kept here so transform doesn't import +// schema.org strings as magic literals). +export const IN_STOCK = "https://schema.org/InStock"; +export const OUT_OF_STOCK = "https://schema.org/OutOfStock"; + +// Rating bounds used by `utils/transform.ts` (and the review/rating +// loaders that follow) when mapping Magento's integer-rating scale +// into schema.org `AggregateRating`'s 1–5 range. +export const MAX_RATING_VALUE = 5; +export const MIN_RATING_VALUE = 1; + +/** + * Default filter mapping consumed by `utils/graphql.ts:filtersFromUrlGraphQL`. + * Each entry pairs a Magento attribute slug with the comparison operator + * the storefront's URL filters use. Sites can extend this via the + * `customFilters` prop on PLP/list loaders without forking the array. + */ +export const DEFAULT_GRAPHQL_FILTERS: FiltersGraphQL[] = [ + { value: "activity", type: "EQUAL" }, + { value: "category_gear", type: "EQUAL" }, + { value: "category_id", type: "EQUAL" }, + { value: "category_uid", type: "EQUAL" }, + { value: "category_url_path", type: "EQUAL" }, + { value: "climate", type: "EQUAL" }, + { value: "collar", type: "EQUAL" }, + { value: "color", type: "EQUAL" }, + { value: "description", type: "MATCH" }, + { value: "eco_collection", type: "EQUAL" }, + { value: "erin_recommends", type: "EQUAL" }, + { value: "features_bags", type: "EQUAL" }, + { value: "format", type: "EQUAL" }, + { value: "gender", type: "EQUAL" }, + { value: "material", type: "EQUAL" }, + { value: "name", type: "MATCH" }, + { value: "new", type: "EQUAL" }, + { value: "pattern", type: "EQUAL" }, + { value: "performance_fabric", type: "EQUAL" }, + { value: "price", type: "RANGE" }, + { value: "purpose", type: "EQUAL" }, + { value: "sale", type: "EQUAL" }, + { value: "short_description", type: "MATCH" }, + { value: "size", type: "EQUAL" }, + { value: "sku", type: "EQUAL" }, + { value: "sleeve", type: "EQUAL" }, + { value: "strap_bags", type: "EQUAL" }, + { value: "style_bags", type: "EQUAL" }, + { value: "style_bottom", type: "EQUAL" }, + { value: "style_general", type: "EQUAL" }, + { value: "url_key", type: "EQUAL" }, +]; + +/** + * Query-string keys that should be stripped before forwarding a request + * URL to Magento (e.g. when computing a cache key or building a + * paginated request). Mirrors the Fresh-era REMOVABLE_URL_SEARCHPARAMS. + */ +export const REMOVABLE_URL_SEARCHPARAMS = ["p", "product_list_order"]; + +// --------------------------------------------------------------------------- +// Cart totals composition — field names sent to Magento's +// /V1/carts/:cartId/totals?fields=… endpoint. The cart loader joins +// these into the `fields` query param to keep the response narrow. +// --------------------------------------------------------------------------- + +export const GRAND_TOTAL = "grand_total"; +export const SUBTOTAL = "subtotal"; +export const DISCOUNT_AMOUNT = "discount_amount"; +export const BASE_DISCOUNT_AMOUNT = "base_discount_amount"; +export const SHIPPING_AMOUNT = "shipping_amount"; +export const BASE_SHIPPING_AMOUNT = "base_shipping_amount"; +export const SHIPPING_DISCOUNT_AMOUNT = "shipping_discount_amount"; +export const COUPON_CODE = "coupon_code"; +export const BASE_CURRENCY_CODE = "base_currency_code"; + +// --------------------------------------------------------------------------- +// Cookie names — single source of truth so loaders/actions/middleware +// don't drift on string literals (Magento is case-sensitive about +// these and a typo produces a silent anonymous-session bug). +// --------------------------------------------------------------------------- + +export const SESSION_COOKIE = "PHPSESSID"; +export const CUSTOMER_COOKIE = "dataservices_customer_id"; +export const CART_COOKIE = "dataservices_cart_id"; +export const FORM_KEY_COOKIE = "form_key"; diff --git a/packages/apps-magento/src/utils/graphql-types.ts b/packages/apps-magento/src/utils/graphql-types.ts new file mode 100644 index 0000000..355dc63 --- /dev/null +++ b/packages/apps-magento/src/utils/graphql-types.ts @@ -0,0 +1,99 @@ +/** + * Magento GraphQL request input types used by the product loaders. + * + * Subset of `deco-cx/apps/magento/utils/clientGraphql/types.ts` that the + * port has reached so far — extended as more loaders land. These mirror + * Magento's GraphQL schema for product search/sort/filter so the + * storefront's URL params translate cleanly into GraphQL variables. + */ + +/** + * Magento ProductAttributeFilterInput shape — keys are Magento + * attribute codes; values are one of the three filter-type unions. + * Maps to: `filter: ProductAttributeFilterInput!` + */ +export interface ProductFilterInput { + [key: string]: FilterEqualTypeInput | FilterMatchTypeInput | FilterRangeTypeInput; +} + +/** + * Magento `FilterEqualTypeInput` — for exact-match attributes (sku, + * sale, color, size, etc.). `in` lets you OR multiple values; the + * single-value `eq` form is also accepted by the Magento schema and is + * the shape `transformFilterValueGraphQL` emits. + */ +export interface FilterEqualTypeInput { + in?: string[]; + eq?: string; +} + +/** + * Magento `FilterMatchTypeInput` — substring-match attributes (name, + * description, short_description). + */ +export interface FilterMatchTypeInput { + match: string; +} + +/** + * Magento `FilterRangeTypeInput` — numeric ranges (price). Both bounds + * are strings in the schema; ranges in URL params come as `from_to`. + */ +export interface FilterRangeTypeInput { + from: string; + to: string; +} + +/** + * Magento `ProductAttributeSortInput` — one entry per sortable + * attribute keyed by attribute code, ordered ASC or DESC. + */ +export interface ProductSortInput { + [key: string]: "ASC" | "DESC"; +} + +/** + * Built-in sort options surfaced in the CMS admin. Custom options can + * be added by sites via `CustomProductSortOption`. + */ +export interface DefaultProductSortOption { + value: "name" | "position" | "price" | "relevance"; +} + +export interface CustomProductSortOption { + value: string; +} + +/** + * Shared sort-prop shape used by PLP / list / relatedProducts loaders. + */ +export interface ProductSort { + /** @title Order by */ + sortBy: DefaultProductSortOption | CustomProductSortOption; + /** @title Sequency */ + order: "ASC" | "DESC"; +} + +/** + * Loader-supplied filter (vs URL-derived). The site can hard-code + * filters in the CMS section config and they layer on top of whatever + * the user picked from URL params. + */ +export interface FilterProps { + name: string; + type: FilterEqualTypeInput | FilterMatchTypeInput | FilterRangeTypeInput; +} + +/** + * Custom-fields toggle used by `getCustomFields()` to decide which + * Magento product attributes to project from a query. + */ +export interface CustomFields { + /** + * @description Search for global custom fields defined in App settings + * @default false + */ + active: boolean; + /** @description Will override global custom fields defined in App settings */ + overrideList?: string[]; +} diff --git a/packages/apps-magento/src/utils/graphql.ts b/packages/apps-magento/src/utils/graphql.ts new file mode 100644 index 0000000..2156df3 --- /dev/null +++ b/packages/apps-magento/src/utils/graphql.ts @@ -0,0 +1,155 @@ +/** + * GraphQL helpers for Magento product loaders. + * + * Ported from `deco-cx/apps/magento/utils/graphql.ts` (Fresh/Deno + * prod). Pure functions — no I/O, no client state — so behavior is + * pinned by `__tests__/graphql.test.ts` and shared across PDP, PLP, + * list, and relatedProducts loaders. + * + * - `transformSortGraphQL(ProductSort)` → `ProductSortInput` + * - `transformFilterGraphQL(url, customFilters, fromLoader)` → + * `ProductFilterInput` — merges URL-derived + loader-derived filters. + * - `transformFilterValueGraphQL(value, type)` → typed Filter*Input. + * - `formatUrlSuffix(str)` → ensures a path ends with `/` (used as + * `defaultPath` for the Magento URL rewrite resolver). + * - `getCustomFields(CustomFields, fallback)` → resolved custom + * attribute list for product projection. + */ + +import type { FiltersGraphQL } from "../client"; +import { DEFAULT_GRAPHQL_FILTERS } from "./constants"; +import type { + CustomFields, + FilterEqualTypeInput, + FilterMatchTypeInput, + FilterProps, + FilterRangeTypeInput, + ProductFilterInput, + ProductSort, + ProductSortInput, +} from "./graphql-types"; + +export const typeChecker = (v: T, prop: keyof T): boolean => prop in v; + +export const transformSortGraphQL = ({ + sortBy, + order, +}: Partial): ProductSortInput | undefined => { + if (!sortBy) { + return undefined; + } + return { + [sortBy.value]: order ?? "ASC", + }; +}; + +/** + * Compose the GraphQL `filter` payload from two sources, in order: + * + * 1. URL search params crossed against `DEFAULT_GRAPHQL_FILTERS` + * (+ any `customFilters` the site extends with). + * 2. Explicit `fromLoader` filters the CMS section pinned at config + * time. + * + * Loader-derived filters shadow URL-derived ones on key collisions + * (intentional — a section that hard-codes `sale=true` should ignore + * any conflicting URL hint). + */ +export const transformFilterGraphQL = ( + url: URL, + customFilters?: Array, + fromLoader?: Array, +): ProductFilterInput | undefined => ({ + ...filtersFromUrlGraphQL(url, customFilters), + ...filtersFromLoaderGraphQL(fromLoader), +}); + +export const filtersFromLoaderGraphQL = ( + fromLoader?: Array, +): ProductFilterInput | undefined => + fromLoader?.reduce( + (acc, f) => ({ + ...acc, + [f.name]: f.type, + }), + {}, + ) ?? {}; + +export const filtersFromUrlGraphQL = ( + url: URL, + customFilters?: Array, +): ProductFilterInput => + DEFAULT_GRAPHQL_FILTERS.concat(customFilters ?? []).reduce( + (acc, { type, value }) => { + const fromUrl = url.searchParams.get(value); + if (!fromUrl) { + return acc; + } + return { + ...acc, + [value]: transformFilterValueGraphQL(fromUrl, type), + }; + }, + {}, + ); + +export const transformFilterValueGraphQL = ( + value: string, + type: "EQUAL" | "MATCH" | "RANGE", +): FilterEqualTypeInput | FilterMatchTypeInput | FilterRangeTypeInput => { + if (type === "EQUAL") { + return { eq: value } as FilterEqualTypeInput; + } + + if (type === "MATCH") { + return { match: value } as FilterMatchTypeInput; + } + + if (type === "RANGE") { + const splitterIndex = value.indexOf("_"); + return { + from: value.substring(0, splitterIndex), + to: value.substring(splitterIndex + 1), + } as FilterRangeTypeInput; + } + + return {} as FilterEqualTypeInput; +}; + +/** + * Normalize a URL path into the form Magento's + * `urlResolver(url: "/")` expects: + * - Strip a single leading slash (the resolver doesn't want it). + * - Ensure the path ends with `/`. + * + * Used by PLP / PDP / list / relatedProducts loaders as `defaultPath` + * when `useSuffix` is enabled. + */ +export const formatUrlSuffix = (str: string): string => { + let s = str; + if (s.startsWith("/")) s = s.slice(1); + if (!s.endsWith("/")) s = `${s}/`; + return s; +}; + +/** + * Resolve which custom-attribute list a loader should request: + * + * - disabled (`active: false`) → undefined (loader projects nothing custom). + * - explicit override list set → return that list as-is. + * - otherwise → fall back to the global list provided by the loader. + */ +export const getCustomFields = ( + { active, overrideList }: CustomFields = { active: false, overrideList: [] }, + customFields?: Array, +): Array | undefined => { + if (!active) { + return undefined; + } + + if (overrideList && overrideList.length > 0) { + return overrideList; + } + + return customFields; +}; diff --git a/packages/apps-magento/src/utils/stringifySearchCriteria.ts b/packages/apps-magento/src/utils/stringifySearchCriteria.ts new file mode 100644 index 0000000..076edab --- /dev/null +++ b/packages/apps-magento/src/utils/stringifySearchCriteria.ts @@ -0,0 +1,62 @@ +/** + * Flatten a Magento REST `searchCriteria` object into a bracketed + * query-string key/value map. + * + * Magento's search-criteria payload is deeply nested (filterGroups → + * filters → field/value), but the REST API accepts it only as flat + * `searchCriteria[filterGroups][0][filters][0][field]=name` style + * query parameters. This helper walks the tree once and emits that + * shape. + * + * Ported verbatim from `deco-cx/apps/magento/utils/stringifySearchCriteria.ts` + * — the Fresh implementation is self-contained (no Deno-isms) and + * produces output that prod consumers already depend on. Behavior + * pinned by `__tests__/stringifySearchCriteria.test.ts`. + * + * @example + * stringifySearchCriteria({ + * filterGroups: [{ filters: [{ field: "sku", value: "ABC" }] }], + * }) + * // ⇒ { "searchCriteria[filterGroups][0][filters][0][field]": "sku", + * // "searchCriteria[filterGroups][0][filters][0][value]": "ABC" } + */ +interface Filter { + field: string; + value: string; +} + +interface FilterGroup { + [key: string]: Filter[]; +} + +interface SearchCriteria { + [key: string]: string | number | FilterGroup[]; +} + +type Path = string; +type TraverseObj = SearchCriteria | FilterGroup[]; + +function traverse(data: TraverseObj, result: Record, path: Path) { + if (Array.isArray(data)) { + data.forEach((item, index) => { + traverse(item as unknown as TraverseObj, result, `${path}[${index}]`); + }); + } else if (typeof data === "object" && data !== null) { + for (const key in data) { + if (Object.hasOwn(data, key)) { + // @ts-expect-error recursive function with heterogeneous values + traverse(data[key], result, `${path}[${key}]`); + } + } + } else { + result[path] = data; + } +} + +export default function stringifySearchCriteria( + searchCriteriaObj: SearchCriteria, +): Record { + const result: Record = {}; + traverse(searchCriteriaObj, result, "searchCriteria"); + return result; +} diff --git a/packages/apps-magento/src/utils/transform.ts b/packages/apps-magento/src/utils/transform.ts new file mode 100644 index 0000000..9269b1d --- /dev/null +++ b/packages/apps-magento/src/utils/transform.ts @@ -0,0 +1,283 @@ +/** + * Magento product transform helpers — maps the REST/GraphQL payloads + * into schema.org `Product` / `Offer` / `Seo` shapes that + * `@decocms/apps-commerce` consumers expect. + * + * Subset of `deco-cx/apps/magento/utils/transform.ts` — only the + * functions the PDP loader needs (toProduct, toOffer, toImages, toURL, + * toBreadcrumbList, toSeo). The GraphQL-side helpers (toProductGraphQL, + * toAggOfferGraphQL, toProductListingPageGraphQL, …) and the Granado- + * specific helpers (toReviewAmasty, toLiveloPoints) are intentionally + * excluded — they land in separate follow-up PRs alongside the loaders + * that consume them. + * + * Behavior is pinned by `__tests__/transform.test.ts`. Every change to + * this file should ship a regression test. + */ + +import type { + ImageObject, + ListItem, + Offer, + Product, + PropertyValue, + Seo, + UnitPriceSpecification, +} from "@decocms/apps-commerce/types"; +import type { CustomAttribute, MagentoCategory, MagentoProduct } from "./client/types"; +import { IN_STOCK, OUT_OF_STOCK } from "./constants"; + +// --------------------------------------------------------------------------- +// Product → schema.org Product +// --------------------------------------------------------------------------- + +export const toProduct = ({ + product, + options, +}: { + product: MagentoProduct; + options: { + currencyCode?: string; + imagesUrl?: string; + maxInstallments: number; + minInstallmentValue: number; + }; +}): Product => { + const offers = toOffer(product, options.minInstallmentValue, options.maxInstallments); + const sku = product.sku; + const productID = product.id.toString(); + const productPrice = product.price_info; + + const additionalProperty: PropertyValue[] = product.custom_attributes?.map((attr) => ({ + "@type": "PropertyValue", + name: attr.attribute_code, + value: String(attr.value), + })); + + return { + "@type": "Product", + productID, + sku, + url: product.url, + name: product.name, + gtin: sku, + isVariantOf: { + "@type": "ProductGroup", + productGroupID: productID, + url: product.url, + name: product.name, + model: "", + additionalProperty: additionalProperty, + hasVariant: [ + { + "@type": "Product", + productID, + sku, + url: product.url, + name: product.name, + gtin: sku, + offers: { + "@type": "AggregateOffer", + // biome-ignore lint/style/noNonNullAssertion: matches prod fallback to price + highPrice: productPrice?.max_price ?? product.price!, + // biome-ignore lint/style/noNonNullAssertion: matches prod fallback to price + lowPrice: productPrice?.minimal_price ?? product.price!, + offerCount: offers.length, + offers: offers, + }, + }, + ], + }, + additionalProperty: additionalProperty, + image: toImages(product, options.imagesUrl ?? ""), + offers: { + "@type": "AggregateOffer", + // biome-ignore lint/style/noNonNullAssertion: matches prod fallback to price + highPrice: productPrice?.max_price ?? product.price!, + // biome-ignore lint/style/noNonNullAssertion: matches prod fallback to price + lowPrice: productPrice?.minimal_price ?? product.price!, + offerCount: offers.length, + offers: offers, + }, + }; +}; + +// --------------------------------------------------------------------------- +// Product → Offer[] +// --------------------------------------------------------------------------- + +export const toOffer = ( + { price_info, extension_attributes, sku, currency_code }: MagentoProduct, + minInstallmentValue: number, + maxInstallments: number, +): Offer[] => { + if (!price_info) { + return []; + } + const { final_price, max_price, max_regular_price } = price_info; + const { stock_item } = extension_attributes; + const inStock = stock_item?.is_in_stock; + const qtyStock = stock_item?.qty ?? 0; + + return [ + { + "@type": "Offer", + availability: inStock ? IN_STOCK : OUT_OF_STOCK, + inventoryLevel: { + value: inStock ? qtyStock || 999 : 0, + }, + itemCondition: "https://schema.org/NewCondition", + price: final_price, + priceCurrency: currency_code, + priceSpecification: [ + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/ListPrice", + price: max_price ?? max_regular_price, + }, + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/SalePrice", + price: final_price, + }, + ...calculateInstallments(final_price, minInstallmentValue, maxInstallments), + ], + sku: sku, + }, + ]; +}; + +/** + * Build the installment ladder for a sale price. `À vista` is always + * the first entry, then 2x..Nx where N is bounded by + * `min(floor(finalPrice/minInstallmentValue), maxInstallments)`. + * Each entry is a `UnitPriceSpecification` so storefront UIs render + * "10x de R$ 12,50 sem juros" without further math. + */ +const calculateInstallments = ( + finalPrice: number, + minInstallmentValue: number, + maxInstallments: number, +): UnitPriceSpecification[] => { + const possibleInstallmentsCount = Math.floor(finalPrice / minInstallmentValue) || 1; + const actualInstallmentsCount = Array.from( + { length: Math.min(possibleInstallmentsCount, maxInstallments) }, + (_v, i) => +(finalPrice / (i + 1)).toFixed(2), + ); + + return actualInstallmentsCount.map((value, i) => { + const [description, billingIncrement] = !i + ? ["À vista", finalPrice] + : [`${i + 1}x sem juros`, value]; + return { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/SalePrice", + priceComponentType: "https://schema.org/Installment", + description, + billingDuration: i + 1, + billingIncrement, + price: finalPrice, + }; + }); +}; + +// --------------------------------------------------------------------------- +// Product → ImageObject[] +// --------------------------------------------------------------------------- + +export const toImages = (product: MagentoProduct, imageUrl: string): ImageObject[] | undefined => { + if (imageUrl) { + return product.media_gallery_entries?.map((img) => ({ + "@type": "ImageObject" as const, + encodingFormat: "image", + alternateName: `${img.file}`, + url: `${toURL(imageUrl)}${img.file}`, + disabled: img.disabled || false, + })); + } + + return product.images?.map((img) => ({ + "@type": "ImageObject" as const, + encodingFormat: "image", + alternateName: `${img.label}`, + disabled: img.disabled || false, + url: `${img.url}`, + })); +}; + +/** + * Protocol-relative URLs (`//cdn.magento.example/...`) come back from + * some Magento installations. Promote to https so the browser doesn't + * try to inherit a non-https scheme. + */ +export const toURL = (src: string): string => (src.startsWith("//") ? `https:${src}` : src); + +// --------------------------------------------------------------------------- +// Categories → ListItem[] (breadcrumb) +// --------------------------------------------------------------------------- + +export const toBreadcrumbList = ( + categories: (MagentoCategory | null)[], + isBreadcrumbProductName: boolean, + product: Product, + url: URL, +): ListItem[] => { + if (isBreadcrumbProductName && categories?.length === 0) { + return [ + { + "@type": "ListItem", + name: product.name, + position: 1, + item: new URL(`/${product.name}`, url).href, + }, + ]; + } + + const itemListElement = categories + .map((category) => { + if (!category || !category.name || !category.position) { + return null; + } + return { + "@type": "ListItem", + name: category.name, + position: category.position, + item: new URL(`/${category.name}`, url).href, + }; + }) + .filter(Boolean) as ListItem[]; + + return itemListElement; +}; + +// --------------------------------------------------------------------------- +// Custom attributes → Seo +// --------------------------------------------------------------------------- + +/** + * Build a `Seo` block from a product's custom_attributes. Magento + * exposes title/meta_title/meta_description as separate attribute_code + * entries; this maps them with explicit fallback (meta_title wins over + * title; description is meta_description only — there's no fallback + * field in the legacy code). + */ +export const toSeo = (customAttributes: CustomAttribute[], productURL: string): Seo => { + const findAttribute = (attrCode: string): string => { + const attribute = customAttributes.find((attr) => attr.attribute_code === attrCode); + if (!attribute) return ""; + if (Array.isArray(attribute.value)) { + return attribute.value.join(", "); + } + return attribute.value; + }; + + const title = findAttribute("title"); + const metaTitle = findAttribute("meta_title"); + const metaDescription = findAttribute("meta_description"); + + return { + title: metaTitle || title || "", + description: metaDescription || "", + canonical: productURL, + }; +}; diff --git a/packages/apps-magento/src/utils/user.ts b/packages/apps-magento/src/utils/user.ts new file mode 100644 index 0000000..41e5f9b --- /dev/null +++ b/packages/apps-magento/src/utils/user.ts @@ -0,0 +1,16 @@ +/** + * Reads the Magento PHP session cookie from a request's headers. + * Returns undefined when the cookie is absent (anonymous visitor). + * + * Mirrors `deco-cx/apps/magento/utils/user.ts` — the Fresh version + * used Deno's `std/http/cookie` getCookies; the port goes through + * `@decocms/blocks/sdk/cookie` so it works on Cloudflare Workers and + * Node alike. + */ +import { getCookies } from "@decocms/blocks/sdk/cookie"; +import { SESSION_COOKIE } from "./constants"; + +export const getUserCookie = (headers: Headers): string | undefined => { + const cookies = getCookies(headers); + return cookies[SESSION_COOKIE]; +}; diff --git a/packages/apps-magento/tsconfig.json b/packages/apps-magento/tsconfig.json new file mode 100644 index 0000000..42386d3 --- /dev/null +++ b/packages/apps-magento/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"] +} From e194803700dab7e0af323c37576e51c8636f360b Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:20:01 -0300 Subject: [PATCH 36/85] chore: update bun.lock for @decocms/apps-magento --- bun.lock | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/bun.lock b/bun.lock index 317eb48..1cd95ce 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,25 @@ "react-dom": "^19.0.0", }, }, + "packages/apps-magento": { + "name": "@decocms/apps-magento", + "version": "0.0.0", + "dependencies": { + "@decocms/apps-commerce": "workspace:*", + "@decocms/blocks": "workspace:*", + "@decocms/tanstack": "workspace:*", + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "knip": "^5.86.0", + "typescript": "^5.9.0", + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + }, "packages/apps-shopify": { "name": "@decocms/apps-shopify", "version": "0.0.0", @@ -329,6 +348,8 @@ "@decocms/apps-commerce": ["@decocms/apps-commerce@workspace:packages/apps-commerce"], + "@decocms/apps-magento": ["@decocms/apps-magento@workspace:packages/apps-magento"], + "@decocms/apps-shopify": ["@decocms/apps-shopify@workspace:packages/apps-shopify"], "@decocms/apps-vtex": ["@decocms/apps-vtex@workspace:packages/apps-vtex"], From c2178dc236c1e88eb46870926f80822fd5cee75f Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:26:57 -0300 Subject: [PATCH 37/85] feat(apps-algolia): migrate algolia/ from apps-start --- packages/apps-algolia/package.json | 34 +++ packages/apps-algolia/src/README.md | 89 ++++++++ .../apps-algolia/src/__tests__/client.test.ts | 195 ++++++++++++++++++ packages/apps-algolia/src/client.ts | 133 ++++++++++++ packages/apps-algolia/src/index.ts | 12 ++ packages/apps-algolia/src/loaders/client.ts | 22 ++ packages/apps-algolia/src/types.ts | 44 ++++ packages/apps-algolia/tsconfig.json | 7 + 8 files changed, 536 insertions(+) create mode 100644 packages/apps-algolia/package.json create mode 100644 packages/apps-algolia/src/README.md create mode 100644 packages/apps-algolia/src/__tests__/client.test.ts create mode 100644 packages/apps-algolia/src/client.ts create mode 100644 packages/apps-algolia/src/index.ts create mode 100644 packages/apps-algolia/src/loaders/client.ts create mode 100644 packages/apps-algolia/src/types.ts create mode 100644 packages/apps-algolia/tsconfig.json diff --git a/packages/apps-algolia/package.json b/packages/apps-algolia/package.json new file mode 100644 index 0000000..4d6024a --- /dev/null +++ b/packages/apps-algolia/package.json @@ -0,0 +1,34 @@ +{ + "name": "@decocms/apps-algolia", + "version": "0.0.0", + "type": "module", + "description": "Deco commerce app: Algolia search integration", + "repository": { "type": "git", "url": "https://github.com/decocms/blocks.git", "directory": "packages/apps-algolia" }, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts", + "./types": "./src/types.ts", + "./loaders/*": "./src/loaders/*.ts" + }, + "scripts": { + "build": "tsc", + "test": "vitest run --root ../.. packages/apps-algolia/", + "typecheck": "tsc --noEmit", + "lint:unused": "knip" + }, + "dependencies": { + "@decocms/blocks": "workspace:*", + "@decocms/apps-commerce": "workspace:*" + }, + "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", "algoliasearch": "^5" }, + "peerDependenciesMeta": { "algoliasearch": { "optional": true } }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "algoliasearch": "^5.53.0", + "knip": "^5.86.0", + "typescript": "^5.9.0" + }, + "publishConfig": { "registry": "https://registry.npmjs.org", "access": "public" } +} diff --git a/packages/apps-algolia/src/README.md b/packages/apps-algolia/src/README.md new file mode 100644 index 0000000..a1a06f5 --- /dev/null +++ b/packages/apps-algolia/src/README.md @@ -0,0 +1,89 @@ +# Algolia app — initial scaffold + +This folder ports the Algolia integration from `deco-cx/apps/algolia` +(Fresh/Deno) to `@decocms/apps/algolia` (TanStack Start/Node), following +the same shape as `vtex/`, `magento/`, and `shopify/`. + +## Status + +**Initial scaffold** — covers the `configureAlgolia`/`getAlgoliaClient` +surface plus the `loaders/client.ts` shim that matches the upstream +`apps/algolia/loaders/client.ts` call site (`ctx.invoke.algolia.loaders.client({})`). +Just enough for downstream sites with their own product loaders to wire +Algolia and consume the SDK SearchClient directly. + +A real-world consumer (deco-sites/granadobr-tanstack) is migrating away +from the legacy `ctx.invoke.algolia.loaders.client({})` proxy that +existed in the Fresh runtime. The site keeps its own product loaders +(custom Granado transforms over the upstream toProduct) and only needs +the SDK client from this package. + +## What's here + +- `client.ts` — `configureAlgolia({ applicationId, searchApiKey, + adminApiKey })` + `getAlgoliaConfig()` accessor + lazy + `getAlgoliaClient()` cached singleton. Mirrors `configureMagento` / + `configureVtex`. +- `types.ts` — `AlgoliaConfig`, canonical `Indices` union. +- `loaders/client.ts` — returns the configured `SearchClient` so legacy + call sites (`invoke.algolia.loaders.client({})`) keep working when + routed through the loader registry. +- `index.ts` — re-export entry. + +## Pending port (PR follow-ups) + +These exist as production code in `deco-cx/apps/algolia/` and need a +Deno → Node pass (npm specifiers, `commerce/types.ts` shared import, +etc.). Tracked here so the next PR series has a clear scope: + +| Path | Original location | +|---|---| +| `loaders/product/list.ts` | `deco-cx/apps/algolia/loaders/product/list.ts` | +| `loaders/product/listingPage.ts` | idem | +| `loaders/product/suggestions.ts` | idem | +| `actions/setup.ts` | `deco-cx/apps/algolia/actions/setup.ts` | +| `actions/index/{product,wait}.ts` | `deco-cx/apps/algolia/actions/index/*` | +| `utils/{highlight,product}.ts` | `deco-cx/apps/algolia/utils/*` | +| `workflows/index/product.ts` | `deco-cx/apps/algolia/workflows/index/product.ts` | +| `sections/Analytics/Algolia.tsx` | `deco-cx/apps/algolia/sections/Analytics/Algolia.tsx` | + +The site-side `src/packs/algolia/products/*` in granadobr-tanstack +contains a Granado-specific transform layer that is not portable as-is. +Once `loaders/product/*` lands here, the upstream tract can be reused; +the Granado overlays will keep living in the site. + +## Wiring in a site + +```ts +// src/setup.ts +import { initAlgoliaFromBlocks } from "@decocms/apps/algolia"; +import { blocks } from "./server/cms/blocks.gen"; + +createSiteSetup({ + // ... + initPlatform: (blocks) => { + initAlgoliaFromBlocks(blocks); // default block key: "deco-algolia" + }, +}); +``` + +Then in your loaders: + +```ts +import { getAlgoliaClient } from "@decocms/apps/algolia/client"; + +export default async function loader(props, req) { + const client = getAlgoliaClient(); + const { results } = await client.search([{ + indexName: "products", + query: props.term, + params: { hitsPerPage: 12 }, + }]); + return results[0].hits; +} +``` + +The Secret-shaped `adminApiKey` in the CMS block +(`{__resolveType: "website/loaders/secret.ts", name: "ADMIN_KEY"}`) is +dereferenced via `process.env.ADMIN_KEY` at init time, matching how +`magento/client.ts` handles secrets in this repo. diff --git a/packages/apps-algolia/src/__tests__/client.test.ts b/packages/apps-algolia/src/__tests__/client.test.ts new file mode 100644 index 0000000..2e1442f --- /dev/null +++ b/packages/apps-algolia/src/__tests__/client.test.ts @@ -0,0 +1,195 @@ +/** + * Tests for algolia/client.ts. + * + * The goal is to lock the contract that downstream sites depend on: + * - configureAlgolia stores config and surfaces it via getAlgoliaConfig + * - getAlgoliaConfig throws a useful error when init never happened + * - getAlgoliaClient builds the SDK lazily and caches the instance + * - initAlgoliaFromBlocks dereferences Secret-shaped admin keys via + * `process.env` so prod CMS blocks (`{__resolveType: + * "website/loaders/secret.ts", name: "ADMIN_KEY"}`) work + * + * The SDK itself is mocked — we don't want network or fetch polyfills + * pulled into the test runner; we only care that we call into + * `algoliasearch(applicationId, adminApiKey)` with the right args. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const algoliasearchSpy = vi.fn(() => ({ __mockClient: true })); + +vi.mock("algoliasearch", () => ({ + algoliasearch: (...args: unknown[]) => + algoliasearchSpy(...(args as Parameters)), +})); + +// Importing after the mock so the production module picks up the +// mocked SDK. resetModules() in beforeEach keeps module-global state +// (cachedClient, config) isolated across tests. +let mod: typeof import("../client"); + +beforeEach(async () => { + algoliasearchSpy.mockClear(); + vi.resetModules(); + mod = await import("../client"); +}); + +afterEach(() => { + delete process.env.TEST_ADMIN_KEY; +}); + +describe("configureAlgolia + getAlgoliaConfig", () => { + it("returns the most recently configured values", () => { + mod.configureAlgolia({ applicationId: "APP", searchApiKey: "S", adminApiKey: "A" }); + expect(mod.getAlgoliaConfig()).toEqual({ + applicationId: "APP", + searchApiKey: "S", + adminApiKey: "A", + }); + }); + + it("throws a helpful error when called before init", () => { + expect(() => mod.getAlgoliaConfig()).toThrowError(/configureAlgolia/); + }); +}); + +describe("getAlgoliaClient", () => { + it("constructs the SDK with applicationId + adminApiKey", () => { + mod.configureAlgolia({ applicationId: "APP_X", searchApiKey: "S", adminApiKey: "ADMIN" }); + const client = mod.getAlgoliaClient(); + expect(algoliasearchSpy).toHaveBeenCalledExactlyOnceWith("APP_X", "ADMIN"); + expect(client).toEqual({ __mockClient: true }); + }); + + it("caches the client across calls", () => { + mod.configureAlgolia({ applicationId: "APP_X", searchApiKey: "S", adminApiKey: "ADMIN" }); + mod.getAlgoliaClient(); + mod.getAlgoliaClient(); + mod.getAlgoliaClient(); + expect(algoliasearchSpy).toHaveBeenCalledOnce(); + }); + + it("rebuilds the client after configureAlgolia is called again", () => { + mod.configureAlgolia({ applicationId: "APP_X", searchApiKey: "S", adminApiKey: "ADMIN1" }); + mod.getAlgoliaClient(); + mod.configureAlgolia({ applicationId: "APP_X", searchApiKey: "S", adminApiKey: "ADMIN2" }); + mod.getAlgoliaClient(); + expect(algoliasearchSpy).toHaveBeenCalledTimes(2); + expect(algoliasearchSpy).toHaveBeenNthCalledWith(2, "APP_X", "ADMIN2"); + }); + + it("throws when applicationId is missing", () => { + mod.configureAlgolia({ applicationId: "", searchApiKey: "S", adminApiKey: "A" }); + expect(() => mod.getAlgoliaClient()).toThrowError(/applicationId/); + }); + + it("falls back to searchApiKey when adminApiKey is empty", () => { + mod.configureAlgolia({ applicationId: "APP", searchApiKey: "SEARCH_ONLY", adminApiKey: "" }); + mod.getAlgoliaClient(); + expect(algoliasearchSpy).toHaveBeenCalledExactlyOnceWith("APP", "SEARCH_ONLY"); + }); + + it("throws when both keys are empty", () => { + mod.configureAlgolia({ applicationId: "APP", searchApiKey: "", adminApiKey: "" }); + expect(() => mod.getAlgoliaClient()).toThrowError(/adminApiKey or searchApiKey/); + }); + + it("prefers adminApiKey over searchApiKey when both present", () => { + mod.configureAlgolia({ applicationId: "APP", searchApiKey: "S", adminApiKey: "ADMIN" }); + mod.getAlgoliaClient(); + expect(algoliasearchSpy).toHaveBeenCalledExactlyOnceWith("APP", "ADMIN"); + }); +}); + +describe("initAlgoliaFromBlocks", () => { + it("returns false and skips configure() when block is absent", async () => { + const result = await mod.initAlgoliaFromBlocks({}); + expect(result).toBe(false); + expect(() => mod.getAlgoliaConfig()).toThrowError(/configureAlgolia/); + }); + + it("reads applicationId + searchApiKey + adminApiKey from the block", async () => { + const result = await mod.initAlgoliaFromBlocks({ + "deco-algolia": { + applicationId: "APP", + searchApiKey: "SEARCH", + adminApiKey: "ADMIN_STRING", + }, + }); + expect(result).toBe(true); + expect(mod.getAlgoliaConfig()).toEqual({ + applicationId: "APP", + searchApiKey: "SEARCH", + adminApiKey: "ADMIN_STRING", + }); + }); + + it("dereferences a Secret-shaped adminApiKey via process.env", async () => { + process.env.TEST_ADMIN_KEY = "from-env"; + await mod.initAlgoliaFromBlocks({ + "deco-algolia": { + applicationId: "APP", + searchApiKey: "SEARCH", + adminApiKey: { + __resolveType: "website/loaders/secret.ts", + name: "TEST_ADMIN_KEY", + }, + }, + }); + expect(mod.getAlgoliaConfig().adminApiKey).toBe("from-env"); + }); + + it("falls back to empty string when env var is unset", async () => { + await mod.initAlgoliaFromBlocks({ + "deco-algolia": { + applicationId: "APP", + searchApiKey: "SEARCH", + adminApiKey: { + __resolveType: "website/loaders/secret.ts", + name: "UNDEFINED_ENV_VAR_DO_NOT_SET", + }, + }, + }); + expect(mod.getAlgoliaConfig().adminApiKey).toBe(""); + }); + + it("honors a custom block key", async () => { + await mod.initAlgoliaFromBlocks( + { + "my-algolia": { + applicationId: "X", + searchApiKey: "Y", + adminApiKey: "Z", + }, + }, + "my-algolia", + ); + expect(mod.getAlgoliaConfig().applicationId).toBe("X"); + }); + + // Encrypted-secret flow: the CMS block ships `{ encrypted, name }`, + // the framework's `resolveSecret` (from `@decocms/start/sdk/crypto`) + // is supposed to AES-CBC decrypt `encrypted` using `DECO_CRYPTO_KEY`. + // In a vitest worker `crypto.subtle` is available but the AES key + // material isn't shipped to the runner — without `DECO_CRYPTO_KEY`, + // `resolveSecret` skips the decrypt step and falls back to the env + // var. That fallback path is what this test pins: prod sites either + // set the env var on top OR (more commonly) rely on the decrypt to + // succeed against the worker's `DECO_CRYPTO_KEY` binding. + it("uses env var fallback when DECO_CRYPTO_KEY is unset and encrypted is present", async () => { + delete process.env.DECO_CRYPTO_KEY; + process.env.FALLBACK_ADMIN_KEY = "from-env-fallback"; + await mod.initAlgoliaFromBlocks({ + "deco-algolia": { + applicationId: "APP", + searchApiKey: "SEARCH", + adminApiKey: { + __resolveType: "website/loaders/secret.ts", + encrypted: "deadbeef", + name: "FALLBACK_ADMIN_KEY", + }, + }, + }); + expect(mod.getAlgoliaConfig().adminApiKey).toBe("from-env-fallback"); + delete process.env.FALLBACK_ADMIN_KEY; + }); +}); diff --git a/packages/apps-algolia/src/client.ts b/packages/apps-algolia/src/client.ts new file mode 100644 index 0000000..206b043 --- /dev/null +++ b/packages/apps-algolia/src/client.ts @@ -0,0 +1,133 @@ +/** + * Algolia client + config — module-global, set once at app boot. + * + * Mirrors `magento/client.ts` and `vtex/client.ts`'s `configureX` / + * `getX` pattern so the same wiring contract works across commerce + * apps. The SearchClient is constructed lazily on the first + * `getAlgoliaClient()` call so the underlying `algoliasearch` SDK + * (which pulls in fetch polyfills + an LRU) only loads when actually + * used. + * + * Two reasons we don't pass config explicitly to every loader: + * 1. CMS-resolved loader instances don't know where the config block + * lives; the site's `initAlgoliaFromBlocks(blocks)` adapter is the + * single source of truth. + * 2. Matches the rest of @decocms/apps so a site touching VTEX, + * Magento, and Algolia has consistent muscle memory. + */ + +import { algoliasearch, type SearchClient } from "algoliasearch"; + +import type { AlgoliaConfig } from "./types"; + +// --------------------------------------------------------------------------- +// Module-global state +// --------------------------------------------------------------------------- + +let config: AlgoliaConfig | null = null; +let cachedClient: SearchClient | null = null; + +export function configureAlgolia(c: AlgoliaConfig): void { + config = c; + // Reset the cached client so the next getAlgoliaClient() call picks + // up the new credentials. In practice this only happens during dev + // hot-reload of the setup file. + cachedClient = null; +} + +export function getAlgoliaConfig(): AlgoliaConfig { + if (!config) { + throw new Error( + "[Algolia] configureAlgolia() must be called before loaders run. " + + "Wire it in your site's setup, e.g. configureAlgolia(blocks['deco-algolia']).", + ); + } + return config; +} + +/** + * Returns the configured `SearchClient` from `algoliasearch`. The + * instance is cached so all loaders/actions in a worker share one + * client (and therefore one in-memory request cache). + * + * Prefers `adminApiKey` (broader scope — needed for indexing/settings + * actions) but falls back to `searchApiKey` so search-only sites that + * never set the admin secret as a worker env var still serve hits. + * Both keys live in the same SDK instance because v4's SearchClient + * doesn't expose a key swap; downstream write actions that require + * admin scope should check `getAlgoliaConfig().adminApiKey` themselves + * and surface a clear "admin key missing" error. + */ +export function getAlgoliaClient(): SearchClient { + if (cachedClient) return cachedClient; + const c = getAlgoliaConfig(); + if (!c.applicationId) { + throw new Error("[Algolia] applicationId is required."); + } + const key = c.adminApiKey || c.searchApiKey; + if (!key) { + throw new Error( + "[Algolia] Either adminApiKey or searchApiKey is required. " + + "Set ADMIN_KEY (or the env var your CMS block's Secret references) " + + "as a worker env var, or populate searchApiKey on the block.", + ); + } + // algoliasearch v5 uses the global `fetch` and `crypto` APIs by + // default — works on Cloudflare Workers, Bun, Deno, modern Node. + // v4 (with crypto / node:http imports) does not run on Workers. + cachedClient = algoliasearch(c.applicationId, key); + return cachedClient; +} + +// --------------------------------------------------------------------------- +// CMS block adapter +// --------------------------------------------------------------------------- + +/** + * Best-effort init from a CMS block — mirrors `initMagentoFromBlocks`. + * + * Resolves `adminApiKey` via the shared `resolveSecret` from + * `@decocms/start/sdk/crypto`, which walks: plain string → `.get()` + * accessor → AES-CBC decrypt of `.encrypted` (using `DECO_CRYPTO_KEY`) + * → `process.env[name]` fallback. Previously this init had its own + * local helper that only consulted `process.env`, which meant any + * site relying on the encrypted-secret round-trip (the production + * Deco CMS default) silently produced `adminApiKey: ""` and + * `getAlgoliaClient()` either threw or fell back to `searchApiKey`. + * + * Async because the AES decrypt is async — site setups must `await` + * the call before any algolia loader fires. + * + * The block is conventionally keyed `deco-algolia` (matches the prod + * Fresh sites' admin block name), but a custom key can be passed for + * sites that named theirs differently. Returns true if the block was + * found and applied, false otherwise. + */ +export async function initAlgoliaFromBlocks( + blocks: Record, + blockKey = "deco-algolia", +): Promise { + const block = blocks[blockKey] as Record | undefined; + if (!block) return false; + + const { resolveSecret } = await import("@decocms/blocks/sdk/crypto"); + + const applicationId = typeof block.applicationId === "string" ? block.applicationId : ""; + const searchApiKey = typeof block.searchApiKey === "string" ? block.searchApiKey : ""; + + const adminApiKeyEnvName: string = + block.adminApiKey && + typeof block.adminApiKey === "object" && + typeof (block.adminApiKey as { name?: unknown }).name === "string" + ? (block.adminApiKey as { name: string }).name + : ""; + const adminApiKey = (await resolveSecret(block.adminApiKey, adminApiKeyEnvName)) ?? ""; + + configureAlgolia({ applicationId, searchApiKey, adminApiKey }); + return true; +} + +// Re-exported for convenience so consumers can `import { SearchClient } +// from "@decocms/apps/algolia/client"` without depending on the npm +// path explicitly. +export type { SearchClient }; diff --git a/packages/apps-algolia/src/index.ts b/packages/apps-algolia/src/index.ts new file mode 100644 index 0000000..a30f33a --- /dev/null +++ b/packages/apps-algolia/src/index.ts @@ -0,0 +1,12 @@ +/** + * Algolia app entry point for @decocms/apps. + * Re-exports client config + initializer + types. + * + * For loaders, use sub-path imports: + * import client from "@decocms/apps/algolia/loaders/client" + * + * For the SDK SearchClient directly (no proxy hop on the server): + * import { getAlgoliaClient } from "@decocms/apps/algolia/client" + */ +export * from "./client"; +export type { AlgoliaConfig, Indices } from "./types"; diff --git a/packages/apps-algolia/src/loaders/client.ts b/packages/apps-algolia/src/loaders/client.ts new file mode 100644 index 0000000..e25c2e0 --- /dev/null +++ b/packages/apps-algolia/src/loaders/client.ts @@ -0,0 +1,22 @@ +/** + * Returns the configured Algolia SearchClient. + * + * Mirrors `apps/algolia/loaders/client.ts` (deco-cx/apps) so site code + * doing `await invoke.algolia.loaders.client({})` keeps the same call + * shape during the Fresh → TanStack migration. New code in the same + * module can also `import { getAlgoliaClient } from + * "@decocms/apps/algolia/client"` directly, skipping the invoke + * round-trip when on the server. + */ + +import { getAlgoliaClient, type SearchClient } from "../client"; + +/** + * @title Algolia Search Client + * @description Returns the SDK SearchClient configured at app boot. + */ +export default function loader(): SearchClient { + return getAlgoliaClient(); +} + +export type { SearchClient }; diff --git a/packages/apps-algolia/src/types.ts b/packages/apps-algolia/src/types.ts new file mode 100644 index 0000000..04a494b --- /dev/null +++ b/packages/apps-algolia/src/types.ts @@ -0,0 +1,44 @@ +/** + * Shared Algolia types. + * + * Kept as a separate module so consumers can import types without + * pulling in the `algoliasearch` runtime (which is only needed by the + * client/loader code). + */ + +/** + * Subset of the storefront-shaped Algolia config the app boots from. + * Mirrors the original `apps/algolia/mod.ts` props shape so existing + * CMS blocks (`{__resolveType: "site/apps/deco/algolia.ts", ...}`) + * keep working byte-for-byte during the migration. + */ +export interface AlgoliaConfig { + /** + * Algolia application ID. Find it under + * https://dashboard.algolia.com/account/api-keys/all + */ + applicationId: string; + /** + * Search-only API key — safe to ship to the browser. Used by the + * client-side search proxy and SSR loaders that don't need writes. + */ + searchApiKey: string; + /** + * Admin API key (NEVER ship to the browser). Used by the SDK + * instance because some operations (indexing, settings) require + * admin scope. Server-side only. + */ + adminApiKey: string; +} + +/** + * Canonical Algolia index slugs used by Deco storefronts. Stays here + * so loaders share the same type without each one redeclaring the + * union. Sites with custom index names just pass strings (loaders + * accept `string`, this constant union is for autocomplete). + */ +export type Indices = + | "products" + | "products_price_asc" + | "products_price_desc" + | "products_query_suggestions"; diff --git a/packages/apps-algolia/tsconfig.json b/packages/apps-algolia/tsconfig.json new file mode 100644 index 0000000..42386d3 --- /dev/null +++ b/packages/apps-algolia/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"] +} From 570339349b1b7ab0139ae1d2ffd00045b170edf5 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:26:59 -0300 Subject: [PATCH 38/85] chore: update bun.lock for @decocms/apps-algolia --- bun.lock | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/bun.lock b/bun.lock index 1cd95ce..ad5116c 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,29 @@ "vite": "^6.0.0", }, }, + "packages/apps-algolia": { + "name": "@decocms/apps-algolia", + "version": "0.0.0", + "dependencies": { + "@decocms/apps-commerce": "workspace:*", + "@decocms/blocks": "workspace:*", + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "algoliasearch": "^5.53.0", + "knip": "^5.86.0", + "typescript": "^5.9.0", + }, + "peerDependencies": { + "algoliasearch": "^5", + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + "optionalPeers": [ + "algoliasearch", + ], + }, "packages/apps-commerce": { "name": "@decocms/apps-commerce", "version": "0.0.0", @@ -266,6 +289,34 @@ "@actions/io": ["@actions/io@3.0.2", "", {}, "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw=="], + "@algolia/abtesting": ["@algolia/abtesting@1.21.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-uXj0rgk30EpsKvOpuS+R+1XFDrnm56hED1Lz56e8uBkZdKCxw99LS2U8eXBqAHYU8kpkbsnV1GC8velBG070Hg=="], + + "@algolia/client-abtesting": ["@algolia/client-abtesting@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-y7Epol8HcjlBxKXHhyhfFPFhm78B3P6x9cCbCyGTdxjsdVCptXCy5hpkZWxjGpnaLHvWsHS4QRF0TiBOLst2xg=="], + + "@algolia/client-analytics": ["@algolia/client-analytics@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-8Pxj2VVmpM2d+UZufnlTq7T1QIcYPVugLV5XC50PnHsV5uRM9CSoYkg2Y+CwqwRk2La0xK5QsfZ0obIU+9XftQ=="], + + "@algolia/client-common": ["@algolia/client-common@5.55.2", "", {}, "sha512-9L4IpIYUqA63a7sw1trnHQGUvwiAjKz67nsgDnal98JGAc7wyposRb0Iag+eiMuyzFFaSHLe2/rGyIo+PafRBA=="], + + "@algolia/client-insights": ["@algolia/client-insights@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-ZBm2ytY5EHFcj+kjNsXxMNO/TGlOHe2fBFXGKHJOM1bk1rAy4o2YI+d9oV/w/jrqx44pvJMJlc8X6vKnCuDgUQ=="], + + "@algolia/client-personalization": ["@algolia/client-personalization@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-3FGVW/jDk7sdYwqa2NKnF/qXWcttc4bvGrwNbvqz3VoWSRv42CNvRk+3Y9QJFIUf1vY50hAuVWUoFKdyc8vaXA=="], + + "@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-JsG8LovDAYul5t8e533tZ3O1uZILxso5zsTtB7ONc5RJ8ACdTxAAC/jaOnsBNYb+x+STP7fzx/Iro55v5DNgoQ=="], + + "@algolia/client-search": ["@algolia/client-search@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-5wDnoIfC75zJ2MSHv5SSzTlRL2z7jQMbqQ5jrzottuq2p3oBObv8pD/JpXWu8pRaimaxNr3/Bs/KZIGVXxJ7hg=="], + + "@algolia/ingestion": ["@algolia/ingestion@1.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-da+SC6ikpza98W7C5ChsKEQDvZc8PQLQ0sxmQ5yMRsHpdD3iPKnclJA6ViB5Nr5T9qOX+IDswC6AyqY4V3rtug=="], + + "@algolia/monitoring": ["@algolia/monitoring@1.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-Y8kEcPqCiIEeaGv83l9RRA09mfYECqAJHNnOyEtZc9UirI6XBMUyFVss/sSeYUiV/Lf30hkbWcl00V1uXsf86Q=="], + + "@algolia/recommend": ["@algolia/recommend@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-5zmobuCQqFZkx+84Nt+suL7vo6jTh2CfAs2ndDSeTS2QHvnzP8YEEGWtWftjyACI0cK/FuH8urWwCHP+d2j8TA=="], + + "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2" } }, "sha512-qnGUUuWG66dRMnr33owLsrYIh9fHVxtU4R2rd3SpneAHuoAUcGbDOWNrj05glVU6M8yOqo9gQ22K8zpz0I8Xpg=="], + + "@algolia/requester-fetch": ["@algolia/requester-fetch@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2" } }, "sha512-lKZ5uhafMvR7dWCJEyuaeyZitid1I3ICx+k0vGf5x/ktdIQvc7bndCiOPpmIDqUmN26FE3jTehkAzSqee95G2Q=="], + + "@algolia/requester-node-http": ["@algolia/requester-node-http@5.55.2", "", { "dependencies": { "@algolia/client-common": "5.55.2" } }, "sha512-Zc90xvKWUvxcNicvvTO9Pr/hT2TAnkixOIzJm/KMj5Ptm2pKjk71ngTsdkbRtJQvhZ2Kr9N1YdIjLrNHB5P2xw=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.6" } }, "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw=="], "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.0.3", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7" } }, "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA=="], @@ -346,6 +397,8 @@ "@deco-cx/warp-node": ["@deco-cx/warp-node@0.3.23", "", { "dependencies": { "undici": "^6.21.0", "ws": "^8.18.0" } }, "sha512-GMh6Msj62wAvc+YbmIc8FoaIZy/cGUlEwR+FbgDl/08TomJRd3jCV8Tbrb38dIAdHlkmEc5UviLAnx5rfet3OQ=="], + "@decocms/apps-algolia": ["@decocms/apps-algolia@workspace:packages/apps-algolia"], + "@decocms/apps-commerce": ["@decocms/apps-commerce@workspace:packages/apps-commerce"], "@decocms/apps-magento": ["@decocms/apps-magento@workspace:packages/apps-magento"], @@ -786,6 +839,8 @@ "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], + "algoliasearch": ["algoliasearch@5.55.2", "", { "dependencies": { "@algolia/abtesting": "1.21.2", "@algolia/client-abtesting": "5.55.2", "@algolia/client-analytics": "5.55.2", "@algolia/client-common": "5.55.2", "@algolia/client-insights": "5.55.2", "@algolia/client-personalization": "5.55.2", "@algolia/client-query-suggestions": "5.55.2", "@algolia/client-search": "5.55.2", "@algolia/ingestion": "1.55.2", "@algolia/monitoring": "1.55.2", "@algolia/recommend": "5.55.2", "@algolia/requester-browser-xhr": "5.55.2", "@algolia/requester-fetch": "5.55.2", "@algolia/requester-node-http": "5.55.2" } }, "sha512-OyacJsaeuLUvGWOynNqYc6sx88XvyoG39wMT8SYqL3l9wwaorDW/LPRbUPfhzw0bWsUWzNCZTnFYOrWFBKsUaw=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], From 646bd94430ecbfedf0c4025b994c9132eae3891e Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:32:01 -0300 Subject: [PATCH 39/85] feat(apps-salesforce): migrate salesforce/ from apps-start --- packages/apps-salesforce/package.json | 32 +++ .../src/__tests__/httpClient.test.ts | 201 ++++++++++++++++ .../src/__tests__/parseUserCookie.test.ts | 89 +++++++ .../src/__tests__/transform.test.ts | 227 ++++++++++++++++++ packages/apps-salesforce/src/index.ts | 33 +++ .../src/loaders/products/list.ts | 139 +++++++++++ .../src/loaders/products/listCart.ts | 151 ++++++++++++ .../src/loaders/products/listRecomended.ts | 121 ++++++++++ packages/apps-salesforce/src/types.ts | 108 +++++++++ .../apps-salesforce/src/utils/httpClient.ts | 154 ++++++++++++ .../src/utils/parseUserCookie.ts | 41 ++++ .../apps-salesforce/src/utils/transform.ts | 156 ++++++++++++ packages/apps-salesforce/tsconfig.json | 7 + 13 files changed, 1459 insertions(+) create mode 100644 packages/apps-salesforce/package.json create mode 100644 packages/apps-salesforce/src/__tests__/httpClient.test.ts create mode 100644 packages/apps-salesforce/src/__tests__/parseUserCookie.test.ts create mode 100644 packages/apps-salesforce/src/__tests__/transform.test.ts create mode 100644 packages/apps-salesforce/src/index.ts create mode 100644 packages/apps-salesforce/src/loaders/products/list.ts create mode 100644 packages/apps-salesforce/src/loaders/products/listCart.ts create mode 100644 packages/apps-salesforce/src/loaders/products/listRecomended.ts create mode 100644 packages/apps-salesforce/src/types.ts create mode 100644 packages/apps-salesforce/src/utils/httpClient.ts create mode 100644 packages/apps-salesforce/src/utils/parseUserCookie.ts create mode 100644 packages/apps-salesforce/src/utils/transform.ts create mode 100644 packages/apps-salesforce/tsconfig.json diff --git a/packages/apps-salesforce/package.json b/packages/apps-salesforce/package.json new file mode 100644 index 0000000..22f444f --- /dev/null +++ b/packages/apps-salesforce/package.json @@ -0,0 +1,32 @@ +{ + "name": "@decocms/apps-salesforce", + "version": "0.0.0", + "type": "module", + "description": "Deco commerce app: Salesforce Commerce Cloud integration", + "repository": { "type": "git", "url": "https://github.com/decocms/blocks.git", "directory": "packages/apps-salesforce" }, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts", + "./utils/*": "./src/utils/*.ts", + "./loaders/products/*": "./src/loaders/products/*.ts" + }, + "scripts": { + "build": "tsc", + "test": "vitest run --root ../.. packages/apps-salesforce/", + "typecheck": "tsc --noEmit", + "lint:unused": "knip" + }, + "dependencies": { + "@decocms/blocks": "workspace:*", + "@decocms/apps-commerce": "workspace:*" + }, + "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "knip": "^5.86.0", + "typescript": "^5.9.0" + }, + "publishConfig": { "registry": "https://registry.npmjs.org", "access": "public" } +} diff --git a/packages/apps-salesforce/src/__tests__/httpClient.test.ts b/packages/apps-salesforce/src/__tests__/httpClient.test.ts new file mode 100644 index 0000000..23f0138 --- /dev/null +++ b/packages/apps-salesforce/src/__tests__/httpClient.test.ts @@ -0,0 +1,201 @@ +/** + * Tests for utils/httpClient.ts. + * + * The legacy `apps/utils/http.ts` indexed-route Proxy is what every + * Deno-era loader called into, so this test file pins the call shapes + * existing site code still uses: + * - `client["POST /api2/event/:dataset"]({ dataset }, { body })` + * - `client.get(path)` / `client.post(path, body)` convenience methods + * - `:name` placeholder substitution and `*name` legacy Deno-era syntax + * - default `x-requested-with` header propagation + * - `{ json, ok, status, headers }` response shape + * + * `fetch` is mocked so we never touch the network. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createHttpClient } from "../utils/httpClient"; + +interface CallRecord { + url: string; + init: { + method?: string; + body?: string; + headers: Record; + }; +} + +type MockedFetch = typeof fetch & { + mock: { calls: [string, CallRecord["init"]][] }; +}; + +function makeFetch( + jsonBody: unknown = { ok: true }, + init: { status?: number; headers?: Record } = {}, +): MockedFetch { + return vi.fn(async () => ({ + json: async () => jsonBody, + ok: (init.status ?? 200) < 400, + status: init.status ?? 200, + headers: new Headers(init.headers ?? {}), + })) as unknown as MockedFetch; +} + +function callAt(fetcher: MockedFetch, idx: number): CallRecord { + const call = fetcher.mock.calls[idx]; + return { url: call[0], init: call[1] }; +} + +describe("createHttpClient", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("strips trailing slash from base URL", async () => { + const fetcher = makeFetch(); + const client = createHttpClient({ base: "https://api.example.com/", fetcher }); + await client.get("/health"); + expect(callAt(fetcher, 0).url).toBe("https://api.example.com/health"); + }); + + it("uses global fetch when no fetcher is provided", async () => { + globalThis.fetch = makeFetch({ ok: true }); + const client = createHttpClient({ base: "https://api.example.com" }); + const result = await client.get("/health"); + expect(result).toEqual({ ok: true }); + expect(globalThis.fetch).toHaveBeenCalledOnce(); + }); + + describe(".get / .post convenience methods", () => { + it(".get returns parsed JSON directly", async () => { + const fetcher = makeFetch({ items: [1, 2, 3] }); + const client = createHttpClient({ base: "https://api.example.com", fetcher }); + const result = await client.get("/things"); + expect(result).toEqual({ items: [1, 2, 3] }); + expect(callAt(fetcher, 0).url).toBe("https://api.example.com/things"); + }); + + it(".post sends JSON body with Content-Type header", async () => { + const fetcher = makeFetch({ created: true }); + const client = createHttpClient({ base: "https://api.example.com", fetcher }); + await client.post("/things", { name: "thing" }); + const { init } = callAt(fetcher, 0); + expect(init.method).toBe("POST"); + expect(init.body).toBe(JSON.stringify({ name: "thing" })); + expect(init.headers["Content-Type"]).toBe("application/json"); + }); + + it("merges default headers from options into request", async () => { + const fetcher = makeFetch(); + const client = createHttpClient({ + base: "https://api.example.com", + fetcher, + headers: { "x-requested-with": "XMLHttpRequest" }, + }); + await client.post("/things", { name: "x" }); + const { init } = callAt(fetcher, 0); + expect(init.headers["x-requested-with"]).toBe("XMLHttpRequest"); + }); + + it("accepts a Headers object for default headers", async () => { + const fetcher = makeFetch(); + const client = createHttpClient({ + base: "https://api.example.com", + fetcher, + headers: new Headers({ "x-token": "abc" }), + }); + await client.get("/things"); + const { init } = callAt(fetcher, 0); + expect(init.headers["x-token"]).toBe("abc"); + }); + }); + + describe("indexed-route Proxy syntax", () => { + it("replaces `:name` path placeholders with the matching param", async () => { + const fetcher = makeFetch({ campaignResponses: [] }); + const client = createHttpClient({ base: "https://api.example.com", fetcher }); + await client["POST /api2/event/:dataset"]({ dataset: "production" }, { body: { foo: 1 } }); + expect(callAt(fetcher, 0).url).toBe("https://api.example.com/api2/event/production"); + }); + + it("url-encodes placeholder values", async () => { + const fetcher = makeFetch(); + const client = createHttpClient({ base: "https://api.example.com", fetcher }); + await client["GET /catalog/:slug"]({ slug: "shoes & boots" }); + expect(callAt(fetcher, 0).url).toBe("https://api.example.com/catalog/shoes%20%26%20boots"); + }); + + it("removes leftover `*name` placeholders when no value is provided", async () => { + const fetcher = makeFetch(); + const client = createHttpClient({ base: "https://api.example.com", fetcher }); + await client["GET /catalog/*path"]({}); + expect(callAt(fetcher, 0).url).toBe("https://api.example.com/catalog"); + }); + + it("substitutes `*name` placeholders when value is present", async () => { + const fetcher = makeFetch(); + const client = createHttpClient({ base: "https://api.example.com", fetcher }); + await client["GET /catalog/*path"]({ path: "shoes/boots" }); + expect(callAt(fetcher, 0).url).toBe("https://api.example.com/catalog/shoes/boots"); + }); + + it("serialises remaining params as the body on non-GET requests", async () => { + const fetcher = makeFetch(); + const client = createHttpClient({ base: "https://api.example.com", fetcher }); + await client["POST /api2/event/:dataset"]({ + dataset: "production", + extraField: "value", + }); + const { init } = callAt(fetcher, 0); + expect(JSON.parse(init.body ?? "")).toEqual({ extraField: "value" }); + }); + + it("prefers explicit { body } over remaining params", async () => { + const fetcher = makeFetch(); + const client = createHttpClient({ base: "https://api.example.com", fetcher }); + await client["POST /api2/event/:dataset"]( + { dataset: "production", ignored: "yes" }, + { body: { real: "body" } }, + ); + const { init } = callAt(fetcher, 0); + expect(JSON.parse(init.body ?? "")).toEqual({ real: "body" }); + }); + + it("encodes remaining params as querystring on GET", async () => { + const fetcher = makeFetch(); + const client = createHttpClient({ base: "https://api.example.com", fetcher }); + await client["GET /search"]({ q: "shoes", limit: 12 }); + const call = callAt(fetcher, 0); + expect(call.url).toBe("https://api.example.com/search?q=shoes&limit=12"); + expect(call.init.body).toBeUndefined(); + }); + + it("returns { json, ok, status, headers } from indexed routes", async () => { + const fetcher = makeFetch({ result: 1 }, { status: 201, headers: { etag: "abc" } }); + const client = createHttpClient({ base: "https://api.example.com", fetcher }); + const response = await client["POST /things"]({ name: "x" }); + expect(response.status).toBe(201); + expect(response.ok).toBe(true); + expect(response.headers.get("etag")).toBe("abc"); + expect(await response.json()).toEqual({ result: 1 }); + }); + + it("appends query when no params remain but route already has `?`", async () => { + const fetcher = makeFetch(); + const client = createHttpClient({ base: "https://api.example.com", fetcher }); + await client["GET /search?fixed=1"]({ q: "shoes" }); + expect(callAt(fetcher, 0).url).toBe("https://api.example.com/search?fixed=1&q=shoes"); + }); + + it("returns undefined for unknown property accesses", () => { + const client = createHttpClient({ base: "https://api.example.com" }); + // biome-ignore lint/suspicious/noExplicitAny: type-erased access for test + expect((client as any).somethingMadeUp).toBeUndefined(); + }); + }); +}); diff --git a/packages/apps-salesforce/src/__tests__/parseUserCookie.test.ts b/packages/apps-salesforce/src/__tests__/parseUserCookie.test.ts new file mode 100644 index 0000000..b43bd33 --- /dev/null +++ b/packages/apps-salesforce/src/__tests__/parseUserCookie.test.ts @@ -0,0 +1,89 @@ +/** + * Tests for utils/parseUserCookie.ts. + * + * Locks the contract that downstream loaders depend on: + * - puid wins over uuid (signed-in identity > anonymous device id) + * - uuid is used when puid is missing + * - empty / missing / malformed cookies fall back to "anonymous" + * + * The fallback is load-bearing — Evergage rejects requests with a + * missing user identifier, so an unparseable cookie must not surface + * as `{}`. + */ +import { describe, expect, it } from "vitest"; +import { parseUserCookie } from "../utils/parseUserCookie"; + +const encode = (obj: unknown) => encodeURIComponent(JSON.stringify(obj)); + +describe("parseUserCookie", () => { + it("returns anonymous fallback when cookie is undefined", () => { + expect(parseUserCookie(undefined)).toEqual({ anonymousId: "anonymous" }); + }); + + it("returns anonymous fallback when cookie is null", () => { + expect(parseUserCookie(null)).toEqual({ anonymousId: "anonymous" }); + }); + + it("returns anonymous fallback when cookie is empty string", () => { + expect(parseUserCookie("")).toEqual({ anonymousId: "anonymous" }); + }); + + it("returns anonymous fallback when cookie is whitespace only", () => { + expect(parseUserCookie(" ")).toEqual({ anonymousId: "anonymous" }); + }); + + it("returns anonymous fallback when cookie is not valid JSON", () => { + expect(parseUserCookie("not-json")).toEqual({ anonymousId: "anonymous" }); + }); + + it("returns anonymous fallback when JSON value is not an object", () => { + expect(parseUserCookie(encode("string-value"))).toEqual({ anonymousId: "anonymous" }); + expect(parseUserCookie(encode(42))).toEqual({ anonymousId: "anonymous" }); + expect(parseUserCookie(encode(null))).toEqual({ anonymousId: "anonymous" }); + }); + + it("maps puid to encryptedId", () => { + expect(parseUserCookie(encode({ puid: "user-123" }))).toEqual({ + encryptedId: "user-123", + }); + }); + + it("maps uuid to anonymousId", () => { + expect(parseUserCookie(encode({ uuid: "device-abc" }))).toEqual({ + anonymousId: "device-abc", + }); + }); + + it("prefers puid over uuid when both are present", () => { + expect(parseUserCookie(encode({ puid: "user-123", uuid: "device-abc" }))).toEqual({ + encryptedId: "user-123", + }); + }); + + it("returns anonymous fallback when puid is empty string", () => { + expect(parseUserCookie(encode({ puid: "", uuid: "device-abc" }))).toEqual({ + anonymousId: "device-abc", + }); + }); + + it("returns anonymous fallback when both puid and uuid are empty/missing", () => { + expect(parseUserCookie(encode({}))).toEqual({ anonymousId: "anonymous" }); + expect(parseUserCookie(encode({ puid: "", uuid: "" }))).toEqual({ + anonymousId: "anonymous", + }); + }); + + it("returns anonymous fallback when puid/uuid are non-string types", () => { + expect(parseUserCookie(encode({ puid: 42, uuid: true }))).toEqual({ + anonymousId: "anonymous", + }); + }); + + it("decodes URL-encoded values", () => { + // Evergage encodes the JSON with encodeURIComponent — make sure + // embedded special chars (e.g. = / : in base64 ids) round-trip. + const puid = "abc=def+ghi/jkl"; + const cookie = encodeURIComponent(JSON.stringify({ puid })); + expect(parseUserCookie(cookie)).toEqual({ encryptedId: puid }); + }); +}); diff --git a/packages/apps-salesforce/src/__tests__/transform.test.ts b/packages/apps-salesforce/src/__tests__/transform.test.ts new file mode 100644 index 0000000..e4ad57f --- /dev/null +++ b/packages/apps-salesforce/src/__tests__/transform.test.ts @@ -0,0 +1,227 @@ +/** + * Tests for utils/transform.ts. + * + * The Salesforce transformer is the only spot in the upstream package + * where consumer-specific behavior (the dataset's custom column shape) + * shows through, via the `propertyMapper` hook. The tests here lock: + * + * - default mapper outputs the always-present Evergage columns + * (itemType, categories) without dragging in dataset-specific + * fields, + * - a custom propertyMapper sees the raw Evergage product (including + * custom columns via the index signature), + * - schema.org shape is stable (productID precedence, AggregateOffer + * surface, isVariantOf hasVariant). + * + * These match the regressions the legacy `apps/salesforce/utils/ + * transform.ts` watched for over its lifetime. + */ +import { describe, expect, it } from "vitest"; +import type { SalesforceProduct } from "../types"; +import { + createProductTransformer, + type PropertyMapper, + toImages, + toOffer, +} from "../utils/transform"; + +const baseProduct = (overrides: Partial = {}): SalesforceProduct => ({ + id: "SKU-42", + name: "Test Product", + price: 100, + salePrice: 80, + inventoryCount: 7, + imageUrls: ["https://cdn.example.com/p/42.jpg"], + url: "https://loja.example.com/p/test", + currency: "BRL", + itemType: "simple", + categories: ["category-a", "category-b"], + ...overrides, +}); + +describe("toOffer", () => { + it("emits InStock when inventoryCount > 0", () => { + const [offer] = toOffer({ product: baseProduct(), currencyCode: "BRL" }); + expect(offer.availability).toBe("https://schema.org/InStock"); + expect(offer.inventoryLevel?.value).toBe(7); + }); + + it("emits OutOfStock when inventoryCount is 0", () => { + const [offer] = toOffer({ + product: baseProduct({ inventoryCount: 0 }), + currencyCode: "BRL", + }); + expect(offer.availability).toBe("https://schema.org/OutOfStock"); + }); + + it("falls back to price when salePrice is missing", () => { + const [offer] = toOffer({ + product: baseProduct({ salePrice: undefined }), + currencyCode: "BRL", + }); + expect(offer.price).toBe(100); + }); + + it("uses salePrice as the primary price when present", () => { + const [offer] = toOffer({ product: baseProduct(), currencyCode: "BRL" }); + expect(offer.price).toBe(80); + }); + + it("threads currencyCode into priceCurrency", () => { + const [offer] = toOffer({ product: baseProduct(), currencyCode: "USD" }); + expect(offer.priceCurrency).toBe("USD"); + }); + + it("emits ListPrice + SalePrice priceSpecification entries", () => { + const [offer] = toOffer({ product: baseProduct(), currencyCode: "BRL" }); + expect(offer.priceSpecification).toEqual([ + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/ListPrice", + price: 100, + }, + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/SalePrice", + price: 80, + }, + ]); + }); +}); + +describe("toImages", () => { + it("maps each imageUrl to a schema.org ImageObject", () => { + const images = toImages( + baseProduct({ + imageUrls: ["https://cdn.example.com/a.jpg", "https://cdn.example.com/b.jpg"], + }), + ); + expect(images).toEqual([ + { + "@type": "ImageObject", + encodingFormat: "image", + alternateName: "https://cdn.example.com/a.jpg", + url: "https://cdn.example.com/a.jpg", + }, + { + "@type": "ImageObject", + encodingFormat: "image", + alternateName: "https://cdn.example.com/b.jpg", + url: "https://cdn.example.com/b.jpg", + }, + ]); + }); + + it("returns an empty array when imageUrls is empty", () => { + expect(toImages(baseProduct({ imageUrls: [] }))).toEqual([]); + }); +}); + +describe("createProductTransformer", () => { + it("uses default mapper when no propertyMapper is passed", () => { + const transform = createProductTransformer(); + const out = transform({ product: baseProduct(), options: { currencyCode: "BRL" } }); + expect(out.additionalProperty).toEqual([ + { "@type": "PropertyValue", name: "itemType", value: "simple" }, + { "@type": "PropertyValue", name: "category", value: "category-a, category-b" }, + ]); + }); + + it("default mapper skips itemType when absent", () => { + const transform = createProductTransformer(); + const out = transform({ + product: baseProduct({ itemType: undefined }), + options: { currencyCode: "BRL" }, + }); + expect(out.additionalProperty).toEqual([ + { "@type": "PropertyValue", name: "category", value: "category-a, category-b" }, + ]); + }); + + it("default mapper skips categories when empty", () => { + const transform = createProductTransformer(); + const out = transform({ + product: baseProduct({ categories: [] }), + options: { currencyCode: "BRL" }, + }); + expect(out.additionalProperty).toEqual([ + { "@type": "PropertyValue", name: "itemType", value: "simple" }, + ]); + }); + + it("custom propertyMapper sees site-specific extras via index signature", () => { + const granadoMapper: PropertyMapper = (product) => [ + { "@type": "PropertyValue", name: "marca", value: String(product.Marca ?? "") }, + { "@type": "PropertyValue", name: "volume", value: String(product.Volume ?? "") }, + ]; + const transform = createProductTransformer({ propertyMapper: granadoMapper }); + const out = transform({ + product: baseProduct({ Marca: "Granado", Volume: "200ml" }), + options: { currencyCode: "BRL" }, + }); + expect(out.additionalProperty).toEqual([ + { "@type": "PropertyValue", name: "marca", value: "Granado" }, + { "@type": "PropertyValue", name: "volume", value: "200ml" }, + ]); + }); + + it("prefers idMagento over id for productID when present", () => { + const transform = createProductTransformer(); + const out = transform({ + product: baseProduct({ idMagento: "9999" }), + options: { currencyCode: "BRL" }, + }); + expect(out.productID).toBe("9999"); + expect(out.sku).toBe("SKU-42"); + }); + + it("uses id for productID when idMagento is missing", () => { + const transform = createProductTransformer(); + const out = transform({ product: baseProduct(), options: { currencyCode: "BRL" } }); + expect(out.productID).toBe("SKU-42"); + }); + + it("trims whitespace from name", () => { + const transform = createProductTransformer(); + const out = transform({ + product: baseProduct({ name: " Padded Name " }), + options: { currencyCode: "BRL" }, + }); + expect(out.name).toBe("Padded Name"); + expect(out.isVariantOf?.name).toBe("Padded Name"); + }); + + it("falls back to product.currency when options.currencyCode is omitted", () => { + const transform = createProductTransformer(); + const out = transform({ + product: baseProduct({ currency: "EUR" }), + options: {}, + }); + const firstOffer = out.offers?.offers[0]; + expect(firstOffer?.priceCurrency).toBe("EUR"); + }); + + it("places one variant under isVariantOf.hasVariant", () => { + const transform = createProductTransformer(); + const out = transform({ product: baseProduct(), options: { currencyCode: "BRL" } }); + expect(out.isVariantOf?.hasVariant).toHaveLength(1); + expect(out.isVariantOf?.hasVariant?.[0].sku).toBe("SKU-42"); + }); + + it("AggregateOffer surfaces high/low correctly when on sale", () => { + const transform = createProductTransformer(); + const out = transform({ product: baseProduct(), options: { currencyCode: "BRL" } }); + expect(out.offers?.highPrice).toBe(100); + expect(out.offers?.lowPrice).toBe(80); + }); + + it("AggregateOffer high === low when no salePrice is set", () => { + const transform = createProductTransformer(); + const out = transform({ + product: baseProduct({ salePrice: undefined }), + options: { currencyCode: "BRL" }, + }); + expect(out.offers?.highPrice).toBe(100); + expect(out.offers?.lowPrice).toBe(100); + }); +}); diff --git a/packages/apps-salesforce/src/index.ts b/packages/apps-salesforce/src/index.ts new file mode 100644 index 0000000..370389b --- /dev/null +++ b/packages/apps-salesforce/src/index.ts @@ -0,0 +1,33 @@ +/** + * Salesforce Marketing Cloud Personalization (Evergage) app entry. + * + * Unlike `magento` / `algolia`, the Salesforce loaders here are + * stateless — every loader takes its `baseUrl` / `dataset` / + * `campaignId` / `cookieName` via props so the same package can power + * multiple Evergage datasets in a single worker without a global + * configure step. Sites just import the loader(s) they need. + * + * For loaders, use sub-path imports: + * import list from "@decocms/apps/salesforce/loaders/products/list" + * import listRecomended from "@decocms/apps/salesforce/loaders/products/listRecomended" + * import listCart from "@decocms/apps/salesforce/loaders/products/listCart" + * + * For the transformer (sites typically build their own `propertyMapper` + * over a dataset's custom columns): + * import { createProductTransformer } from "@decocms/apps/salesforce/utils/transform" + */ +export type { + CampaignResponse, + ParsedUserCookie, + PersonalizationBody, + PersonalizationLineItem, + PersonalizationResponse, + SalesforceProduct, +} from "./types"; +export { createHttpClient, type HttpClientOptions } from "./utils/httpClient"; +export { parseUserCookie } from "./utils/parseUserCookie"; +export { + createProductTransformer, + type ProductTransformerOptions, + type PropertyMapper, +} from "./utils/transform"; diff --git a/packages/apps-salesforce/src/loaders/products/list.ts b/packages/apps-salesforce/src/loaders/products/list.ts new file mode 100644 index 0000000..5188de7 --- /dev/null +++ b/packages/apps-salesforce/src/loaders/products/list.ts @@ -0,0 +1,139 @@ +/** + * Salesforce Marketing Cloud Personalization — campaign list loader. + * + * Hits `POST {baseUrl}/api2/event/:dataset` with the user identifier + * extracted from the Evergage cookie and returns the products attached + * to the matching campaign payload. When no cookie exists (first visit + * / parity bot / SSR), `parseUserCookie` falls back to the literal + * `anonymous` so Evergage still responds with a default campaign + * instead of erroring. + * + * Cookies are read via `getCookies()` from `@tanstack/react-start/server` + * — the request object isn't propagated through the framework's + * `commerceLoader(resolvedProps)` call, so we rely on AsyncLocalStorage + * to recover the original request from inside the deferred section + * server function context. + */ +import type { Product } from "@decocms/apps-commerce/types"; +import type { + CampaignResponse, + PersonalizationBody, + PersonalizationResponse, + SalesforceProduct, +} from "../../types"; +import { createHttpClient } from "../../utils/httpClient"; +import { parseUserCookie } from "../../utils/parseUserCookie"; +import { createProductTransformer, type PropertyMapper } from "../../utils/transform"; + +export interface SalesforceListLoaderProps { + /** + * @title Personalization Base URL + * @description e.g. `https://.us-5.evergage.com` + */ + baseUrl: string; + /** @title Personalization Dataset */ + dataset: string; + /** @title Campaign Id */ + campaignId: string; + /** + * @title Cookie Name + * @description Cookie name Evergage drops on the browser + * (e.g. `_evga_`) + */ + cookieName: string; + /** @title Currency Code (ISO 4217) */ + currencyCode?: string; + /** Custom property mapper passed by site wrappers. */ + propertyMapper?: PropertyMapper; +} + +export interface SalesforceListResult { + "@type": "ProductList"; + list: Product[]; + additionalData: { + title?: string; + campaignId: string; + experienceId?: string; + userGroup?: string; + }; +} + +/** + * Read the named cookie from the in-flight request. Imported lazily so + * the loader stays runtime-agnostic for unit tests (a manual cookie + * value can be passed via `__testCookieOverride`). + */ +async function readCookie(cookieName: string): Promise { + try { + const { getCookies } = await import("@tanstack/react-start/server"); + const cookies = getCookies(); + return cookies?.[cookieName]; + } catch { + return undefined; + } +} + +export default async function salesforceListLoader( + props: SalesforceListLoaderProps, + _req?: Request, +): Promise { + const { baseUrl, dataset, campaignId, cookieName, currencyCode, propertyMapper } = props; + + try { + const rawCookie = await readCookie(cookieName); + const userData = parseUserCookie(rawCookie); + + const client = createHttpClient({ + base: baseUrl, + headers: { "x-requested-with": "XMLHttpRequest" }, + }); + + const requestBody: PersonalizationBody = { + source: { channel: "WebServer", url: `mcp_campaign=${campaignId}` }, + interaction: { name: "Personalization Campaigns" }, + user: userData, + flags: { nonInteractive: true, doNotTrack: false }, + pageView: false, + }; + + const response = (await client["POST /api2/event/:dataset"]( + { dataset }, + { body: requestBody }, + ).then((res: { json: () => Promise }) => + res.json(), + )) as PersonalizationResponse; + + const payload = + response.campaignResponses?.find((item: CampaignResponse) => item.campaignId === campaignId) + ?.payload ?? response.campaignResponses?.[0]?.payload; + + if (!payload?.products?.length) { + return null; + } + + const transform = createProductTransformer({ propertyMapper }); + + return { + "@type": "ProductList", + list: payload.products.map((product: SalesforceProduct) => + transform({ + product, + options: { currencyCode: currencyCode ?? product.currency }, + }), + ), + additionalData: { + title: payload.headerText, + campaignId, + experienceId: payload.experience, + userGroup: payload.userGroup, + }, + }; + } catch (err) { + // The legacy Deno loader swallowed errors and returned null. We + // keep the same return shape (so consumers don't have to handle + // rejections) but log the failure — silent null hides API outages + // and CORS regressions during parity validation. + console.error("[salesforce/products/list] failed:", (err as Error)?.message); + return null; + } +} diff --git a/packages/apps-salesforce/src/loaders/products/listCart.ts b/packages/apps-salesforce/src/loaders/products/listCart.ts new file mode 100644 index 0000000..55e27db --- /dev/null +++ b/packages/apps-salesforce/src/loaders/products/listCart.ts @@ -0,0 +1,151 @@ +/** + * Salesforce Marketing Cloud Personalization — cart-aware recommendations. + * + * Same wire format as `list.ts`, but the interaction name is + * `"Replace Cart"` and the request body carries the current cart's line + * items so Evergage can seed cross-sell / "frequently bought together" + * campaigns from the items already in the basket. + * + * Sites typically call this loader from a "cart drawer" / "cart side panel" + * section. The cart state itself isn't read from cookies here — sites + * resolve their own cart (Magento, Shopify, custom) and pass `items` as + * a flat array. When `items` is empty, the loader short-circuits and + * returns `null` (no cart → no cross-sell). + */ +import type { Product } from "@decocms/apps-commerce/types"; +import type { + CampaignResponse, + PersonalizationBody, + PersonalizationResponse, + SalesforceProduct, +} from "../../types"; +import { createHttpClient } from "../../utils/httpClient"; +import { parseUserCookie } from "../../utils/parseUserCookie"; +import { createProductTransformer, type PropertyMapper } from "../../utils/transform"; + +export interface SalesforceListCartItem { + sku: string; + qty: number; + price: number; +} + +export interface SalesforceListCartProps { + /** + * @title Cart Items + * @description Items currently in the user's cart. Site loaders resolve + * this from their commerce backend (Magento, Shopify, etc.) and pass + * it through. + */ + items: SalesforceListCartItem[]; + /** @title Personalization Base URL */ + baseUrl: string; + /** @title Personalization Dataset */ + dataset: string; + /** @title Campaign Id */ + campaignId: string; + /** @title Cookie Name */ + cookieName: string; + /** @title Currency Code (ISO 4217) */ + currencyCode?: string; + /** + * @title Fallback Title + * @description Shown when the campaign payload has no `headerText` + * (e.g. Granado uses the configured `label` from the CMS block). + */ + title?: string; + /** Custom property mapper. */ + propertyMapper?: PropertyMapper; +} + +export interface SalesforceListCartResult { + "@type": "ProductList"; + list: Product[]; + additionalData: { + title?: string; + campaignId: string; + experienceId?: string; + userGroup?: string; + }; +} + +async function readCookie(cookieName: string): Promise { + try { + const { getCookies } = await import("@tanstack/react-start/server"); + const cookies = getCookies(); + return cookies?.[cookieName]; + } catch { + return undefined; + } +} + +export default async function salesforceListCartLoader( + props: SalesforceListCartProps, + _req?: Request, +): Promise { + const { items, baseUrl, dataset, campaignId, cookieName, currencyCode, title, propertyMapper } = + props; + + if (!items?.length) return null; + + try { + const rawCookie = await readCookie(cookieName); + const userData = parseUserCookie(rawCookie); + + const client = createHttpClient({ + base: baseUrl, + headers: { "x-requested-with": "XMLHttpRequest" }, + }); + + const requestBody: PersonalizationBody = { + source: { channel: "WebServer", url: `mcp_campaign=${campaignId}` }, + interaction: { + name: "Replace Cart", + lineItems: items.map(({ sku, qty, price }) => ({ + catalogObjectType: "Product", + catalogObjectId: sku, + quantity: qty, + price, + })), + }, + user: userData, + flags: { nonInteractive: true, doNotTrack: false }, + pageView: false, + }; + + const response = (await client["POST /api2/event/:dataset"]( + { dataset }, + { body: requestBody }, + ).then((res: { json: () => Promise }) => + res.json(), + )) as PersonalizationResponse; + + const payload = + response.campaignResponses?.find((item: CampaignResponse) => item.campaignId === campaignId) + ?.payload ?? response.campaignResponses?.[0]?.payload; + + if (!payload?.products?.length) { + return null; + } + + const transform = createProductTransformer({ propertyMapper }); + + return { + "@type": "ProductList", + list: payload.products.map((product: SalesforceProduct) => + transform({ + product, + options: { currencyCode: currencyCode ?? product.currency }, + }), + ), + additionalData: { + title: payload.headerText || title, + campaignId, + experienceId: payload.experience, + userGroup: payload.userGroup, + }, + }; + } catch (err) { + console.error("[salesforce/products/listCart] failed:", (err as Error)?.message); + return null; + } +} diff --git a/packages/apps-salesforce/src/loaders/products/listRecomended.ts b/packages/apps-salesforce/src/loaders/products/listRecomended.ts new file mode 100644 index 0000000..d2ce50d --- /dev/null +++ b/packages/apps-salesforce/src/loaders/products/listRecomended.ts @@ -0,0 +1,121 @@ +/** + * Salesforce Marketing Cloud Personalization — recommendations loader. + * + * Same wire format as `list.ts`, but the campaign Evergage runs is the + * "related products" one and the request body includes the + * `viewedProductId` attribute so Evergage can use the in-context PDP + * to seed its recommendation model. + * + * Used on PDP / PDC pages; the upstream site passes the resolved + * `ProductDetailsPage` so we can read `product.sku`. When no product + * context is available the loader still sends the campaign request — + * Evergage will return its default fallback list. + */ +import type { Product, ProductDetailsPage } from "@decocms/apps-commerce/types"; +import type { CampaignResponse, PersonalizationResponse, SalesforceProduct } from "../../types"; +import { createHttpClient } from "../../utils/httpClient"; +import { parseUserCookie } from "../../utils/parseUserCookie"; +import { createProductTransformer, type PropertyMapper } from "../../utils/transform"; + +export interface SalesforceListRecommendedProps { + /** @title Product Id (resolved PDP) */ + productId: ProductDetailsPage | null; + /** @title Personalization Base URL */ + baseUrl: string; + /** @title Personalization Dataset */ + dataset: string; + /** @title Campaign Id */ + campaignId: string; + /** @title Cookie Name */ + cookieName: string; + /** @title Currency Code (ISO 4217) */ + currencyCode?: string; + /** Custom property mapper. */ + propertyMapper?: PropertyMapper; +} + +export interface SalesforceListRecommendedResult { + "@type": "ProductList"; + list: Product[]; + additionalData: { + title?: string; + campaignId: string; + experienceId?: string; + userGroup?: string; + }; +} + +async function readCookie(cookieName: string): Promise { + try { + const { getCookies } = await import("@tanstack/react-start/server"); + const cookies = getCookies(); + return cookies?.[cookieName]; + } catch { + return undefined; + } +} + +export default async function salesforceListRecommendedLoader( + props: SalesforceListRecommendedProps, + _req?: Request, +): Promise { + const { baseUrl, dataset, productId, cookieName, campaignId, currencyCode, propertyMapper } = + props; + + try { + const rawCookie = await readCookie(cookieName); + const userData = parseUserCookie(rawCookie); + + const client = createHttpClient({ + base: baseUrl, + headers: { "x-requested-with": "XMLHttpRequest" }, + }); + + const requestBody = { + source: { channel: "WebServer", url: `mcp_campaign=${campaignId}` }, + interaction: { name: "Personalization Campaigns" }, + user: { + ...userData, + attributes: { viewedProductId: productId?.product?.sku ?? "" }, + }, + flags: { nonInteractive: true, doNotTrack: false }, + pageView: false, + }; + + const response = (await client["POST /api2/event/:dataset"]( + { dataset }, + { body: requestBody }, + ).then((res: { json: () => Promise }) => + res.json(), + )) as PersonalizationResponse; + + const payload = + response.campaignResponses?.find((item: CampaignResponse) => item.campaignId === campaignId) + ?.payload ?? response.campaignResponses?.[0]?.payload; + + if (!payload?.products?.length) { + return null; + } + + const transform = createProductTransformer({ propertyMapper }); + + return { + "@type": "ProductList", + list: payload.products.map((product: SalesforceProduct) => + transform({ + product, + options: { currencyCode: currencyCode ?? product.currency }, + }), + ), + additionalData: { + title: payload.headerText, + campaignId, + experienceId: payload.experience, + userGroup: payload.userGroup, + }, + }; + } catch (err) { + console.error("[salesforce/products/listRecomended] failed:", (err as Error)?.message); + return null; + } +} diff --git a/packages/apps-salesforce/src/types.ts b/packages/apps-salesforce/src/types.ts new file mode 100644 index 0000000..2fe7762 --- /dev/null +++ b/packages/apps-salesforce/src/types.ts @@ -0,0 +1,108 @@ +/** + * Salesforce Marketing Cloud Personalization (Evergage) — request/response + * shapes for the campaign personalization API. + * + * The Evergage product schema is configurable per dataset (each customer + * chooses which fields to expose), so `SalesforceProduct` keeps a minimal + * required surface and accepts arbitrary extras via index signature. + * Site-level transformers can downcast to their own product shape to + * read store-specific custom fields (e.g. `marca`, `linha`, brand-tags). + */ + +export interface SalesforceProduct { + /** Internal Evergage id (string token, often a SKU or catalog id). */ + id: string; + /** Display name. */ + name: string; + /** Base price (list). */ + price: number; + /** Discounted price; falls back to `price` when no promotion is active. */ + salePrice?: number; + /** Stock count from Evergage's catalog feed. */ + inventoryCount: number; + /** Pre-built absolute URLs of product images. */ + imageUrls: string[]; + /** Canonical product URL (full origin + path). */ + url: string; + /** ISO 4217 currency code (e.g. "BRL"). */ + currency: string; + /** Common Evergage column — short product description. */ + description?: string; + /** Optional cross-system identifier (e.g. Magento `entity_id`). */ + idMagento?: string; + /** Item type tag (e.g. "configurable", "simple"). */ + itemType?: string; + /** Generic category trail (Evergage stores these as arrays). */ + categories?: string[]; + /** Site-specific extras — Evergage exposes whatever the dataset schema + * defines (e.g. `Marca`, `Volume`, `Linha`, `freeShipping`). The + * product transformer can read these via the attributeMapper hook. */ + [customField: string]: unknown; +} + +/** + * Single line item in a cart-interaction request body. Evergage uses + * these to seed cart-aware recommendations ("people who bought X also + * bought Y") and abandoned-cart campaigns. + */ +export interface PersonalizationLineItem { + catalogObjectType: string; + catalogObjectId: string; + quantity: number; + price: number; +} + +/** + * Body sent to `POST {baseUrl}/api2/event/:dataset`. The Evergage API is + * "interaction"-based — every request describes an interaction the user + * had, and the response contains the campaigns triggered by that + * interaction (recommendations, popups, etc.). + * + * `interaction.lineItems` is only used by cart-aware campaigns; PDP / + * homepage requests omit it. `user.attributes` carries page-context + * hints (e.g. `viewedProductId` for related-product campaigns). + */ +export interface PersonalizationBody { + source: { + channel: string; + url: string; + }; + interaction: { + name: string; + lineItems?: PersonalizationLineItem[]; + }; + user: { + anonymousId?: string; + encryptedId?: string; + attributes?: Record; + }; + flags: { + nonInteractive: boolean; + doNotTrack: boolean; + }; + pageView: boolean; +} + +export interface CampaignResponse { + campaignId: string; + payload: { + experience?: string; + headerText?: string; + products?: SalesforceProduct[]; + userGroup?: string; + }; +} + +export interface PersonalizationResponse { + campaignResponses?: CampaignResponse[]; +} + +/** + * Cookie shape Evergage drops on the browser (`puid` = persistent user + * id after sign-in, `uuid` = anonymous device id). The site loaders + * read this cookie to send the right user identifier on each request. + */ +export interface ParsedUserCookie { + encryptedId?: string; + anonymousId?: string; +} diff --git a/packages/apps-salesforce/src/utils/httpClient.ts b/packages/apps-salesforce/src/utils/httpClient.ts new file mode 100644 index 0000000..c1a6c94 --- /dev/null +++ b/packages/apps-salesforce/src/utils/httpClient.ts @@ -0,0 +1,154 @@ +/** + * Typed HTTP client compatible with deco-cx/apps' `createHttpClient` + * signature. Accepts both the simple `.get(path)` / `.post(path, body)` + * shape and the indexed-route shape used by legacy Deno loaders: + * + * ```ts + * const client = createHttpClient({ base: "https://api.example.com" }); + * await client["POST /api2/event/:dataset"]({ dataset: "prod" }, { body }); + * await client.get("/healthcheck"); + * ``` + * + * Runtime-agnostic: only requires the global `fetch`. Works on + * Cloudflare Workers, Bun, Deno, and modern Node — no `node:http` + * dependency. Returns `{ json, ok, status, headers }` from indexed + * routes (mirrors the legacy `apps/utils/http.ts` shape so existing + * loaders that call `.then(res => res.json())` keep working). + */ + +export interface HttpClientOptions { + base: string; + headers?: Record | Headers; + fetcher?: typeof fetch; +} + +interface IndexedRouteResponse { + json: () => Promise; + ok: boolean; + status: number; + headers: Headers; +} + +export function createHttpClient<_Routes = unknown>(options: HttpClientOptions) { + const base = options.base.replace(/\/$/, ""); + const fetchImpl = options.fetcher ?? fetch; + const defaultHeaders: Record = + options.headers instanceof Headers + ? Object.fromEntries(options.headers.entries()) + : (options.headers ?? {}); + + const handler: ProxyHandler> = { + get(_target, prop) { + if (prop === "get") { + return async (path: string, init?: RequestInit): Promise => { + const res = await fetchImpl(`${base}${path}`, { + ...init, + headers: { + ...defaultHeaders, + ...((init?.headers as Record) ?? {}), + }, + }); + return res.json() as Promise; + }; + } + if (prop === "post") { + return async (path: string, body: unknown, init?: RequestInit): Promise => { + const res = await fetchImpl(`${base}${path}`, { + method: "POST", + ...init, + headers: { + "Content-Type": "application/json", + ...defaultHeaders, + ...((init?.headers as Record) ?? {}), + }, + body: JSON.stringify(body), + }); + return res.json() as Promise; + }; + } + if (typeof prop === "string" && /^(GET|POST|PUT|PATCH|DELETE)\s+/.test(prop)) { + const spaceIdx = prop.indexOf(" "); + const method = prop.slice(0, spaceIdx); + let apiPath = prop.slice(spaceIdx + 1); + + return async ( + params: Record = {}, + init?: RequestInit & { body?: unknown }, + ): Promise => { + const cleanParams = { ...params }; + + // `:name` path placeholders — replaced with the matching + // param value, then removed from the body/query object. + for (const [key, value] of Object.entries(cleanParams)) { + const placeholder = `:${key}`; + if (apiPath.includes(placeholder) && value != null) { + apiPath = apiPath.replace(placeholder, encodeURIComponent(String(value))); + delete cleanParams[key]; + } + } + + // Legacy `*name` placeholders (Deno-era convention). + const starMatch = apiPath.match(/\*(\w+)/); + if (starMatch) { + const paramName = starMatch[1]; + if (cleanParams[paramName] != null) { + apiPath = apiPath.replace(`*${paramName}`, String(cleanParams[paramName])); + delete cleanParams[paramName]; + } else { + apiPath = apiPath.replace(/\/\*\w+/, ""); + } + } + + let url = `${base}${apiPath}`; + + if (method === "GET") { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(cleanParams)) { + if (v !== undefined && v !== null) sp.set(k, String(v)); + } + const qs = sp.toString(); + if (qs) url += (url.includes("?") ? "&" : "?") + qs; + } + + // Indexed-route callers can either embed the body in the + // remaining params (legacy style) OR pass `{ body }` in the + // second arg (newer call sites). The `body` key in init + // takes precedence when present. + const explicitBody = init && "body" in init ? init.body : undefined; + const fetchBody = + method === "GET" + ? undefined + : explicitBody !== undefined + ? JSON.stringify(explicitBody) + : Object.keys(cleanParams).length > 0 + ? JSON.stringify(cleanParams) + : undefined; + + const fetchInit: RequestInit = { + method, + ...(init ?? {}), + headers: { + "Content-Type": "application/json", + ...defaultHeaders, + ...(init?.headers instanceof Headers + ? Object.fromEntries(init.headers.entries()) + : ((init?.headers as Record) ?? {})), + }, + ...(fetchBody !== undefined ? { body: fetchBody } : {}), + }; + + const res = await fetchImpl(url, fetchInit); + return { + json: () => res.json() as Promise, + ok: res.ok, + status: res.status, + headers: res.headers, + }; + }; + } + return undefined; + }, + }; + + return new Proxy({} as Record, handler) as Record; +} diff --git a/packages/apps-salesforce/src/utils/parseUserCookie.ts b/packages/apps-salesforce/src/utils/parseUserCookie.ts new file mode 100644 index 0000000..904c890 --- /dev/null +++ b/packages/apps-salesforce/src/utils/parseUserCookie.ts @@ -0,0 +1,41 @@ +/** + * Decode the JSON-encoded user identifier cookie set by Salesforce + * Marketing Cloud Personalization (Evergage) on the browser. + * + * Evergage stores `{ puid?: string, uuid?: string }` URL-encoded: + * - `puid` (persistent user id) is present after the user signs in + * - `uuid` (anonymous device id) is dropped on first visit + * + * The personalization API expects EITHER `encryptedId` (mapped from + * `puid`) OR `anonymousId` (mapped from `uuid`). When the cookie is + * missing or malformed, we fall back to the literal "anonymous" so + * the API still returns a default campaign instead of erroring. + */ +import type { ParsedUserCookie } from "../types"; + +const ANONYMOUS_FALLBACK: ParsedUserCookie = { anonymousId: "anonymous" }; + +export function parseUserCookie(rawCookie: string | undefined | null): ParsedUserCookie { + if (!rawCookie?.trim()) return ANONYMOUS_FALLBACK; + + try { + const decoded = decodeURIComponent(rawCookie); + const parsed: unknown = JSON.parse(decoded); + if (typeof parsed !== "object" || parsed === null) return ANONYMOUS_FALLBACK; + + const { puid, uuid } = parsed as { puid?: unknown; uuid?: unknown }; + + // puid wins over uuid when both are present — once a user signs in, + // every request should be attributed to their persistent identity + // so cross-device personalization works. + if (typeof puid === "string" && puid.length > 0) { + return { encryptedId: puid }; + } + if (typeof uuid === "string" && uuid.length > 0) { + return { anonymousId: uuid }; + } + return ANONYMOUS_FALLBACK; + } catch { + return ANONYMOUS_FALLBACK; + } +} diff --git a/packages/apps-salesforce/src/utils/transform.ts b/packages/apps-salesforce/src/utils/transform.ts new file mode 100644 index 0000000..d692136 --- /dev/null +++ b/packages/apps-salesforce/src/utils/transform.ts @@ -0,0 +1,156 @@ +/** + * Map a Salesforce Personalization product into a schema.org `Product` + * suitable for ProductShelf / ProductCard / PDP components. + * + * Most fields (id, name, price, images, currency) are common to every + * Evergage dataset. The `additionalProperty` list, however, is fully + * dataset-specific — each customer chooses which catalog columns are + * exposed by Evergage (`Marca`, `Volume`, `Linha`, `freeShipping` etc.). + * Sites pass a `propertyMapper` to project their custom columns into + * `schema.org/PropertyValue[]`; the default mapper produces the + * standard fields that exist on every Evergage product (`itemType`, + * `categories`). + */ +import type { Offer, Product, PropertyValue } from "@decocms/apps-commerce/types"; +import type { SalesforceProduct } from "../types"; + +const IN_STOCK = "https://schema.org/InStock"; +const OUT_OF_STOCK = "https://schema.org/OutOfStock"; + +export type PropertyMapper = (product: SalesforceProduct) => PropertyValue[]; + +export interface ProductTransformerOptions { + /** + * Project dataset-specific Evergage columns into schema.org + * `PropertyValue[]`. Receives the raw Evergage product (including + * any custom fields via the index signature). Returns an empty + * array by default — sites that want brand / volume / line tags on + * their cards pass a mapper. + */ + propertyMapper?: PropertyMapper; +} + +const DEFAULT_PROPERTY_MAPPER: PropertyMapper = (product) => { + const out: PropertyValue[] = []; + if (product.itemType) { + out.push({ "@type": "PropertyValue", name: "itemType", value: product.itemType }); + } + if (product.categories?.length) { + out.push({ + "@type": "PropertyValue", + name: "category", + value: product.categories.join(", "), + }); + } + return out; +}; + +export function toOffer({ + product, + currencyCode, +}: { + product: SalesforceProduct; + currencyCode?: string; +}): Offer[] { + const productPrice = product.price; + const productSalePrice = product.salePrice || productPrice; + return [ + { + "@type": "Offer", + availability: product.inventoryCount > 0 ? IN_STOCK : OUT_OF_STOCK, + inventoryLevel: { value: product.inventoryCount }, + itemCondition: "https://schema.org/NewCondition", + price: productSalePrice, + priceCurrency: currencyCode, + priceSpecification: [ + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/ListPrice", + price: productPrice, + }, + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/SalePrice", + price: productSalePrice, + }, + ], + sku: product.id, + }, + ]; +} + +export function toImages(product: SalesforceProduct) { + return product.imageUrls.map((url) => ({ + "@type": "ImageObject" as const, + encodingFormat: "image", + alternateName: url, + url, + })); +} + +/** + * Returns a `toProduct` function bound to the dataset's property + * mapper. Sites typically construct one transformer at module level + * and reuse it across loaders. + */ +export function createProductTransformer( + options: ProductTransformerOptions = {}, +): (input: { product: SalesforceProduct; options: { currencyCode?: string } }) => Product { + const mapProperties = options.propertyMapper ?? DEFAULT_PROPERTY_MAPPER; + + return ({ product, options: opts }) => { + const offers = toOffer({ product, currencyCode: opts.currencyCode ?? product.currency }); + const sku = product.id; + // `idMagento` (cross-system identifier) wins when present — + // downstream code keys product detail pages off `productID`. + const productID = (product.idMagento as string | undefined) ?? sku; + const productPrice = product.price; + const productSalePrice = product.salePrice || productPrice; + const productUrl = product.url; + const additionalProperty = mapProperties(product); + + const variantTemplate: Product = { + "@type": "Product", + productID, + sku, + url: productUrl, + name: product.name.trim(), + gtin: sku, + offers: { + "@type": "AggregateOffer", + highPrice: productPrice, + lowPrice: productSalePrice, + offerCount: offers.length, + offers, + }, + }; + + return { + "@type": "Product", + productID, + sku, + url: productUrl, + name: product.name.trim(), + gtin: sku, + aggregateRating: { "@type": "AggregateRating", reviewCount: undefined }, + isVariantOf: { + "@type": "ProductGroup", + productGroupID: productID, + url: productUrl, + name: product.name.trim(), + model: "", + additionalProperty, + hasVariant: [variantTemplate], + }, + additionalProperty, + image: toImages(product), + offers: { + "@type": "AggregateOffer", + highPrice: productPrice, + lowPrice: productSalePrice, + offerCount: offers.length, + offers, + }, + }; + }; +} diff --git a/packages/apps-salesforce/tsconfig.json b/packages/apps-salesforce/tsconfig.json new file mode 100644 index 0000000..42386d3 --- /dev/null +++ b/packages/apps-salesforce/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"] +} From 72c6c540d5416cbdac0c5a360d2e12269ed5a8c5 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:32:03 -0300 Subject: [PATCH 40/85] chore: update bun.lock for @decocms/apps-salesforce --- bun.lock | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/bun.lock b/bun.lock index ad5116c..c1d007a 100644 --- a/bun.lock +++ b/bun.lock @@ -110,6 +110,24 @@ "react-dom": "^19.0.0", }, }, + "packages/apps-salesforce": { + "name": "@decocms/apps-salesforce", + "version": "0.0.0", + "dependencies": { + "@decocms/apps-commerce": "workspace:*", + "@decocms/blocks": "workspace:*", + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "knip": "^5.86.0", + "typescript": "^5.9.0", + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + }, "packages/apps-shopify": { "name": "@decocms/apps-shopify", "version": "0.0.0", @@ -403,6 +421,8 @@ "@decocms/apps-magento": ["@decocms/apps-magento@workspace:packages/apps-magento"], + "@decocms/apps-salesforce": ["@decocms/apps-salesforce@workspace:packages/apps-salesforce"], + "@decocms/apps-shopify": ["@decocms/apps-shopify@workspace:packages/apps-shopify"], "@decocms/apps-vtex": ["@decocms/apps-vtex@workspace:packages/apps-vtex"], From 2923b8477860db1044aca547165091e84fcc5e8f Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:37:23 -0300 Subject: [PATCH 41/85] feat(apps-resend): migrate resend/ from apps-start --- packages/apps-resend/package.json | 34 ++++++ .../apps-resend/src/__tests__/send.test.ts | 101 ++++++++++++++++++ packages/apps-resend/src/actions/send.ts | 57 ++++++++++ packages/apps-resend/src/client.ts | 39 +++++++ packages/apps-resend/src/index.ts | 10 ++ packages/apps-resend/src/manifest.gen.ts | 15 +++ packages/apps-resend/src/mod.ts | 67 ++++++++++++ packages/apps-resend/src/registry.ts | 9 ++ packages/apps-resend/src/types.ts | 54 ++++++++++ packages/apps-resend/tsconfig.json | 7 ++ 10 files changed, 393 insertions(+) create mode 100644 packages/apps-resend/package.json create mode 100644 packages/apps-resend/src/__tests__/send.test.ts create mode 100644 packages/apps-resend/src/actions/send.ts create mode 100644 packages/apps-resend/src/client.ts create mode 100644 packages/apps-resend/src/index.ts create mode 100644 packages/apps-resend/src/manifest.gen.ts create mode 100644 packages/apps-resend/src/mod.ts create mode 100644 packages/apps-resend/src/registry.ts create mode 100644 packages/apps-resend/src/types.ts create mode 100644 packages/apps-resend/tsconfig.json diff --git a/packages/apps-resend/package.json b/packages/apps-resend/package.json new file mode 100644 index 0000000..08563ad --- /dev/null +++ b/packages/apps-resend/package.json @@ -0,0 +1,34 @@ +{ + "name": "@decocms/apps-resend", + "version": "0.0.0", + "type": "module", + "description": "Deco app: Resend transactional email integration", + "repository": { "type": "git", "url": "https://github.com/decocms/blocks.git", "directory": "packages/apps-resend" }, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./mod": "./src/mod.ts", + "./client": "./src/client.ts", + "./types": "./src/types.ts", + "./actions/send": "./src/actions/send.ts", + "./registry": "./src/registry.ts" + }, + "scripts": { + "build": "tsc", + "test": "vitest run --root ../.. packages/apps-resend/", + "typecheck": "tsc --noEmit", + "lint:unused": "knip" + }, + "dependencies": { + "@decocms/blocks": "workspace:*", + "@decocms/apps-commerce": "workspace:*" + }, + "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "knip": "^5.86.0", + "typescript": "^5.9.0" + }, + "publishConfig": { "registry": "https://registry.npmjs.org", "access": "public" } +} diff --git a/packages/apps-resend/src/__tests__/send.test.ts b/packages/apps-resend/src/__tests__/send.test.ts new file mode 100644 index 0000000..b57637a --- /dev/null +++ b/packages/apps-resend/src/__tests__/send.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sendEmail } from "../actions/send"; +import { configureResend } from "../client"; + +describe("sendEmail", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + configureResend({ + apiKey: "re_test_123", + emailFrom: "Test ", + emailTo: ["default@example.com"], + subject: "Default Subject", + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends email with provided fields", async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: "email_abc123" }), + }); + + const result = await sendEmail({ + to: "user@example.com", + subject: "Hello", + html: "

World

", + }); + + expect(result.data).toEqual({ id: "email_abc123" }); + expect(result.error).toBeNull(); + + expect(fetchSpy).toHaveBeenCalledWith("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: "Bearer re_test_123", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: "Test ", + to: "user@example.com", + subject: "Hello", + html: "

World

", + }), + }); + }); + + it("falls back to defaults when fields are omitted", async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: "email_def456" }), + }); + + await sendEmail({ + html: "

Contact form

", + }); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.from).toBe("Test "); + expect(body.to).toEqual(["default@example.com"]); + expect(body.subject).toBe("Default Subject"); + expect(body.html).toBe("

Contact form

"); + }); + + it("returns error on API failure", async () => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + json: () => + Promise.resolve({ + message: "Invalid API key", + name: "invalid_api_Key", + }), + }); + + const result = await sendEmail({ + to: "user@example.com", + subject: "Test", + html: "

Test

", + }); + + expect(result.data).toBeNull(); + expect(result.error).toEqual({ + message: "Invalid API key", + name: "invalid_api_Key", + }); + }); + + it("throws when configureResend was not called", async () => { + // Reset the config by importing a fresh module + // We can't easily reset the singleton, so we test the error path + // by calling getResendConfig directly + const { getResendConfig } = await import("../client"); + // Config was set in beforeEach, so this should work + expect(() => getResendConfig()).not.toThrow(); + }); +}); diff --git a/packages/apps-resend/src/actions/send.ts b/packages/apps-resend/src/actions/send.ts new file mode 100644 index 0000000..c272725 --- /dev/null +++ b/packages/apps-resend/src/actions/send.ts @@ -0,0 +1,57 @@ +import { getResendConfig } from "../client"; +import type { CreateEmailOptions, CreateEmailResponse } from "../types"; + +/** + * Send an email via Resend API. + * + * ```ts + * import { sendEmail } from "@decocms/apps/resend/actions/send"; + * + * const result = await sendEmail({ + * subject: "Hello", + * html: "

World

", + * }); + * ``` + * + * Fields not provided fall back to the defaults set in `configureResend()`. + */ +export async function sendEmail( + payload: Partial & { subject?: string; html?: string }, +): Promise { + const config = getResendConfig(); + + const body: CreateEmailOptions = { + from: payload.from ?? config.emailFrom ?? "Contact ", + to: payload.to ?? config.emailTo ?? [], + subject: payload.subject ?? config.subject ?? "No subject", + ...(payload.bcc && { bcc: payload.bcc }), + ...(payload.cc && { cc: payload.cc }), + ...(payload.reply_to && { reply_to: payload.reply_to }), + ...(payload.html && { html: payload.html }), + ...(payload.text && { text: payload.text }), + ...(payload.headers && { headers: payload.headers }), + }; + + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${config.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return { + data: null, + error: data, + }; + } + + return { + data, + error: null, + }; +} diff --git a/packages/apps-resend/src/client.ts b/packages/apps-resend/src/client.ts new file mode 100644 index 0000000..1a11cab --- /dev/null +++ b/packages/apps-resend/src/client.ts @@ -0,0 +1,39 @@ +import type { ResendConfig } from "./types"; + +let _config: ResendConfig | null = null; + +/** + * Configure the Resend client. Call once in your site's setup.ts. + * + * ```ts + * import { configureResend } from "@decocms/apps/resend/client"; + * + * configureResend({ + * apiKey: process.env.RESEND_API_KEY!, + * emailFrom: "Contact ", + * emailTo: ["team@example.com"], + * subject: "Contact form submission", + * }); + * ``` + * + * TODO(secrets-decrypt): Add an `initResendFromBlocks(blocks, blockKey?)` + * helper that mirrors magento/algolia/vtex. The Deco CMS Resend block + * stores `apiKey` as an encrypted Secret reference (`{ encrypted, name }`) + * — sites currently have to call `configureResend()` with a manually + * resolved env var, missing the AES-CBC decrypt path via + * `@decocms/start/sdk/crypto#resolveSecret`. Until that ships, sites + * keep passing a string they obtain from `process.env` or a custom + * resolver. + */ +export function configureResend(config: ResendConfig) { + _config = config; +} + +export function getResendConfig(): ResendConfig { + if (!_config) { + throw new Error( + "Resend not configured. Call configureResend() in setup.ts before using Resend actions.", + ); + } + return _config; +} diff --git a/packages/apps-resend/src/index.ts b/packages/apps-resend/src/index.ts new file mode 100644 index 0000000..797a06e --- /dev/null +++ b/packages/apps-resend/src/index.ts @@ -0,0 +1,10 @@ +export { sendEmail } from "./actions/send"; +export { configureResend, getResendConfig } from "./client"; +export type { + CreateEmailOptions, + CreateEmailResponse, + CreateEmailResponseSuccess, + ErrorResponse, + ResendConfig, + ResendErrorCodeKey, +} from "./types"; diff --git a/packages/apps-resend/src/manifest.gen.ts b/packages/apps-resend/src/manifest.gen.ts new file mode 100644 index 0000000..1e5b481 --- /dev/null +++ b/packages/apps-resend/src/manifest.gen.ts @@ -0,0 +1,15 @@ +// AUTO-GENERATED by scripts/generate-manifests.ts — DO NOT EDIT +// This file is checked into source control and updated via: npm run generate:manifests +import * as actions_send from "./actions/send"; + +const manifest = { + name: "resend", + loaders: {}, + actions: { + "resend/actions/send": actions_send, + }, + sections: {}, +} as const; + +export type Manifest = typeof manifest; +export default manifest; diff --git a/packages/apps-resend/src/mod.ts b/packages/apps-resend/src/mod.ts new file mode 100644 index 0000000..4570e87 --- /dev/null +++ b/packages/apps-resend/src/mod.ts @@ -0,0 +1,67 @@ +/** + * Resend app module — standard autoconfig contract. + * + * Exports `configure` and `handlers` following the AppModContract pattern. + * The framework's `autoconfigApps()` calls these generically — no hardcoded + * app knowledge needed in the framework. + */ + +import type { AppDefinition, AppHandler, ResolveSecretFn } from "@decocms/apps-commerce/app-types"; +import { sendEmail } from "./actions/send"; +import { configureResend } from "./client"; +import manifest from "./manifest.gen"; +import type { ResendConfig } from "./types"; + +// ------------------------------------------------------------------------- +// State +// ------------------------------------------------------------------------- + +export interface ResendState { + config: ResendConfig; +} + +// ------------------------------------------------------------------------- +// Configure +// ------------------------------------------------------------------------- + +/** + * Configure Resend from CMS block data. + * Returns an AppDefinition or null if missing credentials. + */ +export async function configure( + block: any, + resolveSecret: ResolveSecretFn, +): Promise | null> { + const apiKey = await resolveSecret(block.apiKey, "RESEND_API_KEY"); + if (!apiKey) return null; + + const config: ResendConfig = { + apiKey, + emailFrom: block.emailFrom + ? `${block.emailFrom.name || "Contact"} <${block.emailFrom.domain || "onboarding@resend.dev"}>` + : undefined, + emailTo: block.emailTo, + subject: block.subject, + }; + + // Bridge: maintain global singleton for backward compat + configureResend(config); + + return { + name: "resend", + manifest, + state: { config }, + }; +} + +/** + * Invoke handlers registered under /deco/invoke/{key}. + * Both with and without .ts suffix for compatibility. + */ +export const handlers: Record = { + "resend/actions/emails/send": (props) => sendEmail(props), + "resend/actions/emails/send.ts": (props) => sendEmail(props), +}; + +/** Placeholder preview for CMS editor — evolves when admin supports it. */ +export const preview = undefined; diff --git a/packages/apps-resend/src/registry.ts b/packages/apps-resend/src/registry.ts new file mode 100644 index 0000000..28c0f9f --- /dev/null +++ b/packages/apps-resend/src/registry.ts @@ -0,0 +1,9 @@ +import type { AppRegistryEntry } from "@decocms/apps-commerce/registry"; + +export const RESEND_REGISTRY_ENTRY: AppRegistryEntry = { + blockKey: "deco-resend", + module: () => import("./mod"), + displayName: "Resend", + category: "email", + description: "Transactional email via Resend", +}; diff --git a/packages/apps-resend/src/types.ts b/packages/apps-resend/src/types.ts new file mode 100644 index 0000000..8e73f97 --- /dev/null +++ b/packages/apps-resend/src/types.ts @@ -0,0 +1,54 @@ +export const RESEND_ERROR_CODES_BY_KEY = { + missing_required_field: 422, + invalid_access: 422, + invalid_parameter: 422, + invalid_region: 422, + rate_limit_exceeded: 429, + missing_api_key: 401, + invalid_api_Key: 403, + invalid_from_address: 403, + validation_error: 403, + not_found: 404, + method_not_allowed: 405, + application_error: 500, + internal_server_error: 500, +} as const; + +export type ResendErrorCodeKey = keyof typeof RESEND_ERROR_CODES_BY_KEY; + +export interface ErrorResponse { + message: string; + name: ResendErrorCodeKey; +} + +export interface CreateEmailResponseSuccess { + /** The ID of the newly created email. */ + id: string; +} + +export interface CreateEmailResponse { + data: CreateEmailResponseSuccess | null; + error: ErrorResponse | null; +} + +export interface CreateEmailOptions { + from?: string; + to: string | string[]; + subject: string; + bcc?: string | string[]; + cc?: string | string[]; + reply_to?: string | string[]; + html?: string; + text?: string; + headers?: Record; +} + +export interface ResendConfig { + apiKey: string; + /** Default sender — e.g. "Contact " */ + emailFrom?: string; + /** Default recipients */ + emailTo?: string[]; + /** Default subject */ + subject?: string; +} diff --git a/packages/apps-resend/tsconfig.json b/packages/apps-resend/tsconfig.json new file mode 100644 index 0000000..42386d3 --- /dev/null +++ b/packages/apps-resend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"] +} From ed8263668a6222db099d51e07d768907e374d994 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:37:27 -0300 Subject: [PATCH 42/85] chore: update bun.lock for @decocms/apps-resend --- bun.lock | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/bun.lock b/bun.lock index c1d007a..e5c1adb 100644 --- a/bun.lock +++ b/bun.lock @@ -110,6 +110,24 @@ "react-dom": "^19.0.0", }, }, + "packages/apps-resend": { + "name": "@decocms/apps-resend", + "version": "0.0.0", + "dependencies": { + "@decocms/apps-commerce": "workspace:*", + "@decocms/blocks": "workspace:*", + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "knip": "^5.86.0", + "typescript": "^5.9.0", + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + }, "packages/apps-salesforce": { "name": "@decocms/apps-salesforce", "version": "0.0.0", @@ -421,6 +439,8 @@ "@decocms/apps-magento": ["@decocms/apps-magento@workspace:packages/apps-magento"], + "@decocms/apps-resend": ["@decocms/apps-resend@workspace:packages/apps-resend"], + "@decocms/apps-salesforce": ["@decocms/apps-salesforce@workspace:packages/apps-salesforce"], "@decocms/apps-shopify": ["@decocms/apps-shopify@workspace:packages/apps-shopify"], From 493d1fdac6c22f4c33a6990d843794c60739a039 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:42:24 -0300 Subject: [PATCH 43/85] feat(apps-blog): migrate blog/ from apps-start Migrates the last of the four platform packages into the monorepo. Fixes relative sibling imports by hand: mod.ts pulls AppDefinition/ ResolveSecretFn from @decocms/apps-commerce/app-types, types.ts pulls ImageWidget from @decocms/apps-website/types. Adds registry.ts (deco-blog, category "content") per the registry redesign, exported from package.json alongside the existing loaderMap/loaders/core paths. --- bun.lock | 21 ++ packages/apps-blog/package.json | 36 +++ .../src/__tests__/handlePosts.test.ts | 285 ++++++++++++++++++ .../apps-blog/src/__tests__/loaders.test.ts | 207 +++++++++++++ packages/apps-blog/src/__tests__/mod.test.ts | 11 + .../apps-blog/src/__tests__/records.test.ts | 92 ++++++ packages/apps-blog/src/core/handlePosts.ts | 88 ++++++ packages/apps-blog/src/core/records.ts | 30 ++ packages/apps-blog/src/index.ts | 24 ++ packages/apps-blog/src/loaderMap.ts | 57 ++++ packages/apps-blog/src/loaders/Author.ts | 9 + .../apps-blog/src/loaders/BlogPostItem.ts | 19 ++ .../apps-blog/src/loaders/BlogPostPage.ts | 39 +++ .../apps-blog/src/loaders/BlogRelatedPosts.ts | 66 ++++ packages/apps-blog/src/loaders/Blogpost.ts | 9 + .../apps-blog/src/loaders/BlogpostListing.ts | 101 +++++++ packages/apps-blog/src/loaders/Category.ts | 9 + .../apps-blog/src/loaders/GetCategories.ts | 48 +++ packages/apps-blog/src/manifest.gen.ts | 27 ++ packages/apps-blog/src/mod.ts | 55 ++++ packages/apps-blog/src/registry.ts | 9 + packages/apps-blog/src/types.ts | 105 +++++++ packages/apps-blog/tsconfig.json | 7 + 23 files changed, 1354 insertions(+) create mode 100644 packages/apps-blog/package.json create mode 100644 packages/apps-blog/src/__tests__/handlePosts.test.ts create mode 100644 packages/apps-blog/src/__tests__/loaders.test.ts create mode 100644 packages/apps-blog/src/__tests__/mod.test.ts create mode 100644 packages/apps-blog/src/__tests__/records.test.ts create mode 100644 packages/apps-blog/src/core/handlePosts.ts create mode 100644 packages/apps-blog/src/core/records.ts create mode 100644 packages/apps-blog/src/index.ts create mode 100644 packages/apps-blog/src/loaderMap.ts create mode 100644 packages/apps-blog/src/loaders/Author.ts create mode 100644 packages/apps-blog/src/loaders/BlogPostItem.ts create mode 100644 packages/apps-blog/src/loaders/BlogPostPage.ts create mode 100644 packages/apps-blog/src/loaders/BlogRelatedPosts.ts create mode 100644 packages/apps-blog/src/loaders/Blogpost.ts create mode 100644 packages/apps-blog/src/loaders/BlogpostListing.ts create mode 100644 packages/apps-blog/src/loaders/Category.ts create mode 100644 packages/apps-blog/src/loaders/GetCategories.ts create mode 100644 packages/apps-blog/src/manifest.gen.ts create mode 100644 packages/apps-blog/src/mod.ts create mode 100644 packages/apps-blog/src/registry.ts create mode 100644 packages/apps-blog/src/types.ts create mode 100644 packages/apps-blog/tsconfig.json diff --git a/bun.lock b/bun.lock index e5c1adb..8ff1aa1 100644 --- a/bun.lock +++ b/bun.lock @@ -74,6 +74,25 @@ "algoliasearch", ], }, + "packages/apps-blog": { + "name": "@decocms/apps-blog", + "version": "0.0.0", + "dependencies": { + "@decocms/apps-commerce": "workspace:*", + "@decocms/apps-website": "workspace:*", + "@decocms/blocks": "workspace:*", + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "knip": "^5.86.0", + "typescript": "^5.9.0", + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + }, "packages/apps-commerce": { "name": "@decocms/apps-commerce", "version": "0.0.0", @@ -435,6 +454,8 @@ "@decocms/apps-algolia": ["@decocms/apps-algolia@workspace:packages/apps-algolia"], + "@decocms/apps-blog": ["@decocms/apps-blog@workspace:packages/apps-blog"], + "@decocms/apps-commerce": ["@decocms/apps-commerce@workspace:packages/apps-commerce"], "@decocms/apps-magento": ["@decocms/apps-magento@workspace:packages/apps-magento"], diff --git a/packages/apps-blog/package.json b/packages/apps-blog/package.json new file mode 100644 index 0000000..68f5b0d --- /dev/null +++ b/packages/apps-blog/package.json @@ -0,0 +1,36 @@ +{ + "name": "@decocms/apps-blog", + "version": "0.0.0", + "type": "module", + "description": "Deco app: blog content and CMS integration", + "repository": { "type": "git", "url": "https://github.com/decocms/blocks.git", "directory": "packages/apps-blog" }, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./mod": "./src/mod.ts", + "./types": "./src/types.ts", + "./loaderMap": "./src/loaderMap.ts", + "./loaders/*": "./src/loaders/*.ts", + "./core/*": "./src/core/*.ts", + "./registry": "./src/registry.ts" + }, + "scripts": { + "build": "tsc", + "test": "vitest run --root ../.. packages/apps-blog/", + "typecheck": "tsc --noEmit", + "lint:unused": "knip" + }, + "dependencies": { + "@decocms/blocks": "workspace:*", + "@decocms/apps-commerce": "workspace:*", + "@decocms/apps-website": "workspace:*" + }, + "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "knip": "^5.86.0", + "typescript": "^5.9.0" + }, + "publishConfig": { "registry": "https://registry.npmjs.org", "access": "public" } +} diff --git a/packages/apps-blog/src/__tests__/handlePosts.test.ts b/packages/apps-blog/src/__tests__/handlePosts.test.ts new file mode 100644 index 0000000..d445648 --- /dev/null +++ b/packages/apps-blog/src/__tests__/handlePosts.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from "vitest"; +import handlePosts, { + filterPostsByCategory, + filterPostsBySlugs, + filterPostsByTerm, + filterRelatedPosts, + slicePosts, + sortPosts, +} from "../core/handlePosts"; +import type { BlogPost, SortBy } from "../types"; + +function makePost(overrides: Partial = {}): BlogPost { + return { + title: "Test Post", + slug: "test-post", + date: "2024-06-01", + excerpt: "A test post", + content: "Full content here", + categories: [{ name: "News", slug: "news" }], + authors: [{ name: "Author", email: "a@b.com" }], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// sortPosts +// --------------------------------------------------------------------------- +describe("sortPosts", () => { + const posts = [ + makePost({ title: "Alpha", slug: "a", date: "2024-01-01" }), + makePost({ title: "Charlie", slug: "c", date: "2024-03-01" }), + makePost({ title: "Bravo", slug: "b", date: "2024-02-01" }), + ]; + + it("sorts date_desc — most recent first", () => { + const sorted = sortPosts(posts, "date_desc"); + expect(sorted.map((p) => p.slug)).toEqual(["c", "b", "a"]); + }); + + it("sorts date_asc — oldest first", () => { + const sorted = sortPosts(posts, "date_asc"); + expect(sorted.map((p) => p.slug)).toEqual(["a", "b", "c"]); + }); + + it("sorts title_asc", () => { + const sorted = sortPosts(posts, "title_asc"); + // localeCompare(a,b) is negated for asc → Z-A + expect(sorted.map((p) => p.title)).toEqual(["Charlie", "Bravo", "Alpha"]); + }); + + it("sorts title_desc", () => { + const sorted = sortPosts(posts, "title_desc"); + // localeCompare(a,b) kept as-is for desc → A-Z + expect(sorted.map((p) => p.title)).toEqual(["Alpha", "Bravo", "Charlie"]); + }); + + it("falls back to date for invalid sort field", () => { + const sorted = sortPosts(posts, "invalid_desc" as SortBy); + expect(sorted.map((p) => p.slug)).toEqual(["c", "b", "a"]); + }); + + it("falls back to desc for invalid sort order", () => { + const sorted = sortPosts(posts, "date_wrong" as SortBy); + expect(sorted.map((p) => p.slug)).toEqual(["c", "b", "a"]); + }); + + it("sorts posts with missing date to end", () => { + const withMissing = [ + makePost({ slug: "no-date", date: undefined as unknown as string }), + makePost({ slug: "has-date", date: "2024-06-01" }), + ]; + const sorted = sortPosts(withMissing, "date_desc"); + expect(sorted[0].slug).toBe("has-date"); + expect(sorted[1].slug).toBe("no-date"); + }); + + it("throws on empty array (accesses blogPosts[0])", () => { + expect(() => sortPosts([], "date_desc")).toThrow(); + }); + + it("does not mutate original array", () => { + const original = [...posts]; + sortPosts(posts, "date_asc"); + expect(posts).toEqual(original); + }); +}); + +// --------------------------------------------------------------------------- +// filterPostsByCategory +// --------------------------------------------------------------------------- +describe("filterPostsByCategory", () => { + const posts = [ + makePost({ slug: "a", categories: [{ name: "News", slug: "news" }] }), + makePost({ + slug: "b", + categories: [ + { name: "News", slug: "news" }, + { name: "Tech", slug: "tech" }, + ], + }), + makePost({ slug: "c", categories: [{ name: "Tech", slug: "tech" }] }), + ]; + + it("returns all posts when no slug provided", () => { + expect(filterPostsByCategory(posts)).toHaveLength(3); + }); + + it("filters by matching slug", () => { + const result = filterPostsByCategory(posts, "tech"); + expect(result.map((p) => p.slug)).toEqual(["b", "c"]); + }); + + it("returns empty when no match", () => { + expect(filterPostsByCategory(posts, "sports")).toEqual([]); + }); + + it("excludes posts with no categories", () => { + const withNone = [...posts, makePost({ slug: "d", categories: undefined })]; + expect(filterPostsByCategory(withNone, "news").map((p) => p.slug)).toEqual(["a", "b"]); + }); + + it("includes post when one of multiple categories matches", () => { + const result = filterPostsByCategory(posts, "tech"); + expect(result.find((p) => p.slug === "b")).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// filterPostsBySlugs +// --------------------------------------------------------------------------- +describe("filterPostsBySlugs", () => { + const posts = [makePost({ slug: "a" }), makePost({ slug: "b" }), makePost({ slug: "c" })]; + + it("filters to only matching slugs", () => { + expect(filterPostsBySlugs(posts, ["a", "c"]).map((p) => p.slug)).toEqual(["a", "c"]); + }); + + it("returns empty for empty slugs array", () => { + expect(filterPostsBySlugs(posts, [])).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// filterPostsByTerm +// --------------------------------------------------------------------------- +describe("filterPostsByTerm", () => { + const posts = [ + makePost({ slug: "a", title: "Hello World", excerpt: "intro", content: "body" }), + makePost({ slug: "b", title: "Goodbye", excerpt: "farewell world", content: "end" }), + makePost({ slug: "c", title: "Nothing", excerpt: "nope", content: "empty" }), + ]; + + it("matches in title", () => { + expect(filterPostsByTerm(posts, "Hello").map((p) => p.slug)).toEqual(["a"]); + }); + + it("matches in excerpt", () => { + expect(filterPostsByTerm(posts, "farewell").map((p) => p.slug)).toEqual(["b"]); + }); + + it("matches in content", () => { + expect(filterPostsByTerm(posts, "empty").map((p) => p.slug)).toEqual(["c"]); + }); + + it("is case-insensitive", () => { + expect(filterPostsByTerm(posts, "hELLo")).toHaveLength(1); + }); + + it("returns empty when no match", () => { + expect(filterPostsByTerm(posts, "zzz")).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// filterRelatedPosts +// --------------------------------------------------------------------------- +describe("filterRelatedPosts", () => { + const posts = [ + makePost({ slug: "a", categories: [{ name: "News", slug: "news" }] }), + makePost({ slug: "b", categories: [{ name: "Tech", slug: "tech" }] }), + makePost({ slug: "c", categories: undefined }), + ]; + + it("includes post with category overlap", () => { + expect(filterRelatedPosts(posts, ["news"]).map((p) => p.slug)).toEqual(["a"]); + }); + + it("excludes post with no overlap", () => { + expect(filterRelatedPosts(posts, ["sports"])).toEqual([]); + }); + + it("excludes post with no categories", () => { + const result = filterRelatedPosts(posts, ["news"]); + expect(result.find((p) => p.slug === "c")).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// slicePosts +// --------------------------------------------------------------------------- +describe("slicePosts", () => { + const posts = Array.from({ length: 5 }, (_, i) => makePost({ slug: `p${i + 1}` })); + + it("page 1 returns first N", () => { + expect(slicePosts(posts, 1, 2).map((p) => p.slug)).toEqual(["p1", "p2"]); + }); + + it("page 2 returns next N", () => { + expect(slicePosts(posts, 2, 2).map((p) => p.slug)).toEqual(["p3", "p4"]); + }); + + it("last page with fewer items", () => { + expect(slicePosts(posts, 3, 2).map((p) => p.slug)).toEqual(["p5"]); + }); + + it("page beyond total returns empty", () => { + expect(slicePosts(posts, 10, 2)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// handlePosts (composition pipeline) +// --------------------------------------------------------------------------- +describe("handlePosts", () => { + const posts = [ + makePost({ + slug: "a", + title: "Alpha", + date: "2024-01-01", + categories: [{ name: "News", slug: "news" }], + }), + makePost({ + slug: "b", + title: "Bravo", + date: "2024-02-01", + categories: [{ name: "Tech", slug: "tech" }], + }), + makePost({ + slug: "c", + title: "Charlie", + date: "2024-03-01", + categories: [ + { name: "News", slug: "news" }, + { name: "Tech", slug: "tech" }, + ], + }), + ]; + + it("slug string + postSlugs — uses slug filtering", () => { + const result = handlePosts(posts, "date_desc", "news", ["a", "c"]); + // sorted by date_desc: c (March) before a (Jan) + expect(result?.map((p) => p.slug)).toEqual(["c", "a"]); + }); + + it("slug string + term — chains category + term filters", () => { + const result = handlePosts(posts, "date_desc", "news", undefined, "Alpha"); + expect(result?.map((p) => p.slug)).toEqual(["a"]); + }); + + it("slug as string[] — triggers related posts path", () => { + const result = handlePosts(posts, "date_desc", ["tech"]); + expect(result?.map((p) => p.slug)).toEqual(["c", "b"]); + }); + + it("no slug + term — term-only filtering", () => { + const result = handlePosts(posts, "date_asc", undefined, undefined, "Bravo"); + expect(result).toHaveLength(1); + expect(result![0].slug).toBe("b"); + }); + + it("excludePostSlug removes matching post", () => { + const result = handlePosts(posts, "date_desc", undefined, undefined, undefined, "b"); + expect(result?.find((p) => p.slug === "b")).toBeUndefined(); + }); + + it("returns null when result is empty", () => { + expect(handlePosts(posts, "date_desc", "nonexistent")).toBeNull(); + }); + + it("returns all posts sorted when no filters given", () => { + const result = handlePosts(posts, "date_desc"); + expect(result).toHaveLength(3); + expect(result![0].slug).toBe("c"); + }); +}); diff --git a/packages/apps-blog/src/__tests__/loaders.test.ts b/packages/apps-blog/src/__tests__/loaders.test.ts new file mode 100644 index 0000000..14d83a5 --- /dev/null +++ b/packages/apps-blog/src/__tests__/loaders.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../core/records", () => ({ + getRecordsByPath: vi.fn(), +})); + +import { getRecordsByPath } from "../core/records"; +import BlogPostItem from "../loaders/BlogPostItem"; +import BlogPostPageLoader from "../loaders/BlogPostPage"; +import BlogpostListing from "../loaders/BlogpostListing"; +import BlogRelatedPostsLoader from "../loaders/BlogRelatedPosts"; +import GetCategories from "../loaders/GetCategories"; +import type { BlogPost, Category } from "../types"; + +const mockGetRecords = getRecordsByPath as ReturnType; + +function makePost(overrides: Partial = {}): BlogPost { + return { + title: "Test Post", + slug: "test-post", + date: "2024-06-01", + excerpt: "A test post", + content: "Full content here", + categories: [{ name: "News", slug: "news" }], + authors: [{ name: "Author", email: "a@b.com" }], + ...overrides, + }; +} + +const samplePosts: BlogPost[] = [ + makePost({ slug: "post-a", title: "Alpha", date: "2024-01-01" }), + makePost({ slug: "post-b", title: "Bravo", date: "2024-02-01" }), + makePost({ slug: "post-c", title: "Charlie", date: "2024-03-01" }), +]; + +beforeEach(() => { + vi.clearAllMocks(); + mockGetRecords.mockReturnValue(samplePosts); +}); + +// --------------------------------------------------------------------------- +// BlogpostListing +// --------------------------------------------------------------------------- +describe("BlogpostListing", () => { + it("returns paginated listing with defaults (page=1, count=12, sortBy=date_desc)", () => { + const result = BlogpostListing({}); + expect(result).not.toBeNull(); + expect(result!.posts).toHaveLength(3); + expect(result!.pageInfo.currentPage).toBe(1); + expect(result!.pageInfo.recordPerPage).toBe(12); + // date_desc: most recent first + expect(result!.posts[0].slug).toBe("post-c"); + }); + + it("URL search params override props", () => { + const req = new Request("http://localhost/blog?page=2&count=1"); + const result = BlogpostListing({}, req); + expect(result).not.toBeNull(); + expect(result!.pageInfo.currentPage).toBe(2); + expect(result!.posts).toHaveLength(1); + expect(result!.posts[0].slug).toBe("post-b"); // second page of date_desc + }); + + it("computes nextPage / previousPage correctly", () => { + const result = BlogpostListing({ count: 1, page: 2 }); + expect(result).not.toBeNull(); + expect(result!.pageInfo.nextPage).toContain("page=3"); + expect(result!.pageInfo.previousPage).toContain("page=1"); + }); + + it("returns null when no posts match", () => { + mockGetRecords.mockReturnValue([]); + expect(BlogpostListing({})).toBeNull(); + }); + + it("SEO canonical strips query params", () => { + const req = new Request("http://localhost/blog?page=1&count=1"); + const result = BlogpostListing({}, req); + expect(result).not.toBeNull(); + expect(result!.seo.canonical).toBe("http://localhost/blog"); + }); +}); + +// --------------------------------------------------------------------------- +// BlogRelatedPosts +// --------------------------------------------------------------------------- +describe("BlogRelatedPostsLoader", () => { + it("excludes current post via excludePostSlug", () => { + const result = BlogRelatedPostsLoader({ excludePostSlug: "post-a" }); + expect(result).not.toBeNull(); + expect(result!.find((p) => p.slug === "post-a")).toBeUndefined(); + }); + + it("returns BlogPost[] (not BlogPostListingPage)", () => { + const result = BlogRelatedPostsLoader({}); + expect(Array.isArray(result)).toBe(true); + }); + + it("returns null when empty", () => { + mockGetRecords.mockReturnValue([]); + expect(BlogRelatedPostsLoader({})).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// BlogPostPage +// --------------------------------------------------------------------------- +describe("BlogPostPageLoader", () => { + it("returns BlogPostPage with correct @type", () => { + const result = BlogPostPageLoader({ slug: "post-a" }); + expect(result).not.toBeNull(); + expect(result!["@type"]).toBe("BlogPostPage"); + expect(result!.post.slug).toBe("post-a"); + }); + + it("returns null when post not found", () => { + expect(BlogPostPageLoader({ slug: "nonexistent" })).toBeNull(); + }); + + it("SEO fields fall back to post fields", () => { + mockGetRecords.mockReturnValue([ + makePost({ + slug: "no-seo", + title: "Fallback Title", + excerpt: "Fallback Desc", + image: "img.png", + seo: undefined, + }), + ]); + + const result = BlogPostPageLoader({ slug: "no-seo" }); + expect(result).not.toBeNull(); + expect(result!.seo?.title).toBe("Fallback Title"); + expect(result!.seo?.description).toBe("Fallback Desc"); + expect(result!.seo?.image).toBe("img.png"); + }); + + it("uses SEO fields when present", () => { + mockGetRecords.mockReturnValue([ + makePost({ + slug: "has-seo", + title: "Post Title", + seo: { title: "SEO Title", description: "SEO Desc" }, + }), + ]); + + const result = BlogPostPageLoader({ slug: "has-seo" }); + expect(result!.seo?.title).toBe("SEO Title"); + expect(result!.seo?.description).toBe("SEO Desc"); + }); +}); + +// --------------------------------------------------------------------------- +// GetCategories +// --------------------------------------------------------------------------- +describe("GetCategories", () => { + const categories: Category[] = [ + { name: "Beta", slug: "beta" }, + { name: "Alpha", slug: "alpha" }, + { name: "Charlie", slug: "charlie" }, + ]; + + beforeEach(() => { + mockGetRecords.mockReturnValue([...categories]); + }); + + it("sorts by name (title_desc default)", () => { + const result = GetCategories({}); + expect(result).not.toBeNull(); + expect(result!.map((c) => c.name)).toEqual(["Alpha", "Beta", "Charlie"]); + }); + + it("filters by slug when provided", () => { + const result = GetCategories({ slug: "alpha" }); + expect(result).toHaveLength(1); + expect(result![0].slug).toBe("alpha"); + }); + + it("slices by count", () => { + const result = GetCategories({ count: 2 }); + expect(result).toHaveLength(2); + }); + + it("returns null when no categories", () => { + mockGetRecords.mockReturnValue([]); + expect(GetCategories({})).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// BlogPostItem +// --------------------------------------------------------------------------- +describe("BlogPostItem", () => { + it("returns post by slug", () => { + const result = BlogPostItem({ slug: "post-b" }); + expect(result).not.toBeNull(); + expect(result!.slug).toBe("post-b"); + }); + + it("returns null when no slug provided", () => { + expect(BlogPostItem({ slug: "" })).toBeNull(); + }); + + it("returns null when post not found", () => { + expect(BlogPostItem({ slug: "nonexistent" })).toBeNull(); + }); +}); diff --git a/packages/apps-blog/src/__tests__/mod.test.ts b/packages/apps-blog/src/__tests__/mod.test.ts new file mode 100644 index 0000000..ded33ec --- /dev/null +++ b/packages/apps-blog/src/__tests__/mod.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { configure } from "../mod"; + +describe("blog module", () => { + it("returns AppDefinition with name 'blog' and manifest", async () => { + const app = await configure({}, async () => null); + expect(app.name).toBe("blog"); + expect(app.manifest).toBeDefined(); + expect(app.state).toEqual({}); + }); +}); diff --git a/packages/apps-blog/src/__tests__/records.test.ts b/packages/apps-blog/src/__tests__/records.test.ts new file mode 100644 index 0000000..c2682b6 --- /dev/null +++ b/packages/apps-blog/src/__tests__/records.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@decocms/blocks/cms", () => ({ + loadBlocks: vi.fn(), +})); + +import { loadBlocks } from "@decocms/blocks/cms"; +import { getRecordsByPath } from "../core/records"; + +const mockLoadBlocks = loadBlocks as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("getRecordsByPath", () => { + it("extracts records matching path prefix", () => { + mockLoadBlocks.mockReturnValue({ + "collections/blog/posts/hello": { + name: "collections/blog/posts/hello", + post: { title: "Hello", slug: "hello" }, + }, + "collections/blog/posts/world": { + name: "collections/blog/posts/world", + post: { title: "World", slug: "world" }, + }, + }); + + const result = getRecordsByPath("collections/blog/posts", "post"); + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: "Hello", slug: "hello" }), + expect.objectContaining({ title: "World", slug: "world" }), + ]), + ); + }); + + it("skips non-object blocks", () => { + mockLoadBlocks.mockReturnValue({ + "collections/blog/posts/a": "not-an-object", + "collections/blog/posts/b": null, + "collections/blog/posts/c": { + name: "collections/blog/posts/c", + post: { title: "C" }, + }, + }); + + const result = getRecordsByPath("collections/blog/posts", "post"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ title: "C" })); + }); + + it("skips blocks without the accessor field", () => { + mockLoadBlocks.mockReturnValue({ + "collections/blog/posts/x": { + name: "collections/blog/posts/x", + other: { title: "X" }, + }, + }); + + const result = getRecordsByPath("collections/blog/posts", "post"); + expect(result).toEqual([]); + }); + + it("derives id from block name", () => { + mockLoadBlocks.mockReturnValue({ + "collections/blog/posts/my-post": { + name: "collections/blog/posts/my-post", + post: { title: "My Post" }, + }, + }); + + const result = getRecordsByPath<{ title: string; id?: string }>( + "collections/blog/posts", + "post", + ); + expect(result[0].id).toBe("my-post"); + }); + + it("returns empty array when no blocks match", () => { + mockLoadBlocks.mockReturnValue({ + "collections/blog/categories/cat": { + name: "collections/blog/categories/cat", + category: { name: "Cat" }, + }, + }); + + const result = getRecordsByPath("collections/blog/posts", "post"); + expect(result).toEqual([]); + }); +}); diff --git a/packages/apps-blog/src/core/handlePosts.ts b/packages/apps-blog/src/core/handlePosts.ts new file mode 100644 index 0000000..8e79e74 --- /dev/null +++ b/packages/apps-blog/src/core/handlePosts.ts @@ -0,0 +1,88 @@ +import type { BlogPost, SortBy } from "../types"; + +const VALID_SORT_ORDERS = ["asc", "desc"]; + +/** + * Sort posts by the given criteria. + * Skips view-based sorting (no Drizzle in this port). + */ +export const sortPosts = (blogPosts: BlogPost[], sortBy: SortBy): BlogPost[] => { + const parts = sortBy.split("_"); + const sortMethod = (parts[0] in blogPosts[0] ? parts[0] : "date") as keyof BlogPost; + const sortOrder = VALID_SORT_ORDERS.includes(parts[1]) ? parts[1] : "desc"; + + return [...blogPosts].sort((a, b) => { + if (!a[sortMethod] && !b[sortMethod]) return 0; + if (!a[sortMethod]) return 1; + if (!b[sortMethod]) return -1; + + const comparison = + sortMethod === "date" + ? new Date(`${b.date}T00:00:00`).getTime() - new Date(`${a.date}T00:00:00`).getTime() + : (a[sortMethod]?.toString().localeCompare(b[sortMethod]?.toString() ?? "") ?? 0); + + return sortOrder === "desc" ? comparison : -comparison; + }); +}; + +/** Filter posts by a single category slug. */ +export const filterPostsByCategory = (posts: BlogPost[], slug?: string): BlogPost[] => + slug ? posts.filter(({ categories }) => categories?.find((c) => c.slug === slug)) : posts; + +/** Filter posts whose slug is in the given list. */ +export const filterPostsBySlugs = (posts: BlogPost[], postSlugs: string[]): BlogPost[] => + posts.filter(({ slug }) => postSlugs.includes(slug)); + +/** Filter posts matching a search term (title, excerpt, content). */ +export const filterPostsByTerm = (posts: BlogPost[], term: string): BlogPost[] => + posts.filter(({ content, excerpt, title }) => + [content, excerpt, title].some((field) => field?.toLowerCase().includes(term.toLowerCase())), + ); + +/** Filter posts whose categories overlap with the given slug array. */ +export const filterRelatedPosts = (posts: BlogPost[], slugs: string[]): BlogPost[] => + posts.filter(({ categories }) => categories?.find((c) => slugs.includes(c.slug))); + +/** Slice posts for pagination. */ +export const slicePosts = ( + posts: BlogPost[], + pageNumber: number, + postsPerPage: number, +): BlogPost[] => { + const startIndex = (pageNumber - 1) * postsPerPage; + return posts.slice(startIndex, startIndex + postsPerPage); +}; + +/** + * Combined filter & sort pipeline (no slice). + */ +export default function handlePosts( + posts: BlogPost[], + sortBy: SortBy, + slug?: string | string[], + postSlugs?: string[], + term?: string, + excludePostSlug?: string, +): BlogPost[] | null { + let filtered: BlogPost[]; + + if (typeof slug === "string") { + filtered = + postSlugs && postSlugs.length > 0 + ? filterPostsBySlugs(posts, postSlugs) + : filterPostsByCategory(posts, slug); + if (term) filtered = filterPostsByTerm(filtered, term); + } else if (Array.isArray(slug)) { + filtered = filterRelatedPosts(posts, slug); + } else { + filtered = term ? filterPostsByTerm(posts, term) : posts; + } + + if (excludePostSlug) { + filtered = filtered.filter(({ slug: s }) => s !== excludePostSlug); + } + + if (!filtered || filtered.length === 0) return null; + + return sortPosts(filtered, sortBy); +} diff --git a/packages/apps-blog/src/core/records.ts b/packages/apps-blog/src/core/records.ts new file mode 100644 index 0000000..a38a4a4 --- /dev/null +++ b/packages/apps-blog/src/core/records.ts @@ -0,0 +1,30 @@ +import { loadBlocks } from "@decocms/blocks/cms"; + +/** + * Retrieve records from CMS blocks by path prefix. + * + * Scans the decofile blocks whose key starts with `path` and extracts the + * nested value at `accessor` from each matching block. + * + * Equivalent to the Deno `getRecordsByPath(ctx, path, accessor)` but uses + * `loadBlocks()` from `@decocms/blocks/cms` instead of `ctx.get(resolvables)`. + */ +export function getRecordsByPath(path: string, accessor: string): T[] { + const blocks = loadBlocks() as Record>; + const results: T[] = []; + + for (const [key, value] of Object.entries(blocks)) { + if (!key.startsWith(path) || !value || typeof value !== "object") { + continue; + } + + const record = value[accessor] as T | undefined; + if (!record) continue; + + const id = (value.name as string | undefined)?.split(path)[1]?.replace("/", ""); + + results.push({ ...record, id } as T); + } + + return results; +} diff --git a/packages/apps-blog/src/index.ts b/packages/apps-blog/src/index.ts new file mode 100644 index 0000000..9a3e167 --- /dev/null +++ b/packages/apps-blog/src/index.ts @@ -0,0 +1,24 @@ +/** + * Public API for the blog app. + */ + +export { getRecordsByPath } from "./core/records"; +/** @deprecated Use `createBlogLoaders` instead. */ +export { + createBlogLoaders, + createBlogLoaders as createBlogCommerceLoaders, +} from "./loaderMap"; +export { configure } from "./mod"; + +// Types +export type { + Author, + BlogPost, + BlogPostListingPage, + BlogPostPage, + Category, + ExtraProps, + PageInfo, + Seo, + SortBy, +} from "./types"; diff --git a/packages/apps-blog/src/loaderMap.ts b/packages/apps-blog/src/loaderMap.ts new file mode 100644 index 0000000..bf24604 --- /dev/null +++ b/packages/apps-blog/src/loaderMap.ts @@ -0,0 +1,57 @@ +/** + * Blog loader map factory for CMS block resolution. + * + * Returns a `Record` that the site spreads into its + * block loader registry. + */ + +import AuthorLoader from "./loaders/Author"; +import BlogPostItemLoader from "./loaders/BlogPostItem"; +import BlogPostPageLoader from "./loaders/BlogPostPage"; +import BlogpostLoader from "./loaders/Blogpost"; +import BlogpostListingLoader from "./loaders/BlogpostListing"; +import BlogRelatedPostsLoader from "./loaders/BlogRelatedPosts"; +import CategoryLoader from "./loaders/Category"; +import GetCategoriesLoader from "./loaders/GetCategories"; + +// biome-ignore lint/suspicious/noExplicitAny: loader props/returns vary per block +export type LoaderFn = (props: any, request?: Request) => Promise | any; + +/** + * Create the blog loader map. + * + * @example + * ```ts + * import { createBlogLoaders } from "@decocms/apps/blog"; + * + * const COMMERCE_LOADERS = { + * ...createVtexCommerceLoaders(), + * ...createBlogLoaders(), + * }; + * ``` + */ +export function createBlogLoaders(): Record { + return { + // Loader keys match the Deno app's __resolveType paths + "blog/loaders/BlogPostPage.ts": BlogPostPageLoader, + "blog/loaders/BlogPostPage": BlogPostPageLoader, + "blog/loaders/BlogpostListing.ts": BlogpostListingLoader, + "blog/loaders/BlogpostListing": BlogpostListingLoader, + "blog/loaders/BlogRelatedPosts.ts": BlogRelatedPostsLoader, + "blog/loaders/BlogRelatedPosts": BlogRelatedPostsLoader, + "blog/loaders/GetCategories.ts": GetCategoriesLoader, + "blog/loaders/GetCategories": GetCategoriesLoader, + "blog/loaders/Blogpost.ts": BlogpostLoader, + "blog/loaders/Blogpost": BlogpostLoader, + "blog/loaders/Category.ts": CategoryLoader, + "blog/loaders/Category": CategoryLoader, + "blog/loaders/Author.ts": AuthorLoader, + "blog/loaders/Author": AuthorLoader, + + // BlogPostItem: looks up a single post by slug, returns BlogPost + "blog/loaders/BlogPostItem.ts": BlogPostItemLoader, + "blog/loaders/BlogPostItem": BlogPostItemLoader, + "blog/loaders/BlogpostList.ts": BlogpostListingLoader, + "blog/loaders/BlogpostList": BlogpostListingLoader, + }; +} diff --git a/packages/apps-blog/src/loaders/Author.ts b/packages/apps-blog/src/loaders/Author.ts new file mode 100644 index 0000000..d77d24c --- /dev/null +++ b/packages/apps-blog/src/loaders/Author.ts @@ -0,0 +1,9 @@ +import type { Author } from "../types"; + +/** + * @title Author + * @description Defines a blog post author. + */ +const loader = ({ author }: { author: Author }): Author => author; + +export default loader; diff --git a/packages/apps-blog/src/loaders/BlogPostItem.ts b/packages/apps-blog/src/loaders/BlogPostItem.ts new file mode 100644 index 0000000..b258f1f --- /dev/null +++ b/packages/apps-blog/src/loaders/BlogPostItem.ts @@ -0,0 +1,19 @@ +import { getRecordsByPath } from "../core/records"; +import type { BlogPost } from "../types"; + +export interface Props { + slug: string; +} + +/** + * @title BlogPostItem + * @description Fetches a single blog post by slug. Returns the BlogPost + * directly (not wrapped in BlogPostPage). + */ +export default function BlogPostItem(props: Props & { __pageUrl?: string }): BlogPost | null { + const { slug } = props; + if (!slug) return null; + + const posts = getRecordsByPath("collections/blog/posts", "post"); + return posts.find((p) => p?.slug === slug) ?? null; +} diff --git a/packages/apps-blog/src/loaders/BlogPostPage.ts b/packages/apps-blog/src/loaders/BlogPostPage.ts new file mode 100644 index 0000000..a29e84b --- /dev/null +++ b/packages/apps-blog/src/loaders/BlogPostPage.ts @@ -0,0 +1,39 @@ +import { getRecordsByPath } from "../core/records"; +import type { BlogPost, BlogPostPage } from "../types"; + +const COLLECTION_PATH = "collections/blog/posts"; +const ACCESSOR = "post"; + +export interface Props { + slug: string; +} + +/** + * @title BlogPostPage + * @description Fetches a specific blog post page by its slug. + */ +export default function BlogPostPageLoader( + props: Props & { __pageUrl?: string }, + req?: Request, +): BlogPostPage | null { + const { slug } = props; + const posts = getRecordsByPath(COLLECTION_PATH, ACCESSOR); + + const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/"; + const url = new URL(rawUrl); + const post = posts.find((p) => p?.slug === slug); + + if (!post) return null; + + return { + "@type": "BlogPostPage", + post, + seo: { + title: post?.seo?.title || post?.title, + description: post?.seo?.description || post?.excerpt, + canonical: post?.seo?.canonical || url.href, + image: post?.seo?.image || post?.image, + noIndexing: post?.seo?.noIndexing || false, + }, + }; +} diff --git a/packages/apps-blog/src/loaders/BlogRelatedPosts.ts b/packages/apps-blog/src/loaders/BlogRelatedPosts.ts new file mode 100644 index 0000000..358db47 --- /dev/null +++ b/packages/apps-blog/src/loaders/BlogRelatedPosts.ts @@ -0,0 +1,66 @@ +import handlePosts, { slicePosts } from "../core/handlePosts"; +import { getRecordsByPath } from "../core/records"; +import type { BlogPost, SortBy } from "../types"; + +const COLLECTION_PATH = "collections/blog/posts"; +const ACCESSOR = "post"; + +export interface Props { + /** + * @title Items per page + * @description Number of posts per page to display. + */ + count?: number; + /** + * @title Page query parameter + * @description The current page number. Defaults to 1. + */ + page?: number; + /** + * @title Category Slug + * @description Filter by a specific category slug. + */ + slug?: string | string[]; + /** + * @title Page sorting parameter + * @description The sorting option. Default is "date_desc" + */ + sortBy?: SortBy; + /** + * @description Overrides the query term at url + */ + query?: string; + /** + * @title Exclude Post Slug + * @description Excludes a post slug from the list + */ + excludePostSlug?: string; +} + +export type BlogRelatedPosts = BlogPost[] | null; + +/** + * @title BlogRelatedPosts + * @description Retrieves a list of blog related posts. + */ +export default function BlogRelatedPostsLoader( + props: Props & { __pageUrl?: string }, + req?: Request, +): BlogRelatedPosts { + const { page, count, slug, sortBy, query, excludePostSlug } = props; + const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/"; + const url = new URL(rawUrl); + const postsPerPage = Number(count ?? url.searchParams.get("count") ?? 12); + const pageNumber = Number(page ?? url.searchParams.get("page") ?? 1); + const pageSort = sortBy ?? (url.searchParams.get("sortBy") as SortBy) ?? "date_desc"; + const term = query ?? url.searchParams.get("q") ?? undefined; + + const posts = getRecordsByPath(COLLECTION_PATH, ACCESSOR); + + const handledPosts = handlePosts(posts, pageSort, slug, undefined, term, excludePostSlug); + + if (!handledPosts) return null; + + const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage); + return slicedPosts.length > 0 ? slicedPosts : null; +} diff --git a/packages/apps-blog/src/loaders/Blogpost.ts b/packages/apps-blog/src/loaders/Blogpost.ts new file mode 100644 index 0000000..ec54703 --- /dev/null +++ b/packages/apps-blog/src/loaders/Blogpost.ts @@ -0,0 +1,9 @@ +import type { BlogPost } from "../types"; + +/** + * @title Blogpost + * @description Defines a blog post. + */ +const loader = ({ post }: { post: BlogPost }): BlogPost => post; + +export default loader; diff --git a/packages/apps-blog/src/loaders/BlogpostListing.ts b/packages/apps-blog/src/loaders/BlogpostListing.ts new file mode 100644 index 0000000..88cef53 --- /dev/null +++ b/packages/apps-blog/src/loaders/BlogpostListing.ts @@ -0,0 +1,101 @@ +import handlePosts, { slicePosts } from "../core/handlePosts"; +import { getRecordsByPath } from "../core/records"; +import type { BlogPost, BlogPostListingPage, PageInfo, SortBy } from "../types"; + +const COLLECTION_PATH = "collections/blog/posts"; +const ACCESSOR = "post"; + +export interface Props { + /** + * @title Category Slug + * @description Filter by a specific category slug. + */ + slug?: string; + /** + * @title Items per page + * @description Number of posts per page to display. + */ + count?: number; + /** + * @title Page query parameter + * @description The current page number. Defaults to 1. + */ + page?: number; + /** + * @title Page sorting parameter + * @description The sorting option. Default is "date_desc" + */ + sortBy?: SortBy; + /** + * @description Overrides the query term at url + */ + query?: string; +} + +/** + * @title BlogPostList + * @description Retrieves a paginated list of blog posts. + */ +export default function BlogPostList( + props: Props & { __pageUrl?: string }, + req?: Request, +): BlogPostListingPage | null { + const { page, count, slug, sortBy, query } = props; + const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/"; + const url = new URL(rawUrl); + const params = url.searchParams; + const postsPerPage = Number(count ?? params.get("count") ?? 12); + const pageNumber = Number(page ?? params.get("page") ?? 1); + const pageSort = sortBy ?? (params.get("sortBy") as SortBy) ?? "date_desc"; + const term = query ?? params.get("q") ?? undefined; + + const posts = getRecordsByPath(COLLECTION_PATH, ACCESSOR); + + try { + const handledPosts = handlePosts(posts, pageSort, slug, undefined, term); + + if (!handledPosts) return null; + + const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage); + if (slicedPosts.length === 0) return null; + + const category = slicedPosts[0].categories?.find((c) => c.slug === slug); + + return { + posts: slicedPosts, + pageInfo: toPageInfo(handledPosts, postsPerPage, pageNumber, params), + seo: { + title: category?.name ?? "", + canonical: new URL(url.pathname, url.origin).href, + }, + }; + } catch (e) { + console.error("[BlogpostListing]", e); + return null; + } +} + +function toPageInfo( + posts: BlogPost[], + postsPerPage: number, + pageNumber: number, + params: URLSearchParams, +): PageInfo { + const totalPosts = posts.length; + const totalPages = Math.ceil(totalPosts / postsPerPage); + const hasNextPage = totalPages > pageNumber; + const hasPrevPage = pageNumber > 1; + const nextPage = new URLSearchParams(params); + const previousPage = new URLSearchParams(params); + + if (hasNextPage) nextPage.set("page", (pageNumber + 1).toString()); + if (hasPrevPage) previousPage.set("page", (pageNumber - 1).toString()); + + return { + nextPage: hasNextPage ? `?${nextPage}` : undefined, + previousPage: hasPrevPage ? `?${previousPage}` : undefined, + currentPage: pageNumber, + records: totalPosts, + recordPerPage: postsPerPage, + }; +} diff --git a/packages/apps-blog/src/loaders/Category.ts b/packages/apps-blog/src/loaders/Category.ts new file mode 100644 index 0000000..1deec91 --- /dev/null +++ b/packages/apps-blog/src/loaders/Category.ts @@ -0,0 +1,9 @@ +import type { Category } from "../types"; + +/** + * @title Category + * @description Defines a blog post category. + */ +const loader = ({ category }: { category: Category }): Category => category; + +export default loader; diff --git a/packages/apps-blog/src/loaders/GetCategories.ts b/packages/apps-blog/src/loaders/GetCategories.ts new file mode 100644 index 0000000..c26f5b1 --- /dev/null +++ b/packages/apps-blog/src/loaders/GetCategories.ts @@ -0,0 +1,48 @@ +import { getRecordsByPath } from "../core/records"; +import type { Category } from "../types"; + +const COLLECTION_PATH = "collections/blog/categories"; +const ACCESSOR = "category"; + +export interface Props { + /** + * @title Category Slug + * @description Get the category data from a specific slug. + */ + slug?: string; + /** + * @title Items count + * @description Number of categories to return + */ + count?: number; + /** + * @title Sort + * @description The sorting option. Default is "title_desc" + */ + sortBy?: "title_asc" | "title_desc"; +} + +/** + * @title GetCategories + * @description Retrieves a list of blog categories. + */ +export default function GetCategories({ + count, + slug, + sortBy = "title_desc", +}: Props): Category[] | null { + const categories = getRecordsByPath(COLLECTION_PATH, ACCESSOR); + + if (!categories?.length) return null; + + if (slug) { + return categories.filter((c) => c.slug === slug); + } + + const sortedCategories = categories.sort((a, b) => { + const comparison = a.name.localeCompare(b.name); + return sortBy.endsWith("_desc") ? comparison : -comparison; + }); + + return count ? sortedCategories.slice(0, count) : sortedCategories; +} diff --git a/packages/apps-blog/src/manifest.gen.ts b/packages/apps-blog/src/manifest.gen.ts new file mode 100644 index 0000000..4babb56 --- /dev/null +++ b/packages/apps-blog/src/manifest.gen.ts @@ -0,0 +1,27 @@ +// AUTO-GENERATED by scripts/generate-manifests.ts — DO NOT EDIT +// This file is checked into source control and updated via: npm run generate:manifests +import * as loaders_Author from "./loaders/Author"; +import * as loaders_BlogPostPage from "./loaders/BlogPostPage"; +import * as loaders_Blogpost from "./loaders/Blogpost"; +import * as loaders_BlogpostListing from "./loaders/BlogpostListing"; +import * as loaders_BlogRelatedPosts from "./loaders/BlogRelatedPosts"; +import * as loaders_Category from "./loaders/Category"; +import * as loaders_GetCategories from "./loaders/GetCategories"; + +const manifest = { + name: "blog", + loaders: { + "blog/loaders/Author": loaders_Author, + "blog/loaders/Blogpost": loaders_Blogpost, + "blog/loaders/BlogPostPage": loaders_BlogPostPage, + "blog/loaders/BlogpostListing": loaders_BlogpostListing, + "blog/loaders/BlogRelatedPosts": loaders_BlogRelatedPosts, + "blog/loaders/Category": loaders_Category, + "blog/loaders/GetCategories": loaders_GetCategories, + }, + actions: {}, + sections: {}, +} as const; + +export type Manifest = typeof manifest; +export default manifest; diff --git a/packages/apps-blog/src/mod.ts b/packages/apps-blog/src/mod.ts new file mode 100644 index 0000000..843891e --- /dev/null +++ b/packages/apps-blog/src/mod.ts @@ -0,0 +1,55 @@ +/** + * Blog app module — standard autoconfig contract. + * + * Exports `configure` following the AppModContract pattern. + * Provides blog post, category, and author loaders for the site. + */ + +import type { + AppDefinition, + ResolveSecretFn, +} from "@decocms/apps-commerce/app-types"; +import manifest from "./manifest.gen"; + +// ------------------------------------------------------------------------- +// CMS Props +// ------------------------------------------------------------------------- + +/** @title Deco Blog */ +export interface Props { + /** + * @title Page Slug + * @description The slug of the BlogPostPage to embed. Use :category and :slug. + */ + pageSlug?: string; +} + +export type BlogState = Props; + +// ------------------------------------------------------------------------- +// Configure +// ------------------------------------------------------------------------- + +/** + * Configure the Blog app from CMS block data. + * Always returns an AppDefinition (no required fields). + */ +export async function configure( + // biome-ignore lint/suspicious/noExplicitAny: block data comes from CMS with no fixed schema + _block: any, + _resolveSecret: ResolveSecretFn, +): Promise> { + return { + name: "blog", + manifest, + state: { pageSlug: _block?.pageSlug }, + }; +} + +/** Placeholder preview for CMS editor. */ +export const preview = undefined; + +/** Default export for schema generation and Deno-style app bridges. */ +export default function Blog(state: Props) { + return { state }; +} diff --git a/packages/apps-blog/src/registry.ts b/packages/apps-blog/src/registry.ts new file mode 100644 index 0000000..e9c640a --- /dev/null +++ b/packages/apps-blog/src/registry.ts @@ -0,0 +1,9 @@ +import type { AppRegistryEntry } from "@decocms/apps-commerce/registry"; + +export const BLOG_REGISTRY_ENTRY: AppRegistryEntry = { + blockKey: "deco-blog", + module: () => import("./mod"), + displayName: "Blog", + category: "content", + description: "Blog posts, categories, and authors from CMS collections", +}; diff --git a/packages/apps-blog/src/types.ts b/packages/apps-blog/src/types.ts new file mode 100644 index 0000000..586cad4 --- /dev/null +++ b/packages/apps-blog/src/types.ts @@ -0,0 +1,105 @@ +import type { ImageWidget } from "@decocms/apps-website/types"; + +/** + * @titleBy name + * @widget author + */ +export interface Author { + name: string; + email: string; + avatar?: ImageWidget; + jobTitle?: string; + company?: string; +} + +export interface Category { + name: string; + slug: string; +} + +export interface BlogPost { + title: string; + excerpt: string; + /** + * @title Main image + */ + image?: ImageWidget; + /** + * @title Alt text for the image + */ + alt?: string; + /** + * @widget blog + * @collection authors + */ + authors?: Author[]; + /** + * @widget blog + * @collection categories + */ + categories?: Category[]; + /** + * @format date + */ + date: string; + slug: string; + /** + * @title Post Content + * @format rich-text + */ + content?: string; + /** + * @title Sections + * @label hidden + * @changeable true + */ + sections?: unknown[]; + /** + * @title SEO + */ + seo?: Seo; + /** + * @title ReadTime in minutes + */ + readTime?: number; + /** + * @title Extra Props + */ + extraProps?: ExtraProps[]; + id?: string; +} + +export interface ExtraProps { + key: string; + value: string; +} + +export interface Seo { + title?: string; + description?: string; + image?: ImageWidget; + canonical?: string; + noIndexing?: boolean; +} + +export interface BlogPostPage { + "@type": "BlogPostPage"; + post: BlogPost; + seo?: Seo | null; +} + +export type SortBy = "date_desc" | "date_asc" | "title_asc" | "title_desc"; + +export interface PageInfo { + nextPage?: string; + previousPage?: string; + currentPage: number; + records?: number; + recordPerPage?: number; +} + +export interface BlogPostListingPage { + posts: BlogPost[]; + pageInfo: PageInfo; + seo: Seo; +} diff --git a/packages/apps-blog/tsconfig.json b/packages/apps-blog/tsconfig.json new file mode 100644 index 0000000..42386d3 --- /dev/null +++ b/packages/apps-blog/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"] +} From d2d04857f83a3db0f2f3e2e644c8452eb7878bad Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 22:59:32 -0300 Subject: [PATCH 44/85] fix(apps-salesforce): declare @tanstack/react-start peer dependency; docs: correct stale apps-* dependency-rule text Final whole-branch review of the apps-monorepo-migration plan found: 1. apps-salesforce's loaders dynamically import @tanstack/react-start/server but never declared it -- worked only via workspace hoisting in this monorepo's CI, a real (if low-risk) provenance gap. Declared as a peerDependency matching @decocms/tanstack's own >=1.0.0 constraint. 2. Both docs still said 'apps-* packages do not depend on each other', contradicted by two accepted type-only edges added during Tasks 7 and 13 (apps-vtex/apps-blog importing Secret/ImageWidget types from apps-website). Runtime dependencies between apps-* packages are still disallowed; only this narrow type-only case is now documented as accepted. Co-Authored-By: Claude Sonnet 5 --- docs/apps-monorepo-migration-design.md | 2 +- docs/apps-monorepo-migration-plan.md | 2 +- packages/apps-salesforce/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/apps-monorepo-migration-design.md b/docs/apps-monorepo-migration-design.md index 0e7f670..ce7805d 100644 --- a/docs/apps-monorepo-migration-design.md +++ b/docs/apps-monorepo-migration-design.md @@ -38,7 +38,7 @@ Split by platform/concern, migrated from `apps-start`'s current directory struct All 9 migrate in one pass (not phased by usage) — most of the work is mechanical file relocation + import rewriting, not new logic, so there's little marginal cost to doing the platforms with no current in-house consumer (Shopify, Magento, Algolia, Salesforce) alongside VTEX. -**Dependency rule** (extends the existing one-way graph already documented in `CLAUDE.md`): every `apps-*` package may depend on `@decocms/blocks` / `@decocms/blocks-admin` / `@decocms/tanstack`, never the reverse. `apps-*` packages do not depend on each other. +**Dependency rule** (extends the existing one-way graph already documented in `CLAUDE.md`): every `apps-*` package may depend on `@decocms/blocks` / `@decocms/blocks-admin` / `@decocms/tanstack`, never the reverse. `apps-*` packages generally do not depend on each other, with one accepted exception found during implementation: a **type-only** dependency on another `apps-*` package for a genuinely shared type (e.g. `apps-vtex` and `apps-blog` both import `Secret`/`ImageWidget` types from `apps-website`) is fine — it's erased at compile time and doesn't create a runtime cycle, as verified per-case in the implementation plan's Global Constraints. Runtime dependencies between `apps-*` packages remain disallowed. ## Content redistribution (not a straight file move) diff --git a/docs/apps-monorepo-migration-plan.md b/docs/apps-monorepo-migration-plan.md index 3be36a4..bc34176 100644 --- a/docs/apps-monorepo-migration-plan.md +++ b/docs/apps-monorepo-migration-plan.md @@ -15,7 +15,7 @@ - Every new package name is `@decocms/apps-`, published alongside the existing 5 at the same lockstep version (via `scripts/sync-versions.mjs`, which already dynamically globs `packages/*` — confirmed, no changes needed there or in `.releaserc.v7.json`'s `publishCmd`, also glob-based). - Every new package's `dependencies` uses `"workspace:*"` for `@decocms/blocks`/`@decocms/blocks-admin`/`@decocms/tanstack` (matching the existing 5 packages' pattern) — `sync-versions.mjs` rewrites these to the real version at publish time. - Every new package needs a `repository` field (`{"type": "git", "url": "https://github.com/decocms/blocks.git", "directory": "packages/apps-"}`) — required for npm provenance verification (this exact requirement broke the first real publish of the original 5 packages; don't repeat that). -- One-way dependency rule: `apps-*` packages may depend on `@decocms/blocks`/`@decocms/blocks-admin`/`@decocms/tanstack`, never the reverse. `apps-*` packages do not depend on each other. +- One-way dependency rule: `apps-*` packages may depend on `@decocms/blocks`/`@decocms/blocks-admin`/`@decocms/tanstack`, never the reverse. `apps-*` packages generally do not depend on each other, with one accepted exception discovered during execution: an `apps-*` package MAY take a **type-only** (`import type`) dependency on another `apps-*` package for a genuinely shared type (e.g. `apps-vtex`'s `mod.ts` and `apps-blog`'s `types.ts` both import `apps-website`'s `Secret`/`ImageWidget` types for app-config fields) — this is erased at compile time, creates no runtime coupling, and does not violate the acyclic one-way graph as long as the target package doesn't depend back (verify per-case). Runtime (value-level) cross-`apps-*` dependencies remain disallowed. - **Proven old→new import mapping** (evidenced from `casaevideo-tanstack`'s actual, verified migration commits `7133613`, `55e353d`, `9585417`, `9393bf9` — apply exactly, don't re-derive from scratch): | Old (`@decocms/start/...`) | New | Notes | diff --git a/packages/apps-salesforce/package.json b/packages/apps-salesforce/package.json index 22f444f..bef3693 100644 --- a/packages/apps-salesforce/package.json +++ b/packages/apps-salesforce/package.json @@ -21,7 +21,7 @@ "@decocms/blocks": "workspace:*", "@decocms/apps-commerce": "workspace:*" }, - "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, + "peerDependencies": { "@tanstack/react-start": ">=1.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", From f1dfaa38c872961ba3988864d1b3e2dc5361d6f6 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 7 Jul 2026 23:43:00 -0300 Subject: [PATCH 45/85] fix(blocks-cli): update generate-schema.ts's app-loader scan for the split apps-* package layout Same bug class Task 7 already fixed in generate-invoke.ts, but in a different script that the apps-monorepo-migration plan never touched: generate-schema.ts's app-loader pass hardcoded a single shared node_modules/@decocms/apps directory and a "@decocms/apps//mod" bridge-file regex, both from the old monolithic package. Under the split architecture each namespace is its own "@decocms/apps-" package, so this silently found zero loaders for any real site -- confirmed via baggagio-tanstack's live migration, which surfaced the admin schema dropping 26 of 54 platform-loader entries (54 -> 28). Fixed by resolving each detected namespace to its own node_modules/@decocms/apps-/src/loaders directory instead of a shared base dir, and updating the bridge-file regex to "@decocms/apps-/mod". CMS decofile keys (the "/loaders/..." format) are unchanged -- that's a decofile convention independent of the npm package layout. Verified against baggagio-tanstack's real linked packages (apps-vtex, apps-blog, apps-website): schema regeneration now reports "54 loaders", exactly matching the pre-migration baseline, confirming the fix is complete and not just partially working. Co-Authored-By: Claude Sonnet 5 --- .../blocks-cli/scripts/generate-schema.ts | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/blocks-cli/scripts/generate-schema.ts b/packages/blocks-cli/scripts/generate-schema.ts index d32bb69..55830e5 100644 --- a/packages/blocks-cli/scripts/generate-schema.ts +++ b/packages/blocks-cli/scripts/generate-schema.ts @@ -839,14 +839,22 @@ function generateMeta(): MetaResponse { } // --------------------------------------------------------------------------- - // App loaders pass: walk @decocms/apps/*/loaders/ directories and register - // each .ts file as a CMS loader. CMS keys are derived from the file path - // (e.g. "vtex/loaders/intelligentSearch/productList.ts"). + // App loaders pass: walk each @decocms/apps- package's src/loaders/ + // directory and register each .ts file as a CMS loader. CMS keys are derived + // from the file path (e.g. "vtex/loaders/intelligentSearch/productList.ts") — + // this key format is a CMS decofile convention, independent of the npm + // package layout, so it stays "/loaders/..." even though the + // namespace now maps to its own "@decocms/apps-" package rather + // than a subdirectory of one monolithic "@decocms/apps" package. // // Only apps installed in src/apps/ are scanned. An app bridge file that - // re-exports from "@decocms/apps/{namespace}/mod" signals the namespace. + // re-exports from "@decocms/apps-{namespace}/mod" signals the namespace. // --------------------------------------------------------------------------- - const appsPkgDir = path.resolve(root, "node_modules/@decocms/apps"); + + /** Absolute path to the installed @decocms/apps- package, if present. */ + function getAppPkgDir(namespace: string): string { + return path.resolve(root, `node_modules/@decocms/apps-${namespace}`); + } /** Detect installed app namespaces from src/apps/ bridge files. */ function detectInstalledAppNamespaces(): Set { @@ -855,7 +863,7 @@ function generateMeta(): MetaResponse { if (!fs.existsSync(siteAppsDir)) return namespaces; const appFiles = findTsxFiles(siteAppsDir); - const re = /["']@decocms\/apps\/([^/]+)\/mod["']/; + const re = /["']@decocms\/apps-([^/]+)\/mod["']/; for (const filePath of appFiles) { try { const content = fs.readFileSync(filePath, "utf-8"); @@ -869,13 +877,13 @@ function generateMeta(): MetaResponse { // Discover app loader files via filesystem walk (scoped to installed apps) function discoverAppLoaders(): Array<{ cmsKey: string; sourceFile: string; namespace: string }> { const result: Array<{ cmsKey: string; sourceFile: string; namespace: string }> = []; - if (!fs.existsSync(appsPkgDir)) return result; const installed = detectInstalledAppNamespaces(); if (installed.size === 0) return result; for (const namespace of installed) { - const loadersDir = path.join(appsPkgDir, namespace, "loaders"); + const pkgDir = getAppPkgDir(namespace); + const loadersDir = path.join(pkgDir, "src", "loaders"); if (!fs.existsSync(loadersDir)) continue; const files = fs.readdirSync(loadersDir, { recursive: true }) as string[]; @@ -888,7 +896,9 @@ function generateMeta(): MetaResponse { if (rel.includes("__tests__") || rel.includes("__test__") || rel.endsWith(".test.ts")) continue; const cmsKey = `${namespace}/loaders/${rel.replace(/\\/g, "/")}`; - const sourceFile = `${namespace}/loaders/${rel.replace(/\\/g, "/")}`; + // Absolute — each namespace now resolves against its own package dir, + // not a single shared "appsPkgDir" (see absSourceFile below). + const sourceFile = path.join(loadersDir, rel); result.push({ cmsKey, sourceFile, namespace }); } } @@ -908,9 +918,9 @@ function generateMeta(): MetaResponse { const installedNs = detectInstalledAppNamespaces(); console.log(`Installed app namespaces: ${[...installedNs].join(", ") || "(none)"}`); - console.log(`Scanning app loaders from @decocms/apps (${appLoaders.length} files)...`); + console.log(`Scanning app loaders from ${[...installedNs].map((ns) => `@decocms/apps-${ns}`).join(", ") || "(none)"} (${appLoaders.length} files)...`); for (const appLoader of appLoaders) { - const absSourceFile = path.resolve(appsPkgDir, appLoader.sourceFile); + const absSourceFile = appLoader.sourceFile; try { // Resolve the real path to de-duplicate re-exports pointing to the same file From f1a41afc8cb2b3836c883c93502aea8dd9e32292 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 08:49:23 -0300 Subject: [PATCH 46/85] chore: sync bun.lock with apps-salesforce's peerDependencies fix Co-Authored-By: Claude Sonnet 5 --- bun.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/bun.lock b/bun.lock index 8ff1aa1..d8e6e10 100644 --- a/bun.lock +++ b/bun.lock @@ -161,6 +161,7 @@ "typescript": "^5.9.0", }, "peerDependencies": { + "@tanstack/react-start": ">=1.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", }, From 3805f2f7274b670f41fd75abdd16587b13bc4d09 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 09:29:36 -0300 Subject: [PATCH 47/85] fix(publish): add explicit files allowlist to packages shipping a manifest.gen.ts Real bug found while bumping 3 downstream sites (casaevideo-tanstack, lebiscuit-tanstack, baggagio-tanstack) off bun-link to the real published 7.2.0 packages: manifest.gen.ts is genuinely absent from the npm tarballs for apps-vtex, apps-blog, apps-website (and, on inspection, also apps-shopify, apps-resend, plus apps-shopify's storefront.graphql.gen.ts) -- despite being git-tracked and despite the earlier .gitignore negation carve-out (!packages/apps-*/src/manifest.gen.ts) working correctly for `git status`/`git add`. Root cause: that negation lives in the monorepo ROOT .gitignore, several directories above each package. npm's own packing walk (no `files` field was set on any of these 5 packages) only resolves ignore rules within the package directory being packed -- it never sees the root-level negation, so the blanket *.gen.ts pattern silently wins for npm specifically, even though git itself is unaffected. Fixed with an explicit "files": ["src", "tsconfig.json"] allowlist on all 5 affected packages -- verified via `npm pack --dry-run` that manifest.gen.ts (and apps-shopify's storefront.graphql.gen.ts) are now included. This is a more robust fix than another one-off gitignore carve-out: any future generated-but-necessary file added under any of these 5 packages' src/ is now included by default, no new exception needed. Confirmed via three independent subagent reports (working on casaevideo-tanstack, lebiscuit-tanstack, baggagio-tanstack in parallel) that the already-published 7.2.0 tarballs are broken this way -- this fix requires a new 7.2.1 release, republishing all 14 packages together per the lockstep versioning policy. Co-Authored-By: Claude Sonnet 5 --- packages/apps-blog/package.json | 1 + packages/apps-resend/package.json | 1 + packages/apps-shopify/package.json | 1 + packages/apps-vtex/package.json | 1 + packages/apps-website/package.json | 1 + 5 files changed, 5 insertions(+) diff --git a/packages/apps-blog/package.json b/packages/apps-blog/package.json index 68f5b0d..7381a81 100644 --- a/packages/apps-blog/package.json +++ b/packages/apps-blog/package.json @@ -4,6 +4,7 @@ "type": "module", "description": "Deco app: blog content and CMS integration", "repository": { "type": "git", "url": "https://github.com/decocms/blocks.git", "directory": "packages/apps-blog" }, + "files": ["src", "tsconfig.json"], "main": "./src/index.ts", "exports": { ".": "./src/index.ts", diff --git a/packages/apps-resend/package.json b/packages/apps-resend/package.json index 08563ad..3b9ab88 100644 --- a/packages/apps-resend/package.json +++ b/packages/apps-resend/package.json @@ -4,6 +4,7 @@ "type": "module", "description": "Deco app: Resend transactional email integration", "repository": { "type": "git", "url": "https://github.com/decocms/blocks.git", "directory": "packages/apps-resend" }, + "files": ["src", "tsconfig.json"], "main": "./src/index.ts", "exports": { ".": "./src/index.ts", diff --git a/packages/apps-shopify/package.json b/packages/apps-shopify/package.json index 4b2a6d4..61e3770 100644 --- a/packages/apps-shopify/package.json +++ b/packages/apps-shopify/package.json @@ -8,6 +8,7 @@ "url": "https://github.com/decocms/blocks.git", "directory": "packages/apps-shopify" }, + "files": ["src", "tsconfig.json"], "main": "./src/index.ts", "exports": { ".": "./src/index.ts", diff --git a/packages/apps-vtex/package.json b/packages/apps-vtex/package.json index 2bc75f8..4fbcd09 100644 --- a/packages/apps-vtex/package.json +++ b/packages/apps-vtex/package.json @@ -8,6 +8,7 @@ "url": "https://github.com/decocms/blocks.git", "directory": "packages/apps-vtex" }, + "files": ["src", "tsconfig.json"], "main": "./src/index.ts", "exports": { ".": "./src/index.ts", diff --git a/packages/apps-website/package.json b/packages/apps-website/package.json index a574ca9..70227e4 100644 --- a/packages/apps-website/package.json +++ b/packages/apps-website/package.json @@ -8,6 +8,7 @@ "url": "https://github.com/decocms/blocks.git", "directory": "packages/apps-website" }, + "files": ["src", "tsconfig.json"], "main": "./src/index.ts", "exports": { ".": "./src/index.ts", From e93125ee9f672c8fc22227cb43d7746ae5c37cf6 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 10:32:04 -0300 Subject: [PATCH 48/85] fix(tanstack): add apps-vtex to the SSR noExternal forcing list Real, load-bearing bug found while bumping downstream sites off bun-link to the real published packages (casaevideo-tanstack, lebiscuit-tanstack, baggagio-tanstack): the dev server hard-crashed at boot with "createServerFn must be assigned to a variable!" from the Cloudflare Workers runtime's static entry-export scan, ONLY when @decocms/apps-vtex was installed as a real npm package (not bun-linked). Root cause: configEnvironment's noExternal-forcing `additions` array (added to fix issue #197, a prior HTTP-500 "Invalid server function ID" bug) only listed "@decocms/tanstack" itself. But apps-vtex's invoke.ts wraps 18 actions/loaders via createInvokeFn -- each a real createServerFn(...) call site -- and this was never added to the list when createInvokeFn's export was added to tanstack's public barrel (apps-monorepo-migration Task 7). It worked by accident under bun link (Vite treats symlinked/workspace packages as live source, skipping optimizeDeps pre-bundling for them) but a real npm install pre-bundles apps-vtex before the SSR transform can register its createServerFn handlers, so the Workers runtime's static scan finds an unregistered call and crashes. Fixed by adding "@decocms/apps-vtex" to the additions array. Verified via bun link against baggagio-tanstack with apps-vtex genuinely installed as a real npm 7.2.1 package (not linked): dev server boots cleanly, real HTTP 200 with real Bagaggio product content, matching the exact repro two independent subagents diagnosed (casaevideo-tanstack surfaced the crash; baggagio-tanstack root-caused it to this exact array/line). Confirmed no other apps-* package needs the same fix (grepped for createInvokeFn( call sites across all apps-* packages -- apps-vtex is the only one). Co-Authored-By: Claude Sonnet 5 --- packages/tanstack/src/vite/plugin.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/tanstack/src/vite/plugin.js b/packages/tanstack/src/vite/plugin.js index 64cf2cc..57468df 100644 --- a/packages/tanstack/src/vite/plugin.js +++ b/packages/tanstack/src/vite/plugin.js @@ -588,14 +588,25 @@ export function decoVitePlugin() { env.optimizeDeps.esbuildOptions.jsxImportSource = "react"; } - // Force @decocms/tanstack through the SSR transform pipeline so - // TanStack Start's compiler can register its createServerFn handlers + // Force these packages through the SSR transform pipeline so + // TanStack Start's compiler can register their createServerFn handlers // (loadDeferredSection in routes/cmsRoute.ts, and loadCmsPage / - // loadCmsHomePage alongside it) in the per-environment serverFnsById - // manifest. Without this, Vite pre-bundles the package via - // optimizeDeps before plugins run, the handler never enters the - // manifest, and every POST /_serverFn/* call from the browser returns - // HTTP 500 ("Invalid server function ID"). See #197. + // loadCmsHomePage alongside it, for @decocms/tanstack) in the + // per-environment serverFnsById manifest. Without this, Vite + // pre-bundles the package via optimizeDeps before plugins run, the + // handler never enters the manifest, and every POST /_serverFn/* call + // from the browser returns HTTP 500 ("Invalid server function ID"). + // See #197. + // + // @decocms/apps-vtex needs this too: its invoke.ts wraps 18 actions/ + // loaders via createInvokeFn (packages/tanstack/src/sdk/createInvoke.ts), + // each a real `createServerFn(...)` call site. This was missed when + // createInvokeFn's export was added to @decocms/tanstack's public + // barrel (apps-monorepo-migration Task 7) — worked by accident under + // `bun link` (Vite treats symlinked/workspace packages as live source, + // skipping optimizeDeps pre-bundling for them) but hard-crashes with + // a real npm install: "createServerFn must be assigned to a variable!" + // from the Cloudflare Workers runtime's static entry-export scan. // // @decocms/blocks does NOT need this: despite its // validateSection.ts / useScript.ts doc comments mentioning @@ -607,7 +618,7 @@ export function decoVitePlugin() { if (name === "ssr") { env.resolve = env.resolve || {}; const existing = env.resolve.noExternal; - const additions = ["@decocms/tanstack"]; + const additions = ["@decocms/tanstack", "@decocms/apps-vtex"]; if (existing === true) { // Already noExternal everything — nothing to add. } else if (Array.isArray(existing)) { From 5bd6fc22d716a1ebd10f0b548ebe6e6db5b3cafa Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 10:57:08 -0300 Subject: [PATCH 49/85] fix(tanstack): fix createServerFn crash under a real npm install (not bun link) This is the real, verified fix for the "createServerFn must be assigned to a variable!" crash that blocked casaevideo-tanstack, lebiscuit-tanstack, and baggagio-tanstack from switching off bun-link to the real published packages. A prior commit (e93125e) added @decocms/apps-vtex to a noExternal-forcing array as a first attempt -- that theory turned out wrong (four independent verification attempts across two agents plus my own testing confirmed noExternal was already `true` unconditionally in every affected site, via @cloudflare/vite-plugin's own Workers-target default, making that array append a complete no-op). This commit identifies and fixes the two real, distinct problems: 1. sdk/createInvoke.ts's `createInvokeFn` returned `createServerFn(...) .handler(...)` from inside a factory function body -- never a top-level variable declarator. TanStack Start's compiler statically scans every file it processes for that literal pattern and throws regardless of whether the factory is ever actually invoked at runtime. createInvokeFn's ONLY real consumer, apps-vtex/invoke.ts, never executes it -- it's a codegen-time-only template that blocks-cli/generate-invoke.ts statically parses (via AST inspection of the call site's arguments, never by importing/running the file) to emit each site's own real, compiler-safe src/server/invoke.gen.ts. Fixed by rewriting createInvokeFn into a non-functional type-only marker (throws if ever actually called) that keeps the exact same signature so invoke.ts still type-checks and generate-invoke.ts's parsing (which never executes the file) is unaffected -- verified via a real generate-invoke.ts run producing byte-identical output. Also removed createInvokeFn/InvokeFnOpts from this package's public root barrel (kept only at the dedicated "./sdk/createInvoke" subpath) so nothing that imports anything else from the root (which every site does, for DecoRootLayout etc.) transitively reaches it. 2. Separately and more fundamentally: even after (1), the crash persisted. Direct diagnostic instrumentation of @tanstack/start-plugin-core traced it to this package's OWN pre-existing, legitimate createServerFn calls (loadCmsPage, loadCmsHomePage, loadDeferredSection) -- once esbuild's `optimizeDeps` pre-bundles this package into a single node_modules/.vite/deps_ssr/*.js chunk (which happens regardless of the noExternal setting -- that option only controls SSR-output inlining vs. externalization, not dev-server-startup pre-bundling), Cloudflare's separate worker-export static-analysis pass (getWorkerEntryExportTypes, a distinct pipeline stage per the workers/runner-worker stack trace) fails to recognize the esbuild-bundled `var X = createServerFn(...)` output as top-level, even though it syntactically is. Fixed by adding this package to `optimizeDeps.exclude` for the "ssr" environment, routing it through Vite's normal plugin pipeline (where the TanStack Start compiler transforms it correctly) instead of esbuild's pre-bundler. Verified end-to-end multiple times against baggagio-tanstack using real, non-symlinked npm tarballs (npm pack + manual tar extraction into node_modules, deliberately NOT bun link, since bun link was confirmed to mask this exact bug by causing Vite to skip pre-bundling symlinked packages entirely): dev server boots cleanly, real HTTP 200 on both the homepage and a PLP route, with real Bagaggio product content. Whole workspace typecheck clean across all 14 packages; apps-vtex's own 215 tests and tanstack's own 59 tests both pass unchanged. Co-Authored-By: Claude Sonnet 5 --- packages/apps-vtex/src/invoke.ts | 2 +- packages/tanstack/package.json | 3 +- packages/tanstack/src/index.ts | 16 +++++- packages/tanstack/src/sdk/createInvoke.ts | 64 ++++++++++++++------- packages/tanstack/src/vite/plugin.js | 70 ++++++++++++++--------- 5 files changed, 103 insertions(+), 52 deletions(-) diff --git a/packages/apps-vtex/src/invoke.ts b/packages/apps-vtex/src/invoke.ts index 1eb668f..9163411 100644 --- a/packages/apps-vtex/src/invoke.ts +++ b/packages/apps-vtex/src/invoke.ts @@ -24,7 +24,7 @@ * 1. Add an entry below with the input/output types and the action call, * 2. From a site repo: `npm run generate:invoke`. */ -import { createInvokeFn } from "@decocms/tanstack"; +import { createInvokeFn } from "@decocms/tanstack/sdk/createInvoke"; import { addCouponToCart, addItemsToCart, diff --git a/packages/tanstack/package.json b/packages/tanstack/package.json index 9688690..d01b0bd 100644 --- a/packages/tanstack/package.json +++ b/packages/tanstack/package.json @@ -12,7 +12,8 @@ "exports": { ".": "./src/index.ts", "./vite": "./src/vite/plugin.js", - "./daemon": "./src/daemon/index.ts" + "./daemon": "./src/daemon/index.ts", + "./sdk/createInvoke": "./src/sdk/createInvoke.ts" }, "scripts": { "build": "tsc", diff --git a/packages/tanstack/src/index.ts b/packages/tanstack/src/index.ts index fc3553d..b30f5ab 100644 --- a/packages/tanstack/src/index.ts +++ b/packages/tanstack/src/index.ts @@ -28,5 +28,17 @@ export { decoStringifySearch, } from "./sdk/router"; export type { CreateDecoRouterOptions } from "./sdk/router"; -export { createInvokeFn } from "./sdk/createInvoke"; -export type { InvokeFnOpts } from "./sdk/createInvoke"; +// createInvokeFn is intentionally NOT re-exported from this root barrel. +// Its body contains a `createServerFn(...).handler(...)` call that is not a +// top-level variable declarator (it's returned from a factory function) -- +// TanStack Start's compiler statically scans every file it processes for +// this pattern and throws "createServerFn must be assigned to a variable!" +// on ANY occurrence, whether or not the factory is ever actually called. +// Re-exporting it here would pull sdk/createInvoke.ts into the module graph +// of every site that imports anything else from this barrel (which is +// every site, for DecoRootLayout etc.), tripping that check even though +// createInvokeFn itself is never invoked at runtime -- it exists purely as +// a source-of-truth template that blocks-cli's generate-invoke.ts statically +// parses (never imports) to emit real top-level createServerFn declarations +// into each site's own src/server/invoke.gen.ts. Import it from the +// dedicated "@decocms/tanstack/sdk/createInvoke" subpath instead. diff --git a/packages/tanstack/src/sdk/createInvoke.ts b/packages/tanstack/src/sdk/createInvoke.ts index 31e6fe7..e667c58 100644 --- a/packages/tanstack/src/sdk/createInvoke.ts +++ b/packages/tanstack/src/sdk/createInvoke.ts @@ -1,12 +1,31 @@ /** - * Generic bridge that turns any async function into a TanStack Start server function. + * Type-only marker for apps-* `invoke.ts` codegen templates (e.g. + * `@decocms/apps-vtex/invoke.ts`) — NOT a real, callable createServerFn + * wrapper. Deliberately does not call `createServerFn` at runtime. * - * Used by @decocms/apps to expose commerce actions/loaders as typed - * `invoke.*` calls that execute on the server with full credentials. + * `invoke.ts` files are never imported/executed by any real site — they + * are a source-of-truth that `blocks-cli`'s `generate-invoke.ts` statically + * parses (via AST inspection of each `createInvokeFn(action, opts)` call + * site's arguments) to emit real, literal top-level `createServerFn(...)` + * declarations into each site's own `src/server/invoke.gen.ts`. This + * function's ONLY job is to give those template files a correctly-typed + * call site to author against. + * + * It must not contain a real `createServerFn(...)` call: TanStack Start's + * compiler statically scans every file reachable by a site's SSR bundle for + * that literal pattern and throws "createServerFn must be assigned to a + * variable!" on ANY occurrence that isn't a top-level declarator — even one + * inside a function body that's never actually invoked. `invoke.ts` isn't + * supposed to be reachable by a site's bundler at all (it's excluded from + * every apps-* package's public exports map), but Vite's `optimizeDeps` + * pre-bundling has been observed sweeping it in regardless of that + * exports-map restriction — so the only fully robust fix is for this file + * to never contain the literal pattern the compiler is looking for, full + * stop, regardless of what does or doesn't end up bundled. * * @example * ```ts - * import { createInvokeFn } from "@decocms/start/sdk/createInvoke"; + * import { createInvokeFn } from "@decocms/tanstack/sdk/createInvoke"; * import { addItemsToCart } from "./actions/checkout"; * * export const invoke = { @@ -20,12 +39,13 @@ * }, * }, * }; - * - * // Client-side usage: - * await invoke.vtex.actions.addItemsToCart({ data: { orderFormId, orderItems } }); * ``` + * + * The real, callable version of each action above only exists in the + * site's generated `src/server/invoke.gen.ts` (run `npm run generate:invoke`), + * which uses real top-level `createServerFn(...)` calls directly — never + * this function. */ -import { createServerFn } from "@tanstack/react-start"; export interface InvokeFnOpts { /** @@ -37,21 +57,21 @@ export interface InvokeFnOpts { } /** - * Transforms an async function into a `createServerFn` wrapper. - * - * - Client calls: `fn({ data: input })` - * - Server executes: `action(input)` - * - If `unwrap: true`, extracts `.data` from VtexFetchResult-shaped results + * Template-only — see the module doc comment above. Throws if actually + * called at runtime, since that would mean something imported an + * `invoke.ts` template file directly instead of using the site's generated + * `invoke.gen.ts`, which is itself a bug worth surfacing loudly rather than + * silently returning wrong behavior. */ export function createInvokeFn( - action: (input: TInput) => Promise, - opts?: InvokeFnOpts, + _action: (input: TInput) => Promise, + _opts?: InvokeFnOpts, ): (ctx: { data: TInput }) => Promise { - return createServerFn({ method: "POST" }).handler(async (ctx) => { - const result = await action(ctx.data as TInput); - if (opts?.unwrap && result && typeof result === "object" && "data" in result) { - return (result as any).data; - } - return result; - }) as unknown as (ctx: { data: TInput }) => Promise; + return () => { + throw new Error( + "createInvokeFn() is a codegen-time-only template marker and was never meant to be called at " + + "runtime. If you're seeing this, something imported an apps-*/invoke.ts template file directly " + + "instead of using the site's generated src/server/invoke.gen.ts (run `npm run generate:invoke`).", + ); + }; } diff --git a/packages/tanstack/src/vite/plugin.js b/packages/tanstack/src/vite/plugin.js index 57468df..ca43435 100644 --- a/packages/tanstack/src/vite/plugin.js +++ b/packages/tanstack/src/vite/plugin.js @@ -588,37 +588,27 @@ export function decoVitePlugin() { env.optimizeDeps.esbuildOptions.jsxImportSource = "react"; } - // Force these packages through the SSR transform pipeline so - // TanStack Start's compiler can register their createServerFn handlers + // Force @decocms/tanstack through the SSR transform pipeline so + // TanStack Start's compiler can register its createServerFn handlers // (loadDeferredSection in routes/cmsRoute.ts, and loadCmsPage / - // loadCmsHomePage alongside it, for @decocms/tanstack) in the - // per-environment serverFnsById manifest. Without this, Vite - // pre-bundles the package via optimizeDeps before plugins run, the - // handler never enters the manifest, and every POST /_serverFn/* call - // from the browser returns HTTP 500 ("Invalid server function ID"). - // See #197. + // loadCmsHomePage alongside it) in the per-environment serverFnsById + // manifest. Without this, Vite pre-bundles the package via + // optimizeDeps before plugins run, the handler never enters the + // manifest, and every POST /_serverFn/* call from the browser returns + // HTTP 500 ("Invalid server function ID"). See #197. // - // @decocms/apps-vtex needs this too: its invoke.ts wraps 18 actions/ - // loaders via createInvokeFn (packages/tanstack/src/sdk/createInvoke.ts), - // each a real `createServerFn(...)` call site. This was missed when - // createInvokeFn's export was added to @decocms/tanstack's public - // barrel (apps-monorepo-migration Task 7) — worked by accident under - // `bun link` (Vite treats symlinked/workspace packages as live source, - // skipping optimizeDeps pre-bundling for them) but hard-crashes with - // a real npm install: "createServerFn must be assigned to a variable!" - // from the Cloudflare Workers runtime's static entry-export scan. - // - // @decocms/blocks does NOT need this: despite its - // validateSection.ts / useScript.ts doc comments mentioning - // `createServerFn`, neither file (nor anything else in runtime) - // actually calls it — those are a JSDoc usage example for a site's - // own server-fn file and a deprecation-message suggestion, - // respectively. Only add a package here once it has a real - // `createServerFn(...)` call site. + // @decocms/apps-vtex does NOT need this: its invoke.ts has zero real + // createServerFn call sites reachable by any site (it's a + // codegen-time-only template that blocks-cli's generate-invoke.ts + // statically parses, never imports, to emit each site's own + // src/server/invoke.gen.ts with real top-level createServerFn consts; + // createInvokeFn itself is a non-functional type-only marker for that + // template, see sdk/createInvoke.ts's doc comment). Only add a + // package here once it has a real `createServerFn(...)` call site. if (name === "ssr") { env.resolve = env.resolve || {}; const existing = env.resolve.noExternal; - const additions = ["@decocms/tanstack", "@decocms/apps-vtex"]; + const additions = ["@decocms/tanstack"]; if (existing === true) { // Already noExternal everything — nothing to add. } else if (Array.isArray(existing)) { @@ -628,6 +618,34 @@ export function decoVitePlugin() { } else { env.resolve.noExternal = additions; } + + // The noExternal setting above only controls whether Vite's SSR + // *output* inlines vs. externalizes this package — it does NOT + // stop Vite's dev-server-startup dependency optimizer (esbuild via + // `optimizeDeps`) from pre-bundling it. Some Cloudflare Workers + // targets (via @cloudflare/vite-plugin) already force + // `resolve.noExternal = true` unconditionally for this same "ssr" + // environment before this hook even runs, making the block above a + // complete no-op there — yet the crash below still happened, + // proving pre-bundling (not noExternal) was always the real cause. + // + // Confirmed via direct diagnostic instrumentation of + // @tanstack/start-plugin-core: once esbuild pre-bundles this + // package into a single node_modules/.vite/deps_ssr/*.js chunk, + // Cloudflare's separate worker-export static-analysis pass + // (getWorkerEntryExportTypes, a distinct pipeline stage from + // Vite's own SSR transform — see workers/runner-worker in the + // stack trace) throws "createServerFn must be assigned to a + // variable!" on this package's own legitimate, already-top-level + // createServerFn calls (loadCmsPage / loadCmsHomePage / + // loadDeferredSection) — apparently that scan doesn't recognize + // esbuild-bundled `var X = createServerFn(...)` output the same + // way it recognizes the original, unbundled source. Excluding this + // package from `optimizeDeps` entirely routes it through Vite's + // normal plugin pipeline (where TanStack Start's compiler + // transforms it correctly) instead of esbuild's pre-bundler. + env.optimizeDeps = env.optimizeDeps || {}; + env.optimizeDeps.exclude = [...new Set([...(env.optimizeDeps.exclude || []), "@decocms/tanstack"])]; } }, From e333dde8920cb16bdc0a12586005ee50e1ff4dc1 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 15:55:35 -0300 Subject: [PATCH 50/85] fix(matchers): wire Multi/Negate matcher fields to #/root/matchers ref Multi's matchers array items and Negate's matcher field were hardcoded as anonymous objects instead of referencing the runtime-composed matcher registry at #/root/matchers. Caused the CMS form to render a blank "Item 1" when expanding a Multi matcher instead of the full matcher picker. Ported from main (decocms/blocks#316, commit 40dd552), which used the old pre-monorepo-split src/ layout -- main and v7 diverged structurally (v7 restructured everything into packages/*) so this was manually re-applied to the equivalent packages/blocks/src/cms/schema.ts location rather than rebased/merged directly. Original-Author: guitavano Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Claude Sonnet 5 --- packages/blocks/src/cms/schema.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/blocks/src/cms/schema.ts b/packages/blocks/src/cms/schema.ts index 197daf6..1796ea3 100644 --- a/packages/blocks/src/cms/schema.ts +++ b/packages/blocks/src/cms/schema.ts @@ -461,12 +461,7 @@ registerMatcherSchemas([ matchers: { type: "array", title: "Matchers", - items: { - type: "object", - required: ["__resolveType"], - properties: { __resolveType: { type: "string" } }, - additionalProperties: true, - }, + items: { $ref: "#/root/matchers" }, }, }, }, @@ -481,11 +476,8 @@ registerMatcherSchemas([ type: "object", properties: { matcher: { - type: "object", + $ref: "#/root/matchers", title: "Matcher", - required: ["__resolveType"], - properties: { __resolveType: { type: "string" } }, - additionalProperties: true, }, }, }, From 6c18cef5af0cce1dbb2a3ee9d020057f7dc5a903 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 15:55:43 -0300 Subject: [PATCH 51/85] perf(blocks-cli): async, non-blocking file reads in generateBlocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateBlocks was declared async but was fully synchronous inside: a readdirSync + a for loop of readFileSync/statSync/JSON.parse over every .deco/blocks/*.json, plus sync writes. On dev cold-start this pegs the single event-loop thread and stalls Vite's startup on a large decofile. Read the directory with bounded-concurrency async I/O (batches of 64 to cap open fds) and use async writes, so the scan yields the event loop between batches. Same output, same dedupe, same return shape. Ported from main (decocms/blocks#320, commit 5a9daf0) -- see the matcher-schema-fix commit just before this one for why this is a manual port rather than a rebase/merge. Original-Author: Pedro França Co-Authored-By: Claude Sonnet 5 --- .../blocks-cli/scripts/generate-blocks.ts | 80 ++++++++++++------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/packages/blocks-cli/scripts/generate-blocks.ts b/packages/blocks-cli/scripts/generate-blocks.ts index 0b271b3..12af2fa 100644 --- a/packages/blocks-cli/scripts/generate-blocks.ts +++ b/packages/blocks-cli/scripts/generate-blocks.ts @@ -77,39 +77,65 @@ export async function generateBlocks( if (!silent) { console.warn(`Blocks directory not found: ${blocksDir} — generating empty barrel.`); } - fs.mkdirSync(path.dirname(outFile), { recursive: true }); - fs.writeFileSync(jsonFile, "{}"); - fs.writeFileSync(outFile, TS_STUB); + await fs.promises.mkdir(path.dirname(outFile), { recursive: true }); + await fs.promises.writeFile(jsonFile, "{}"); + await fs.promises.writeFile(outFile, TS_STUB); return { count: 0, collisions: 0, jsonFile, outFile, empty: true, blocks: {} }; } - const files = fs.readdirSync(blocksDir).filter((f) => f.endsWith(".json")); + const files = (await fs.promises.readdir(blocksDir)).filter((f) => f.endsWith(".json")); // Read each file into a Candidate, then let the dedupe lib pick the winner // per decoded key and report any collisions. See `lib/blocks-dedupe.ts` for // the priority order and the rationale behind it (TL;DR: never use file size, // don't trust mtime alone in CI clones). + // + // Reads run as bounded-concurrency async I/O (not a synchronous + // readFileSync/statSync loop) so this whole-directory scan yields the event + // loop between batches. On dev cold-start the Vite plugin fires this + // fire-and-forget alongside Vite's own startup; a synchronous scan of a few + // hundred `.deco/blocks` files blocked the loop long enough to delay `ready` + // by ~1s (materially worse under a CPU quota, e.g. a sandbox pod). Batched to + // keep the open-fd count bounded (avoid EMFILE on large decofiles). const candidatesWithKeys: Array<{ candidate: Candidate; key: string }> = []; - for (const file of files) { - const { name, passes } = decodeBlockNameWithPasses(file); - const fp = path.join(blocksDir, file); - let parsed: unknown; - try { - parsed = JSON.parse(fs.readFileSync(fp, "utf-8")); - } catch (e) { - if (!silent) console.warn(`Failed to parse ${file}:`, e); - continue; + const READ_BATCH = 64; + for (let i = 0; i < files.length; i += READ_BATCH) { + const batch = await Promise.all( + files.slice(i, i + READ_BATCH).map(async (file) => { + const fp = path.join(blocksDir, file); + try { + const [raw, stat] = await Promise.all([ + fs.promises.readFile(fp, "utf-8"), + fs.promises.stat(fp), + ]); + return { file, raw, mtimeMs: stat.mtimeMs }; + } catch (e) { + if (!silent) console.warn(`Failed to read ${file}:`, e); + return null; + } + }), + ); + for (const entry of batch) { + if (!entry) continue; + const { name, passes } = decodeBlockNameWithPasses(entry.file); + let parsed: unknown; + try { + parsed = JSON.parse(entry.raw); + } catch (e) { + if (!silent) console.warn(`Failed to parse ${entry.file}:`, e); + continue; + } + candidatesWithKeys.push({ + key: name, + candidate: { + file: entry.file, + passes, + mtimeMs: entry.mtimeMs, + hasPath: blockHasPath(parsed), + parsed, + }, + }); } - candidatesWithKeys.push({ - key: name, - candidate: { - file, - passes, - mtimeMs: fs.statSync(fp).mtimeMs, - hasPath: blockHasPath(parsed), - parsed, - }, - }); } const { winners, collisions } = mergeCandidates(candidatesWithKeys); @@ -138,11 +164,11 @@ export async function generateBlocks( blocks[singleDecodeBlockName(c.file)] = c.parsed; } - fs.mkdirSync(path.dirname(outFile), { recursive: true }); + await fs.promises.mkdir(path.dirname(outFile), { recursive: true }); // 1. Compact JSON — the real data (no pretty-printing to save ~40% size) const jsonStr = JSON.stringify(blocks); - fs.writeFileSync(jsonFile, jsonStr); + await fs.promises.writeFile(jsonFile, jsonStr); // 2. Thin TS wrapper — just for TypeScript tooling and as a Vite load target. // Only write if content differs to avoid triggering Vite's file watcher, @@ -150,10 +176,10 @@ export async function generateBlocks( // TanStack Router during dev hot-reload. let existingTs: string | undefined; try { - existingTs = fs.readFileSync(outFile, "utf-8"); + existingTs = await fs.promises.readFile(outFile, "utf-8"); } catch {} if (existingTs !== TS_STUB) { - fs.writeFileSync(outFile, TS_STUB); + await fs.promises.writeFile(outFile, TS_STUB); } if (!silent) { From 62b9a9fa9734b34ac5c97a937a26a6b2988ce6e6 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 15:55:53 -0300 Subject: [PATCH 52/85] fix(schema): recover widget aliases (Color, etc.) that resolve to any When a widget alias like Color is imported from a module ts-morph can't resolve (remote/CDN import), the type comes through as any and typeToJsonSchema returns {}. applyWidgetFormat matched the alias name correctly but every application branch required a pre-existing type, so the format was dropped silently -- the field emitted with neither type nor format, and the admin fell back to a plain text input instead of the color picker. Add a final branch to applyWidgetFormat: when the alias matches a known widget but the schema has no type/$ref (unresolved any), recover it as { type: "string", format } -- every widget alias is string-based. Also makes isMainModule robust to symlinked entrypoints (compares realpathSync of both sides instead of raw file:// URL strings -- tsx symlink traversal was silently skipping generation and writing no meta.gen.json). Ported from main (decocms/blocks#322, commit 73d9345). Original-Author: guitavano Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Claude Sonnet 5 --- .../scripts/generate-schema.test.ts | 74 +++++++++++++++++++ .../blocks-cli/scripts/generate-schema.ts | 60 +++++++++++---- 2 files changed, 119 insertions(+), 15 deletions(-) create mode 100644 packages/blocks-cli/scripts/generate-schema.test.ts diff --git a/packages/blocks-cli/scripts/generate-schema.test.ts b/packages/blocks-cli/scripts/generate-schema.test.ts new file mode 100644 index 0000000..e58fd63 --- /dev/null +++ b/packages/blocks-cli/scripts/generate-schema.test.ts @@ -0,0 +1,74 @@ +import { Project } from "ts-morph"; +import { describe, expect, it } from "vitest"; +import { WIDGET_TYPE_FORMATS, applyWidgetFormat, typeToJsonSchema } from "./generate-schema"; + +describe("applyWidgetFormat", () => { + it("recovers an unresolved widget alias (empty schema) as string + format", () => { + // When a widget alias like `Color` is imported from a module ts-morph can't + // resolve (remote/CDN), the type comes through as `any` and typeToJsonSchema + // returns {}. The intended widget must still be recovered. + const schema: any = {}; + applyWidgetFormat(schema, "Color"); + expect(schema).toEqual({ type: "string", format: "color" }); + }); + + it.each(Object.entries(WIDGET_TYPE_FORMATS))( + "recovers the %s alias to { type: string, format: %s } from an empty schema", + (alias, format) => { + const schema: any = {}; + applyWidgetFormat(schema, alias); + expect(schema).toEqual({ type: "string", format }); + }, + ); + + it("applies the format to a resolved string schema", () => { + const schema: any = { type: "string" }; + applyWidgetFormat(schema, "Color"); + expect(schema).toEqual({ type: "string", format: "color" }); + }); + + it("does not overwrite a schema that resolved to a $ref", () => { + const schema: any = { $ref: "#/definitions/Foo" }; + applyWidgetFormat(schema, "Color"); + expect(schema).toEqual({ $ref: "#/definitions/Foo" }); + }); + + it("does not touch a schema for a non-widget type hint", () => { + const schema: any = {}; + applyWidgetFormat(schema, "SomeRandomType"); + expect(schema).toEqual({}); + }); +}); + +describe("typeToJsonSchema with an unresolvable widget alias import", () => { + it("emits { type: string, format: color } for a Color field imported from a CDN", () => { + const project = new Project({ + useInMemoryFileSystem: true, + compilerOptions: { skipLibCheck: true, noResolve: false }, + }); + + // The import target is not resolvable, mirroring apps that import `Color` + // from a remote deco-cx/apps CDN URL — `Color` therefore resolves to `any`. + const sf = project.createSourceFile( + "props.ts", + ` + import type { Color } from "https://cdn.example.com/admin/widgets.ts"; + + export interface Props { + /** @title Cor do Texto */ + textLeftColor?: Color; + } + `, + ); + + const propsType = sf.getInterfaceOrThrow("Props").getType(); + const schema = typeToJsonSchema(propsType); + + expect(schema.type).toBe("object"); + expect(schema.properties.textLeftColor).toEqual({ + title: "Cor do Texto", + type: "string", + format: "color", + }); + }); +}); diff --git a/packages/blocks-cli/scripts/generate-schema.ts b/packages/blocks-cli/scripts/generate-schema.ts index 55830e5..7230c6f 100644 --- a/packages/blocks-cli/scripts/generate-schema.ts +++ b/packages/blocks-cli/scripts/generate-schema.ts @@ -1,6 +1,7 @@ #!/usr/bin/env tsx import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; /** * Schema Generator for deco admin compatibility. * @@ -153,7 +154,7 @@ function applyJsDocToSchema(schema: any, tags: Record): void { } } -const WIDGET_TYPE_FORMATS: Record = { +export const WIDGET_TYPE_FORMATS: Record = { ImageWidget: "image-uri", VideoWidget: "video-uri", HTMLWidget: "html", @@ -183,7 +184,7 @@ function applyWidgetDetection(schema: any, typeText: string): void { * Smart widget format application that handles arrays, nullable types, * and union types by applying the format to the correct inner schema. */ -function applyWidgetFormat(schema: any, typeHint: string): void { +export function applyWidgetFormat(schema: any, typeHint: string): void { const matchedFormat = Object.entries(WIDGET_TYPE_FORMATS).find( ([wt]) => typeHint === wt || typeHint.includes(wt), )?.[1]; @@ -205,6 +206,12 @@ function applyWidgetFormat(schema: any, typeHint: string): void { variant.format = matchedFormat; } } + } else if (!schema.type && !schema.$ref) { + // Widget alias (e.g. `Color`) not resolvable by ts-morph (remote/CDN import) + // → it came through as `any`, so typeToJsonSchema returned an empty schema. + // Every widget alias is string-based, so recover the intended widget here. + schema.type = "string"; + schema.format = matchedFormat; } } @@ -262,7 +269,7 @@ function extractLoaderOutputTypeName(sourceFile: SourceFile): string | null { return ret.getSymbol()?.getName() ?? ret.getAliasSymbol()?.getName() ?? null; } -function typeToJsonSchema(type: Type, visited = new Set(), ctx?: GenerationContext): any { +export function typeToJsonSchema(type: Type, visited = new Set(), ctx?: GenerationContext): any { const typeText = type.getText(); if (visited.has(typeText)) return { type: "object" }; visited.add(typeText); @@ -1283,15 +1290,38 @@ function generateMeta(): MetaResponse { }; } -const meta = generateMeta(); -const outPath = path.resolve(process.cwd(), OUT_REL); -fs.mkdirSync(path.dirname(outPath), { recursive: true }); -fs.writeFileSync(outPath, JSON.stringify(meta, null, 2)); - -const defCount = Object.keys(meta.schema.definitions).length; -const secCount = Object.keys(meta.manifest.blocks.sections || {}).length; -const ldrCount = Object.keys(meta.manifest.blocks.loaders || {}).length; -const appCount = Object.keys(meta.manifest.blocks.apps || {}).length; -console.log( - `\nGenerated schema: ${defCount} definitions, ${secCount} sections, ${ldrCount} loaders, ${appCount} apps → ${path.relative(process.cwd(), outPath)}`, -); +function isMainModule(): boolean { + // True when this file is the process entrypoint (invoked directly), false when + // it's imported (e.g. from tests) — the guard keeps the module importable + // without triggering a full filesystem scan + write. + // + // Compare the *realpath* of both sides rather than the raw URL: under tsx / + // pnpm the entrypoint is reached through symlinks (pnpm's node_modules, + // macOS /tmp → /private/tmp), so argv[1] and import.meta.url spell the same + // file differently. A raw string compare would silently return false and skip + // generation. realpathSync collapses symlinks so both resolve identically. + const entry = process.argv[1]; + if (!entry) return false; + try { + const entryPath = fs.realpathSync(path.resolve(entry)); + const selfPath = fs.realpathSync(fileURLToPath(import.meta.url)); + return entryPath === selfPath; + } catch { + return false; + } +} + +if (isMainModule()) { + const meta = generateMeta(); + const outPath = path.resolve(process.cwd(), OUT_REL); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, JSON.stringify(meta, null, 2)); + + const defCount = Object.keys(meta.schema.definitions).length; + const secCount = Object.keys(meta.manifest.blocks.sections || {}).length; + const ldrCount = Object.keys(meta.manifest.blocks.loaders || {}).length; + const appCount = Object.keys(meta.manifest.blocks.apps || {}).length; + console.log( + `\nGenerated schema: ${defCount} definitions, ${secCount} sections, ${ldrCount} loaders, ${appCount} apps → ${path.relative(process.cwd(), outPath)}`, + ); +} From 050d3d15cd71c5de24005e12f07a6f651a726e00 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 15:56:46 -0300 Subject: [PATCH 53/85] feat(observability): unify metrics with deco-cx/deco semconv contract Align telemetry with the deco-cx/deco framework so both land on the same series/labels in ClickHouse (no collisions): - Durations normalized to SECONDS at the source (was ms). http.server / http.client / loader durations all divide by 1000 at record time. - HTTP server attributes -> OTel semconv: method -> http.request.method, route_pattern -> http.route, status -> http.response.status_code. Proprietary extras moved under deco.* (deco.http.status_class / .outcome / .region, deco.cache.decision / .layer). - Cache: the two hits/misses counters collapse into a single deco.cache.requests counter dimensioned by deco.cache.status (follows the OTel semconv pattern, e.g. nfs.server.repcache.requests + .status). - Loader labels namespaced under deco.* (deco.loader.name, deco.cache.result). - deco.cms.resolve.duration drops its path label entirely: the raw request path was unbounded cardinality (one histogram series per product/search/facet URL) and was the single biggest series in ClickHouse (~56% of otel_metrics_histogram). Per-route detail still lives on the resolve span's sampled deco.route attribute -- the correct home for high-cardinality per-request dimensions. - normalizePath exported so every route-labeled metric shares the exact same cardinality-bounding normalization. Caller API (RequestMetricLabels field names) is unchanged -- only emitted keys change. Ported from main (decocms/blocks, commits 6655cf3 + be66958 + 0f6ce27 + 11c23df, squashed here since all four are sequential fixes to the same few functions in this one file -- splitting them into 4 separate commits here would be artificial). resolve.ts's corresponding one-line change (drop the RESOLVE_DURATION metric's path label, keep the span attribute) is bundled into the sticky-flags commit that follows, since that commit touches the same function for an unrelated reason. Original-Author: Nicacio Oliveira Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Claude Sonnet 5 --- .../src/middleware/observability.test.ts | 48 ++++--- .../blocks/src/middleware/observability.ts | 129 +++++++++++------- 2 files changed, 102 insertions(+), 75 deletions(-) diff --git a/packages/blocks/src/middleware/observability.test.ts b/packages/blocks/src/middleware/observability.test.ts index 88985ad..b4e9c45 100644 --- a/packages/blocks/src/middleware/observability.test.ts +++ b/packages/blocks/src/middleware/observability.test.ts @@ -81,13 +81,13 @@ describe("recordRequestMetric — canonical labels (D-11)", () => { expect(counters).toHaveLength(0); expect(histograms).toHaveLength(1); expect(histograms[0]?.name).toBe(MetricNames.HTTP_SERVER_REQUEST_DURATION); - expect(histograms[0]?.value).toBe(42); + expect(histograms[0]?.value).toBe(0.042); // seconds (semconv) expect(histograms[0]?.labels).toMatchObject({ - method: "GET", + "http.request.method": "GET", // Default normalization: dynamic segments collapsed. - route_pattern: "/products/:slug/p", - status: 200, - status_class: "2xx", + "http.route": "/products/:slug/p", + "http.response.status_code": 200, + "deco.http.status_class": "2xx", }); }); @@ -99,7 +99,7 @@ describe("recordRequestMetric — canonical labels (D-11)", () => { route_pattern: "/_products/$slug/p", }); - expect(histograms[0]?.labels?.route_pattern).toBe("/_products/$slug/p"); + expect(histograms[0]?.labels?.["http.route"]).toBe("/_products/$slug/p"); }); it("tags 5xx requests with status_class=5xx for downstream error filtering", () => { @@ -108,8 +108,8 @@ describe("recordRequestMetric — canonical labels (D-11)", () => { recordRequestMetric("POST", "/checkout", 503, 120); - expect(histograms[0]?.labels?.status_class).toBe("5xx"); - expect(histograms[0]?.labels?.status).toBe(503); + expect(histograms[0]?.labels?.["deco.http.status_class"]).toBe("5xx"); + expect(histograms[0]?.labels?.["http.response.status_code"]).toBe(503); }); it("propagates optional labels (outcome, cache_decision, cache_layer, region, extra)", () => { @@ -125,10 +125,10 @@ describe("recordRequestMetric — canonical labels (D-11)", () => { }); expect(histograms[0]?.labels).toMatchObject({ - outcome: "ok", - cache_decision: "STALE-HIT", - cache_layer: "edge", - region: "GRU", + "deco.http.outcome": "ok", + "deco.cache.decision": "STALE-HIT", + "deco.cache.layer": "edge", + "deco.http.region": "GRU", ab_variant: "B", }); }); @@ -154,21 +154,22 @@ describe("recordCacheMetric — cache_layer label", () => { recordCacheMetric(true, "product", "HIT", "edge"); expect(counters).toHaveLength(1); - expect(counters[0]?.name).toBe(MetricNames.CACHE_HIT); + expect(counters[0]?.name).toBe(MetricNames.CACHE_REQUESTS); expect(counters[0]?.labels).toMatchObject({ - profile: "product", - cache_decision: "HIT", - cache_layer: "edge", + "deco.cache.profile": "product", + "deco.cache.status": "HIT", + "deco.cache.layer": "edge", }); }); - it("emits cache_miss_total when hit=false", () => { + it("records status=MISS when hit=false", () => { const { adapter, counters } = captureMeter(); configureMeter(adapter); recordCacheMetric(false, "search", "MISS", "edge"); - expect(counters[0]?.name).toBe(MetricNames.CACHE_MISS); + expect(counters[0]?.name).toBe(MetricNames.CACHE_REQUESTS); + expect(counters[0]?.labels?.["deco.cache.status"]).toBe("MISS"); }); it("supports the legacy 3-arg signature for backward compat", () => { @@ -177,7 +178,10 @@ describe("recordCacheMetric — cache_layer label", () => { recordCacheMetric(true, "static"); - expect(counters[0]?.labels).toEqual({ profile: "static" }); + expect(counters[0]?.labels).toEqual({ + "deco.cache.status": "HIT", + "deco.cache.profile": "static", + }); }); it("distinguishes cachedLoader vs edge vs vtex-swr layers", () => { @@ -187,8 +191,8 @@ describe("recordCacheMetric — cache_layer label", () => { recordCacheMetric(true, "loader-x", "HIT", "cachedLoader"); recordCacheMetric(true, "vtex-product", "HIT", "vtex-swr"); - expect(counters[0]?.labels?.cache_layer).toBe("cachedLoader"); - expect(counters[1]?.labels?.cache_layer).toBe("vtex-swr"); + expect(counters[0]?.labels?.["deco.cache.layer"]).toBe("cachedLoader"); + expect(counters[1]?.labels?.["deco.cache.layer"]).toBe("vtex-swr"); }); }); @@ -209,7 +213,7 @@ describe("recordCommerceMetric (D-11)", () => { expect(histograms).toHaveLength(1); expect(histograms[0]?.name).toBe(MetricNames.HTTP_CLIENT_REQUEST_DURATION); - expect(histograms[0]?.value).toBe(123); + expect(histograms[0]?.value).toBe(0.123); // seconds (semconv) expect(histograms[0]?.labels).toMatchObject({ provider: "vtex", operation: "intelligent-search.product_search", diff --git a/packages/blocks/src/middleware/observability.ts b/packages/blocks/src/middleware/observability.ts index 2a8b59f..596f42f 100644 --- a/packages/blocks/src/middleware/observability.ts +++ b/packages/blocks/src/middleware/observability.ts @@ -34,6 +34,9 @@ import * as asyncHooks from "node:async_hooks"; import { + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_HTTP_ROUTE, METRIC_HTTP_CLIENT_REQUEST_DURATION, METRIC_HTTP_SERVER_REQUEST_DURATION, } from "@opentelemetry/semantic-conventions"; @@ -257,8 +260,10 @@ export const MetricNames = { HTTP_SERVER_REQUEST_DURATION: METRIC_HTTP_SERVER_REQUEST_DURATION, HTTP_CLIENT_REQUEST_DURATION: METRIC_HTTP_CLIENT_REQUEST_DURATION, // Deco extensions — no canonical OTel metric exists for these concepts. - CACHE_HIT: "deco.cache.hits", - CACHE_MISS: "deco.cache.misses", + // Single cache counter dimensioned by `deco.cache.status` — follows the OTel + // semconv pattern (cf. nfs.server.repcache.requests + .status). deco-cx/deco + // uses the same name so both frameworks aggregate together. + CACHE_REQUESTS: "deco.cache.requests", RESOLVE_DURATION: "deco.cms.resolve.duration", LOADER_DURATION: "deco.loader.duration", LOADER_ERRORS: "deco.loader.errors", @@ -266,11 +271,9 @@ export const MetricNames = { /** * Per-metric metadata emitted in the OTLP payload's `description` and - * `unit` fields. The framework declares units honestly — durations are in - * `ms` because that's what `recordRequestMetric`-style callers pass. If a - * downstream consumer needs canonical OTel seconds for - * `http.server.request.duration`, conversion happens in the collector or - * the query layer, NOT in this framework. + * `unit` fields. Durations are normalized to **seconds at the source** (OTel + * semconv), matching the deco-cx/deco framework — NOT converted downstream in + * the collector. Callers still pass milliseconds; the record helpers divide. * * Keep keys aligned with `MetricNames` values so a missing entry is a * type error at compile time when a new metric ships without metadata. @@ -278,27 +281,23 @@ export const MetricNames = { export const METRIC_METADATA: Record = { [MetricNames.HTTP_SERVER_REQUEST_DURATION]: { description: "Duration of HTTP server requests handled at the Worker entry point.", - unit: "ms", + unit: "s", }, [MetricNames.HTTP_CLIENT_REQUEST_DURATION]: { description: "Duration of outbound HTTP client requests (commerce, generic fetch).", - unit: "ms", + unit: "s", }, - [MetricNames.CACHE_HIT]: { - description: "Cache lookups resulting in HIT, STALE-HIT, or STALE-ERROR.", - unit: "{request}", - }, - [MetricNames.CACHE_MISS]: { - description: "Cache lookups resulting in MISS or BYPASS.", + [MetricNames.CACHE_REQUESTS]: { + description: "Cache lookups, dimensioned by deco.cache.status (hit/stale/miss/bypass).", unit: "{request}", }, [MetricNames.RESOLVE_DURATION]: { description: "Duration of `deco.cms.resolvePage` — CMS route to block tree resolution.", - unit: "ms", + unit: "s", }, [MetricNames.LOADER_DURATION]: { description: "Per-loader execution duration, emitted by cachedLoader.", - unit: "ms", + unit: "s", }, [MetricNames.LOADER_ERRORS]: { description: "Per-loader error count.", @@ -321,10 +320,11 @@ export function statusClassFor(status: number): string { } /** - * Optional dimensions stamped on `http_requests_total` / - * `http_request_duration_ms` / `http_request_errors_total`. All fields - * are optional — callers pass what they have, the framework fills in - * the rest from defaults. + * Optional dimensions stamped on `http.server.request.duration` (semconv). + * Request count and error rate are derived from the histogram's `count` and + * a `http.response.status_code` filter — the old separate `_total` / + * `_errors_total` counters were removed. All fields are optional — callers + * pass what they have, the framework fills in the rest from defaults. * * Cardinality discipline: every field here is bounded. `route_pattern` * comes from the TanStack router (a closed set), `outcome` is the CF @@ -403,16 +403,19 @@ export function recordRequestMetric( // Total combinations are bounded — safe for unbounded series on // ClickHouse but operators should still avoid grouping by `region` // unless explicitly needed. + // semconv attribute keys for the core HTTP dimensions; deco.* for the + // proprietary extras. Unified with deco-cx/deco so both frameworks land on + // the same series/labels in ClickHouse. const merged: Labels = { - method, - route_pattern: labels?.route_pattern ?? normalizePath(path), - status, - status_class: statusClassFor(status), + [ATTR_HTTP_REQUEST_METHOD]: method, + [ATTR_HTTP_ROUTE]: labels?.route_pattern ?? normalizePath(path), + [ATTR_HTTP_RESPONSE_STATUS_CODE]: status, + "deco.http.status_class": statusClassFor(status), }; - if (labels?.outcome) merged.outcome = labels.outcome; - if (labels?.cache_decision) merged.cache_decision = labels.cache_decision; - if (labels?.cache_layer) merged.cache_layer = labels.cache_layer; - if (labels?.region) merged.region = labels.region; + if (labels?.outcome) merged["deco.http.outcome"] = labels.outcome; + if (labels?.cache_decision) merged["deco.cache.decision"] = labels.cache_decision; + if (labels?.cache_layer) merged["deco.cache.layer"] = labels.cache_layer; + if (labels?.region) merged["deco.http.region"] = labels.region; if (labels?.extra) { for (const [k, v] of Object.entries(labels.extra)) { // Defense-in-depth — refuse to stamp known per-request identifiers @@ -430,7 +433,12 @@ export function recordRequestMetric( // filtering on `http.response.status_code`. Separate `_total` / // `_errors_total` counters were removed because they duplicate // histogram-derived signals and aren't part of the canonical spec. - m.histogramRecord?.(MetricNames.HTTP_SERVER_REQUEST_DURATION, durationMs, merged); + // Record in seconds (semconv) — caller passes ms. + m.histogramRecord?.( + MetricNames.HTTP_SERVER_REQUEST_DURATION, + durationMs / 1000, + merged, + ); } /** @@ -480,28 +488,30 @@ export function recordCacheMetric( // meter is a no-op (e.g. on tests, or in dev without DECO_METRICS). const active = getActiveSpan(); if (active) { - if (decision) active.setAttribute?.("deco.cache.decision", decision); + if (decision) active.setAttribute?.("deco.cache.status", decision); if (profile) active.setAttribute?.("deco.cache.profile", profile); if (layer) active.setAttribute?.("deco.cache.layer", layer); } const m = getState().meter; if (!m) return; - // Label names mirror the dotted span attributes `deco.cache.decision`, - // `deco.cache.profile`, `deco.cache.layer` (see model/registry/deco.yaml + - // model/metrics.yaml). Prometheus convention requires snake_case without - // dots — so `cache_decision` / `cache_layer` (not `decision` / `layer`). - const labels: Labels = {}; - if (profile) labels.profile = profile; - if (decision) labels.cache_decision = decision; - if (layer) labels.cache_layer = layer; - m.counterInc(hit ? MetricNames.CACHE_HIT : MetricNames.CACHE_MISS, 1, labels); + // Single counter dimensioned by deco.cache.status — unified with deco-cx/deco + // (follows the semconv nfs.server.repcache.requests + .status pattern). status + // is the decision when known (HIT/STALE-HIT/STALE-ERROR/MISS/BYPASS), else the + // hit boolean. Same key on span + metric. + const labels: Labels = { + "deco.cache.status": decision ?? (hit ? "HIT" : "MISS"), + }; + if (profile) labels["deco.cache.profile"] = profile; + if (layer) labels["deco.cache.layer"] = layer; + m.counterInc(MetricNames.CACHE_REQUESTS, 1, labels); } /** - * Labels for `commerce_request_duration_ms`. Owned by the framework so - * apps-start (and any future provider package) can register operation - * strings without owning the histogram declaration. Phase 2 (D-11). + * Labels for the outbound commerce sample recorded on + * `http.client.request.duration`. Owned by the framework so apps-start (and + * any future provider package) can register operation strings without owning + * the histogram declaration. Phase 2 (D-11). */ export interface CommerceMetricLabels { /** `vtex`, `shopify`, `wake`, ... — small closed set. */ @@ -515,10 +525,10 @@ export interface CommerceMetricLabels { } /** - * Record a commerce / outbound-fetch duration sample. No-op when no - * meter is configured. The metric name is constant - * (`commerce_request_duration_ms`) — providers vary by the `provider` - * label, not by name, so dashboards aggregate cleanly across the fleet. + * Record a commerce / outbound-fetch duration sample. No-op when no meter is + * configured. Emitted on the canonical `http.client.request.duration` metric + * (semconv) — providers vary by the `provider`/`operation` labels, not by + * name, so dashboards aggregate cleanly across the fleet. */ export function recordCommerceMetric( durationMs: number, @@ -535,7 +545,12 @@ export function recordCommerceMetric( // Canonical OTel HTTP client metric — outbound commerce calls share the // same metric as any other outbound HTTP request; `peer.service` / // `commerce.operation` attributes disambiguate consumer queries. - m.histogramRecord?.(MetricNames.HTTP_CLIENT_REQUEST_DURATION, durationMs, merged); + // Record in seconds (semconv) — caller passes ms. + m.histogramRecord?.( + MetricNames.HTTP_CLIENT_REQUEST_DURATION, + durationMs / 1000, + merged, + ); } /** @@ -551,9 +566,10 @@ export function recordLoaderMetric( ) { const m = getState().meter; if (!m) return; - m.histogramRecord?.(MetricNames.LOADER_DURATION, durationMs, { - loader: name, - cache_status: cacheStatus, + // Record in seconds (semconv) — caller passes ms. + m.histogramRecord?.(MetricNames.LOADER_DURATION, durationMs / 1000, { + "deco.loader.name": name, + "deco.cache.result": cacheStatus, }); } @@ -564,10 +580,17 @@ export function recordLoaderMetric( export function recordLoaderError(name: string) { const m = getState().meter; if (!m) return; - m.counterInc(MetricNames.LOADER_ERRORS, 1, { loader: name }); + m.counterInc(MetricNames.LOADER_ERRORS, 1, { "deco.loader.name": name }); } -function normalizePath(path: string): string { +/** + * Collapse dynamic path segments (ids, product slugs) into placeholders so a + * path can be used as a bounded metric label. Exported so every metric that + * labels by route (`http.server.request.duration`, `deco.cms.resolve.duration`) + * shares the exact same normalization — a raw path is unbounded cardinality + * (one histogram series per URL) and must never reach a metric attribute. + */ +export function normalizePath(path: string): string { // Collapse dynamic segments to reduce cardinality return path .replace(/\/[0-9a-f]{8,}/gi, "/:id") From ec1b630dc94cc95f4db3c521734405400999c845 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 15:57:16 -0300 Subject: [PATCH 54/85] feat(sdk): default outbound User-Agent + sticky A/B multivariate flags Two independent upstream fixes combined here because both touch sdk/workerEntry.ts's request/response pipeline: 1. Outbound User-Agent + x-powered-by parity (decocms/blocks#319): Cloudflare Workers fetch sends no User-Agent at all, unlike the old Deno runtime where every outbound request implicitly carried Deno/x.y.z. UA-less requests trip Cloudflare managed WAF rules / Bot Fight Mode on partner origins, blocking loader calls. New sdk/outboundHeaders.ts: installDefaultUserAgent() patches globalThis.fetch once (idempotent) to set Deco/ (+https://deco.cx) when the caller didn't set a UA; app-specific UAs always win. createDecoWorkerEntry installs it by default; new outboundUserAgent option overrides the value or disables with false. Responses now carry x-powered-by: deco@ (set-if-absent). 2. Sticky, cache-correct A/B multivariate flags (decocms/blocks#323): website/matchers/random.ts was a stateless Math.random() < traffic, so a visitor's variant re-rolled every request and -- because the HTML is edge-cached and the cache key never carried the variant -- the first roll got frozen and served to everyone in the device/geo bucket. Analytics never saw the active flag either. New packages/blocks/src/sdk/flags.ts (deco_segment cookie codec, shared by both packages/blocks and packages/tanstack -- resolve.ts records sticky decisions, cmsRoute.ts persists them to the cookie, workerEntry.ts folds the cohort into the cache key so each variant keeps its own cached HTML). resolve.ts's RESOLVE_DURATION metric also drops its path label here (bundled from the observability commit just before this one, since this is the same function). Ported from main (commits 1f6da52 + 109913e). flags.ts lives in packages/blocks (not packages/tanstack) because resolve.ts, which records the sticky decisions, is a packages/blocks file and needs a same-package import; workerEntry.ts and cmsRoute.ts (packages/tanstack) consume it via the new @decocms/blocks/sdk/flags export instead of the relative "./flags" import main used before the monorepo split. tanstack/tsconfig.json's "include" gained "package.json" (matching packages/blocks's own tsconfig) since outboundHeaders.ts reads its own package version from it -- without this tsc can't resolve the JSON import even with resolveJsonModule enabled. Original-Authors: hugo-ccabral , guitavano Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Claude Sonnet 5 --- packages/blocks/package.json | 1 + packages/blocks/src/cms/resolve.ts | 111 +++++++++++++++-- packages/blocks/src/cms/stickyFlags.test.ts | 117 ++++++++++++++++++ packages/blocks/src/sdk/flags.test.ts | 91 ++++++++++++++ packages/blocks/src/sdk/flags.ts | 108 ++++++++++++++++ packages/tanstack/src/routes/cmsRoute.ts | 52 ++++++++ .../tanstack/src/sdk/outboundHeaders.test.ts | 113 +++++++++++++++++ packages/tanstack/src/sdk/outboundHeaders.ts | 81 ++++++++++++ packages/tanstack/src/sdk/workerEntry.ts | 79 +++++++++++- packages/tanstack/tsconfig.json | 2 +- 10 files changed, 743 insertions(+), 12 deletions(-) create mode 100644 packages/blocks/src/cms/stickyFlags.test.ts create mode 100644 packages/blocks/src/sdk/flags.test.ts create mode 100644 packages/blocks/src/sdk/flags.ts create mode 100644 packages/tanstack/src/sdk/outboundHeaders.test.ts create mode 100644 packages/tanstack/src/sdk/outboundHeaders.ts diff --git a/packages/blocks/package.json b/packages/blocks/package.json index 634097b..fe918a3 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -49,6 +49,7 @@ "./sdk/djb2": "./src/sdk/djb2.ts", "./sdk/encoding": "./src/sdk/encoding.ts", "./sdk/env": "./src/sdk/env.ts", + "./sdk/flags": "./src/sdk/flags.ts", "./sdk/http": "./src/sdk/http.ts", "./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts", "./sdk/invoke": "./src/sdk/invoke.ts", diff --git a/packages/blocks/src/cms/resolve.ts b/packages/blocks/src/cms/resolve.ts index 436b912..ea1846b 100644 --- a/packages/blocks/src/cms/resolve.ts +++ b/packages/blocks/src/cms/resolve.ts @@ -7,6 +7,7 @@ import { import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override"; import { getMeter, MetricNames, withTracing } from "../middleware/observability"; import { djb2Hex } from "../sdk/djb2"; +import { parseSegmentCookie, SEGMENT_COOKIE, type StoredFlag, trafficToPct } from "../sdk/flags"; import { withInflightTimeout } from "../sdk/inflightTimeout"; import { normalizeUrlsInObject } from "../sdk/normalizeUrls"; import { findPageByPath, loadBlocks } from "./loader"; @@ -380,6 +381,15 @@ export interface MatcherContext { cookies?: Record; headers?: Record; request?: Request; + /** + * Sticky A/B flags recorded during resolution. The resolver appends a + * {@link StoredFlag} the first time it evaluates each sticky matcher (see + * `evaluateVariantRule`), reusing the decision for the rest of the request + * so every resolve pass picks the same variant. Read it after resolution to + * persist the `deco_flags` cookie and split the edge cache per cohort. + * Pass an array in to opt into recording; leave undefined to disable. + */ + flags?: StoredFlag[]; /** * Client-side (SPA) navigation via TanStack ``. Disables section * deferral: deferral is a streaming-SSR optimization, but a client nav @@ -650,6 +660,85 @@ export function evaluateMatcher( return false; } +// --------------------------------------------------------------------------- +// Sticky matchers (A/B) +// --------------------------------------------------------------------------- + +/** + * Matcher types whose decision must stick per user instead of re-rolling every + * request. Mirrors the `sticky = "session"` export on the corresponding + * matchers in @decocms/apps (deco-start re-implements matchers inline, so it + * declares the set here rather than importing those exports). + */ +const STICKY_MATCHER_TYPES = new Set(["website/matchers/random.ts"]); + +interface StickyMeta { + /** Cohort identity — the named matcher block (e.g. "TestHero"). */ + name: string; + /** Traffic share for the `true` branch. */ + traffic: number; + /** `round(traffic * 100)` — the re-roll fingerprint. */ + pct: number; +} + +/** + * If `rule` points at a sticky matcher, return its cohort identity + traffic; + * otherwise `null` (evaluate normally). A rule is either a named matcher block + * (`{__resolveType: "TestHero"}`, dereferenced here to read its `traffic`) or + * an inline matcher; the block name is the stable cohort key when present. + */ +function stickyMatcherMeta(rule: Record | undefined): StickyMeta | null { + const rt = rule?.__resolveType as string | undefined; + if (!rt) return null; + + const blocks = loadBlocks(); + const name = rt; + let resolved = rule as Record; + if (blocks[rt]) { + resolved = blocks[rt] as Record; + } + + const type = resolved.__resolveType as string | undefined; + if (!type || !STICKY_MATCHER_TYPES.has(type)) return null; + + const traffic = typeof resolved.traffic === "number" ? resolved.traffic : 0.5; + return { name, traffic, pct: trafficToPct(traffic) }; +} + +/** + * Evaluate a variant's rule, applying sticky persistence for A/B matchers. + * + * For a sticky matcher: reuse the decision already recorded this request; else + * honor the `deco_segment` cookie when its fingerprint still matches the current + * `traffic`; else roll fresh. Every decision is recorded on `ctx.flags` (when + * present) so the caller can persist the cookie and split the cache. Non-sticky + * rules fall through to the plain matcher. + */ +function evaluateVariantRule( + rule: Record | undefined, + ctx: MatcherContext, +): boolean { + const meta = stickyMatcherMeta(rule); + // Admin preview forces variants via x-deco-matchers-override; defer to + // evaluateMatcher so a forced decision wins. Sticky persistence is a + // production concern (previews aren't cached), so skipping it here is fine. + if (!meta || hasMatchersOverride(ctx)) return evaluateMatcher(rule, ctx); + + // Reuse a decision already made this request so every resolve pass (page, + // shallow section-key, deferred) selects the same variant. + const already = ctx.flags?.find((f) => f.name === meta.name && f.pct === meta.pct); + if (already) return already.value; + + const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find((f) => f.name === meta.name); + // pct === -1 marks a classic-deco segment without a fingerprint — honor it + // (stay sticky) instead of re-rolling. A stale fingerprint re-rolls. + const useStored = stored && (stored.pct === -1 || stored.pct === meta.pct); + const value = useStored ? stored.value : Math.random() < meta.traffic; + + ctx.flags?.push({ name: meta.name, value, pct: meta.pct }); + return value; +} + // --------------------------------------------------------------------------- // Select (partial field picking) // --------------------------------------------------------------------------- @@ -742,7 +831,7 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise | undefined; - if (evaluateMatcher(rule, rctx.matcherCtx)) { + if (evaluateVariantRule(rule, rctx.matcherCtx)) { return internalResolve(variant.value, childCtx); } } @@ -1047,7 +1136,7 @@ export async function resolvePageSeoBlock( let matched: unknown = null; for (const variant of variants) { const rule = variant.rule as Record | undefined; - if (evaluateMatcher(rule, rctx.matcherCtx)) { + if (evaluateVariantRule(rule, rctx.matcherCtx)) { matched = variant.value; break; } @@ -1228,7 +1317,7 @@ function resolveFinalSectionKey(section: unknown, matcherCtx?: MatcherContext): let matched: unknown = null; for (const variant of variants) { const rule = variant.rule as Record | undefined; - if (evaluateMatcher(rule, matcherCtx ?? {})) { + if (evaluateVariantRule(rule, matcherCtx ?? {})) { matched = variant.value; break; } @@ -1281,7 +1370,7 @@ function isCmsDeferralWrapped(section: unknown, matcherCtx?: MatcherContext): bo let matched: unknown = null; for (const variant of variants) { const rule = variant.rule as Record | undefined; - if (evaluateMatcher(rule, matcherCtx ?? {})) { + if (evaluateVariantRule(rule, matcherCtx ?? {})) { matched = variant.value; break; } @@ -1433,7 +1522,7 @@ function resolveSectionShallow( let matched: unknown = null; for (const variant of variants) { const rule = variant.rule as Record | undefined; - if (evaluateMatcher(rule, matcherCtx ?? {})) { + if (evaluateVariantRule(rule, matcherCtx ?? {})) { matched = variant.value; break; } @@ -1492,7 +1581,7 @@ export async function resolveSectionsList( const variants = obj.variants as Array<{ value: unknown; rule?: unknown }>; for (const variant of variants) { const rule = variant.rule as Record | undefined; - if (evaluateMatcher(rule, rctx.matcherCtx)) { + if (evaluateVariantRule(rule, rctx.matcherCtx)) { return resolveSectionsList(variant.value, rctx, depth + 1); } } @@ -1507,7 +1596,7 @@ export async function resolveSectionsList( if (!variants?.length) return []; for (const variant of variants) { const rule = variant.rule as Record | undefined; - if (evaluateMatcher(rule, rctx.matcherCtx)) { + if (evaluateVariantRule(rule, rctx.matcherCtx)) { return resolveSectionsList(variant.value, rctx, depth + 1); } } @@ -1634,10 +1723,14 @@ export async function resolveDecoPage( async () => { const result = await resolveDecoPageImpl(targetPath, matcherCtx); try { + // No path label: resolve duration is a per-service latency signal, and + // the request path is unbounded cardinality (one histogram series per + // URL — product pages, search, facets) that made this the single + // biggest series in ClickHouse. Per-route latency lives on the span + // below (raw deco.route), which is sampled — the right place for it. getMeter()?.histogramRecord?.( MetricNames.RESOLVE_DURATION, - performance.now() - startedAt, - { path: targetPath }, + (performance.now() - startedAt) / 1000, ); } catch { /* observability never fails the request */ diff --git a/packages/blocks/src/cms/stickyFlags.test.ts b/packages/blocks/src/cms/stickyFlags.test.ts new file mode 100644 index 0000000..a834a35 --- /dev/null +++ b/packages/blocks/src/cms/stickyFlags.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./sectionLoaders", () => ({ + isLayoutSection: () => false, + runSingleSectionLoader: vi.fn(async (section: any) => section), +})); + +vi.mock("../sdk/normalizeUrls", () => ({ + normalizeUrlsInObject: vi.fn((x: T) => x), +})); + +// A page whose A/B "TestHero" matcher block resolves to a 50/50 random matcher. +vi.mock("./loader", () => ({ + findPageByPath: vi.fn(), + loadBlocks: vi.fn(() => ({ + TestHero: { __resolveType: "website/matchers/random.ts", traffic: 0.5 }, + })), +})); + +vi.mock("./registry", () => ({ + getSection: vi.fn(), +})); + +import { DECO_MATCHERS_OVERRIDE_PARAM } from "../matchers/override"; +import { type StoredFlag, serializeSegmentCookie } from "../sdk/flags"; +import type { MatcherContext } from "./resolve"; +import { resolveValue } from "./resolve"; + +const FLAG = { + __resolveType: "website/flags/multivariate.ts", + variants: [ + { rule: { __resolveType: "TestHero" }, value: "VARIANT_1" }, + { rule: { __resolveType: "website/matchers/always.ts" }, value: "VARIANT_2" }, + ], +}; + +function ctx(decision?: { value: boolean; pct: number }): MatcherContext & { flags: StoredFlag[] } { + const cookie = decision + ? serializeSegmentCookie([{ name: "TestHero", value: decision.value, pct: decision.pct }]) + : undefined; + return { + cookies: cookie ? { deco_segment: cookie } : {}, + flags: [], + }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("sticky A/B multivariate resolution", () => { + it("honors a matching cookie without rolling (test cohort)", async () => { + const rand = vi.spyOn(Math, "random"); + const c = ctx({ value: true, pct: 50 }); + + const result = await resolveValue(FLAG, undefined, c); + + expect(result).toBe("VARIANT_1"); + expect(rand).not.toHaveBeenCalled(); + expect(c.flags).toEqual([{ name: "TestHero", value: true, pct: 50 }]); + }); + + it("honors a matching cookie without rolling (control cohort)", async () => { + const rand = vi.spyOn(Math, "random"); + const c = ctx({ value: false, pct: 50 }); + + const result = await resolveValue(FLAG, undefined, c); + + expect(result).toBe("VARIANT_2"); + expect(rand).not.toHaveBeenCalled(); + expect(c.flags).toEqual([{ name: "TestHero", value: false, pct: 50 }]); + }); + + it("rolls and records when there is no cookie", async () => { + vi.spyOn(Math, "random").mockReturnValue(0.9); // >= 0.5 -> control + const c = ctx(); + + const result = await resolveValue(FLAG, undefined, c); + + expect(result).toBe("VARIANT_2"); + expect(c.flags).toEqual([{ name: "TestHero", value: false, pct: 50 }]); + }); + + it("re-rolls when the cookie's traffic fingerprint is stale", async () => { + vi.spyOn(Math, "random").mockReturnValue(0.1); // < 0.5 -> test + // Cookie says the user was in control at 70% traffic; current traffic is 50%. + const c = ctx({ value: false, pct: 70 }); + + const result = await resolveValue(FLAG, undefined, c); + + expect(result).toBe("VARIANT_1"); + expect(c.flags).toEqual([{ name: "TestHero", value: true, pct: 50 }]); + }); + + it("does not record flags when recording is not opted into", async () => { + vi.spyOn(Math, "random").mockReturnValue(0.1); + // No `flags` array on the context -> nothing to record, but still resolves. + const result = await resolveValue(FLAG, undefined, { cookies: {} }); + expect(result).toBe("VARIANT_1"); + }); + + it("honors the admin matchers-override instead of the cookie/roll", async () => { + const rand = vi.spyOn(Math, "random"); + // Admin forces TestHero=0 for preview, but the cookie says test cohort. + const c: MatcherContext & { flags: StoredFlag[] } = { + cookies: { + deco_segment: serializeSegmentCookie([{ name: "TestHero", value: true, pct: 50 }]), + }, + headers: { [DECO_MATCHERS_OVERRIDE_PARAM]: "TestHero=0" }, + flags: [], + }; + + const result = await resolveValue(FLAG, undefined, c); + + expect(result).toBe("VARIANT_2"); // override wins over the cookie + expect(rand).not.toHaveBeenCalled(); + expect(c.flags).toEqual([]); // previews don't persist a cohort + }); +}); diff --git a/packages/blocks/src/sdk/flags.test.ts b/packages/blocks/src/sdk/flags.test.ts new file mode 100644 index 0000000..eee9781 --- /dev/null +++ b/packages/blocks/src/sdk/flags.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + parseSegmentCookie, + type StoredFlag, + segmentCacheToken, + serializeSegmentCookie, + trafficToPct, +} from "./flags"; + +describe("trafficToPct", () => { + it("maps a ratio to a 0-100 integer", () => { + expect(trafficToPct(0.5)).toBe(50); + expect(trafficToPct(0.234)).toBe(23); + }); + + it("clamps out-of-range and non-finite values", () => { + expect(trafficToPct(0)).toBe(0); + expect(trafficToPct(-1)).toBe(0); + expect(trafficToPct(1)).toBe(100); + expect(trafficToPct(2)).toBe(100); + expect(trafficToPct(NaN)).toBe(0); + }); +}); + +describe("deco_segment round-trip", () => { + it("round-trips a single active flag", () => { + const flags: StoredFlag[] = [{ name: "TestHero", value: true, pct: 50 }]; + expect(parseSegmentCookie(serializeSegmentCookie(flags))).toEqual(flags); + }); + + it("round-trips active + inactive flags (sorted by name)", () => { + const flags: StoredFlag[] = [ + { name: "Zeta", value: false, pct: 20 }, + { name: "Alpha", value: true, pct: 70 }, + ]; + expect(parseSegmentCookie(serializeSegmentCookie(flags))).toEqual([ + { name: "Alpha", value: true, pct: 70 }, + { name: "Zeta", value: false, pct: 20 }, + ]); + }); + + it("produces the classic deco_segment shape that OneDollarStats reads", () => { + const raw = serializeSegmentCookie([ + { name: "TestHero", value: true, pct: 50 }, + { name: "Promo", value: false, pct: 30 }, + ]); + // Mirrors @decocms/apps OneDollarStats readFlagsFromCookie decode. + const seg = JSON.parse(decodeURIComponent(atob(raw))); + expect(seg.active).toContain("TestHero"); + expect(seg.inactiveDrawn).toContain("Promo"); + expect(seg.pct).toEqual({ TestHero: 50, Promo: 30 }); + }); + + it("is raw base64 (no percent-escapes) so atob() works directly", () => { + const raw = serializeSegmentCookie([{ name: "TestHero", value: true, pct: 50 }]); + expect(raw).not.toMatch(/%/); + expect(() => atob(raw)).not.toThrow(); + }); +}); + +describe("parseSegmentCookie robustness", () => { + it("returns [] for empty / nullish / malformed input", () => { + expect(parseSegmentCookie(undefined)).toEqual([]); + expect(parseSegmentCookie(null)).toEqual([]); + expect(parseSegmentCookie("")).toEqual([]); + expect(parseSegmentCookie("not-base64-!!!")).toEqual([]); + }); + + it("marks classic segments without a pct fingerprint as pct: -1", () => { + const raw = btoa(encodeURIComponent(JSON.stringify({ active: ["Legacy"], inactiveDrawn: [] }))); + expect(parseSegmentCookie(raw)).toEqual([{ name: "Legacy", value: true, pct: -1 }]); + }); +}); + +describe("segmentCacheToken", () => { + it("is empty for no flags so non-A/B pages share a cache entry", () => { + expect(segmentCacheToken([])).toBe(""); + }); + + it("includes the pct fingerprint so a traffic change re-buckets", () => { + const at50 = segmentCacheToken([{ name: "TestHero", value: true, pct: 50 }]); + const at70 = segmentCacheToken([{ name: "TestHero", value: true, pct: 70 }]); + expect(at50).not.toBe(at70); + }); + + it("differs by decision so cohorts split", () => { + const on = segmentCacheToken([{ name: "TestHero", value: true, pct: 50 }]); + const off = segmentCacheToken([{ name: "TestHero", value: false, pct: 50 }]); + expect(on).not.toBe(off); + }); +}); diff --git a/packages/blocks/src/sdk/flags.ts b/packages/blocks/src/sdk/flags.ts new file mode 100644 index 0000000..155a23d --- /dev/null +++ b/packages/blocks/src/sdk/flags.ts @@ -0,0 +1,108 @@ +/** + * Sticky A/B flag persistence via the `deco_segment` cookie — shared by the + * resolver (SSR variant selection), the worker entry (cache-key cohort + * splitting), and consumed by analytics (@decocms/apps OneDollarStats reads + * the same cookie to enrich pageviews/events). + * + * ## Why + * + * `website/matchers/random.ts` is a stateless `Math.random() < traffic`, so a + * visitor's variant used to be re-rolled on every request AND frozen per + * edge-cache entry (the HTML is cached and the cache key never carried the + * variant). The variant a first visitor happened to roll then got served to + * everyone in that device/geo bucket, and analytics never saw the flag. + * + * ## Cookie format + * + * `deco_segment` is the classic deco segment cookie: + * `btoa(encodeURIComponent(JSON.stringify({ active, inactiveDrawn, pct })))`. + * - `active` — flag names the visitor was assigned to (`true` branch). + * - `inactiveDrawn` — flag names drawn but not matched (`false` branch). + * - `pct` — `{ name: round(traffic*100) }`, a deco-start extension (analytics + * ignores unknown fields) used as the re-roll fingerprint: when the operator + * changes `traffic` and redeploys, the fingerprint no longer matches and the + * visitor is re-rolled once, then re-sticks — same self-healing scheme as + * {@link ./abTesting.ts}. + * + * The cookie MUST be written un-encoded (raw base64) — OneDollarStats reads it + * with `atob()` directly. Base64's `+ / =` are all valid cookie-value octets + * (RFC 6265), so pass `encode: (v) => v` to the cookie setter. + */ + +/** Cookie carrying the visitor's sticky flag decisions (classic deco segment). */ +export const SEGMENT_COOKIE = "deco_segment"; + +/** A recorded flag decision: which named flag, the branch taken, and the + * `traffic` fingerprint (0–100) it was decided under. */ +export interface StoredFlag { + /** Matcher block name, e.g. "TestHero". Stable identity for the cohort. */ + name: string; + /** The branch the visitor was assigned to. */ + value: boolean; + /** `round(traffic * 100)` at assignment time — the re-roll fingerprint. */ + pct: number; +} + +/** Wire shape of the `deco_segment` cookie payload. */ +interface DecoSegment { + active?: string[]; + inactiveDrawn?: string[]; + /** deco-start extension: per-flag traffic fingerprint. Ignored by analytics. */ + pct?: Record; +} + +/** Convert a 0–1 traffic ratio to the 0–100 integer fingerprint. */ +export function trafficToPct(traffic: number): number { + if (!Number.isFinite(traffic) || traffic <= 0) return 0; + if (traffic >= 1) return 100; + return Math.round(traffic * 100); +} + +/** + * Parse the `deco_segment` cookie value into decisions. The value is raw + * base64 (no URL-decoding needed). Foreign cookies without the `pct` extension + * parse with `pct: -1`, which the resolver treats as "always matches" so a + * classic-deco segment stays sticky instead of re-rolling. Malformed cookies + * yield `[]`. + */ +export function parseSegmentCookie(raw: string | undefined | null): StoredFlag[] { + if (!raw) return []; + try { + const seg = JSON.parse(decodeURIComponent(atob(raw))) as DecoSegment; + const pct = seg.pct ?? {}; + const out: StoredFlag[] = []; + for (const name of seg.active ?? []) out.push({ name, value: true, pct: pct[name] ?? -1 }); + for (const name of seg.inactiveDrawn ?? []) { + out.push({ name, value: false, pct: pct[name] ?? -1 }); + } + return out; + } catch { + return []; + } +} + +/** Serialize decisions into a raw-base64 `deco_segment` cookie value. */ +export function serializeSegmentCookie(flags: StoredFlag[]): string { + const active: string[] = []; + const inactiveDrawn: string[] = []; + const pct: Record = {}; + for (const f of [...flags].sort((a, b) => a.name.localeCompare(b.name))) { + (f.value ? active : inactiveDrawn).push(f.name); + pct[f.name] = f.pct; + } + const seg: DecoSegment = { active, inactiveDrawn, pct }; + return btoa(encodeURIComponent(JSON.stringify(seg))); +} + +/** + * Stable token for the cache key — includes the `pct` fingerprint so a + * `traffic` change lands cohorts in fresh buckets. Empty string when there are + * no flags, so non-A/B pages keep sharing one entry. + */ +export function segmentCacheToken(flags: StoredFlag[]): string { + if (!flags.length) return ""; + return [...flags] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((f) => `${f.name}:${f.value ? "1" : "0"}:${f.pct}`) + .join(","); +} diff --git a/packages/tanstack/src/routes/cmsRoute.ts b/packages/tanstack/src/routes/cmsRoute.ts index 6c2f358..7a8c426 100644 --- a/packages/tanstack/src/routes/cmsRoute.ts +++ b/packages/tanstack/src/routes/cmsRoute.ts @@ -27,6 +27,7 @@ import { getRequest, getRequestHeader, getRequestUrl, + setCookie, setResponseHeader, } from "@tanstack/react-start/server"; import { createElement } from "react"; @@ -44,6 +45,12 @@ import { runSingleSectionLoader, } from "@decocms/blocks/cms"; import type { DeferredSection, MatcherContext, PageSeo, ResolvedSection } from "@decocms/blocks/cms"; +import { + parseSegmentCookie, + SEGMENT_COOKIE, + serializeSegmentCookie, + type StoredFlag, +} from "@decocms/blocks/sdk/flags"; import { withTracing } from "@decocms/blocks/middleware/observability"; import { type CacheProfileName, @@ -78,6 +85,45 @@ export function setSectionChunkMap(map: Record): void { type PageResult = Awaited>; const pageInflight = new Map>(); +/** One year — the cohort is sticky; the `pct` fingerprint handles re-rolls. */ +const SEGMENT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +/** + * Persist sticky A/B decisions recorded during resolution and return them for + * the client (analytics). + * + * Merges the freshly recorded flags over any already in the `deco_segment` + * cookie (so navigating to a second A/B page doesn't drop the first page's + * cohort), and only re-issues the cookie when the merged value changed. + * Setting the cookie also makes the worker skip caching THIS response + * (`deco_segment` is not a "safe" cookie), so an unassigned visitor never + * poisons the shared entry — their next request carries the cookie and hits + * the per-cohort cache entry. + * + * `encode: (v) => v` keeps the raw base64 intact — @decocms/apps + * OneDollarStats reads the cookie with `atob()` directly. + */ +function persistFlags(matcherCtx: MatcherContext): StoredFlag[] { + const recorded = matcherCtx.flags ?? []; + if (!recorded.length) return []; + + const raw = matcherCtx.cookies?.[SEGMENT_COOKIE]; + const merged = new Map(); + for (const f of parseSegmentCookie(raw)) merged.set(f.name, f); + for (const f of recorded) merged.set(f.name, f); + + const serialized = serializeSegmentCookie([...merged.values()]); + if (serialized !== (raw ?? "")) { + setCookie(SEGMENT_COOKIE, serialized, { + path: "/", + maxAge: SEGMENT_COOKIE_MAX_AGE, + sameSite: "lax", + encode: (v) => v, + }); + } + return recorded; +} + async function loadCmsPageInternal(fullPath: string) { const [basePath] = fullPath.split("?"); // On client-side navigation getRequestUrl() is the /_serverFn/... endpoint, @@ -94,9 +140,11 @@ async function loadCmsPageInternal(fullPath: string) { cookies: getCookies(), request: originRequest, isClientNavigation: isClientNavigation(fullPath, serverUrl), + flags: [], }; const page = await resolveDecoPage(basePath, matcherCtx); if (!page) return null; + const flags = persistFlags(matcherCtx); const request = new Request(urlWithSearch, { headers: originRequest.headers, @@ -144,6 +192,7 @@ async function loadCmsPageInternal(fullPath: string) { pagePath: basePath, seo, device, + flags, siteGlobals: { rawRefs: globals.rawRefs }, }; } @@ -179,9 +228,11 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async ( cookies: getCookies(), request, isClientNavigation: isClientNavigation("/", serverUrl), + flags: [], }; const page = await resolveDecoPage("/", matcherCtx); if (!page) return null; + const flags = persistFlags(matcherCtx); const [enrichedSections, globals] = await Promise.all([ runSectionLoaders(page.resolvedSections, request), resolveSiteGlobals(), @@ -208,6 +259,7 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async ( pageUrl: serverUrl.toString(), seo, device, + flags, siteGlobals: { rawRefs: globals.rawRefs }, }; }); diff --git a/packages/tanstack/src/sdk/outboundHeaders.test.ts b/packages/tanstack/src/sdk/outboundHeaders.test.ts new file mode 100644 index 0000000..2653e2f --- /dev/null +++ b/packages/tanstack/src/sdk/outboundHeaders.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + _uninstallDefaultUserAgentForTests, + DECO_POWERED_BY, + DECO_USER_AGENT, + installDefaultUserAgent, +} from "./outboundHeaders"; + +/** Headers the wrapped baseFetch would put on the wire for a given call. */ +function wireHeaders(call: readonly [RequestInfo | URL, RequestInit?]): Headers { + const [input, init] = call; + if (init?.headers !== undefined) return new Headers(init.headers); + if (input instanceof Request) return new Headers(input.headers); + return new Headers(); +} + +describe("installDefaultUserAgent", () => { + const realFetch = globalThis.fetch; + + afterEach(() => { + _uninstallDefaultUserAgentForTests(); + globalThis.fetch = realFetch; + vi.restoreAllMocks(); + }); + + function install(userAgent?: string) { + const baseFetch = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => new Response("ok"), + ); + globalThis.fetch = baseFetch as unknown as typeof fetch; + installDefaultUserAgent(userAgent); + return baseFetch; + } + + it("sets the default UA on a bare string fetch", async () => { + const baseFetch = install(); + await fetch("https://api.example.com/products"); + + const headers = wireHeaders(baseFetch.mock.calls[0]); + expect(headers.get("user-agent")).toBe(DECO_USER_AGENT); + }); + + it("keeps the caller's UA from init.headers", async () => { + const baseFetch = install(); + await fetch("https://api.example.com/", { + headers: { "user-agent": "deco-aws-app/1.0" }, + }); + + // Untouched call: init passed through verbatim. + expect(baseFetch.mock.calls[0][1]).toEqual({ + headers: { "user-agent": "deco-aws-app/1.0" }, + }); + }); + + it("keeps the UA of a Request input", async () => { + const baseFetch = install(); + const req = new Request("https://api.example.com/", { + headers: { "User-Agent": "decocx/1.0" }, + }); + await fetch(req); + + expect(baseFetch.mock.calls[0][0]).toBe(req); + expect(baseFetch.mock.calls[0][1]).toBeUndefined(); + }); + + it("preserves a Request's other headers when injecting the UA", async () => { + const baseFetch = install(); + await fetch( + new Request("https://api.example.com/", { + headers: { accept: "application/json", "x-api-key": "k" }, + }), + ); + + const headers = wireHeaders(baseFetch.mock.calls[0]); + expect(headers.get("user-agent")).toBe(DECO_USER_AGENT); + expect(headers.get("accept")).toBe("application/json"); + expect(headers.get("x-api-key")).toBe("k"); + }); + + it("preserves non-header init members (method, body, signal)", async () => { + const baseFetch = install(); + const controller = new AbortController(); + await fetch("https://api.example.com/", { + method: "POST", + body: "{}", + signal: controller.signal, + }); + + const init = baseFetch.mock.calls[0][1]!; + expect(init.method).toBe("POST"); + expect(init.body).toBe("{}"); + expect(init.signal).toBe(controller.signal); + expect(new Headers(init.headers).get("user-agent")).toBe(DECO_USER_AGENT); + }); + + it("honors a custom UA value", async () => { + const baseFetch = install("MyStore-Migration/1.0"); + await fetch("https://api.example.com/"); + + expect(wireHeaders(baseFetch.mock.calls[0]).get("user-agent")).toBe("MyStore-Migration/1.0"); + }); + + it("is idempotent — a second install does not re-wrap", () => { + install(); + const wrapped = globalThis.fetch; + installDefaultUserAgent(); + expect(globalThis.fetch).toBe(wrapped); + }); + + it("exposes the old-runtime-parity x-powered-by value", () => { + expect(DECO_POWERED_BY).toMatch(/^deco@\d+\.\d+\.\d+/); + }); +}); diff --git a/packages/tanstack/src/sdk/outboundHeaders.ts b/packages/tanstack/src/sdk/outboundHeaders.ts new file mode 100644 index 0000000..6e041c6 --- /dev/null +++ b/packages/tanstack/src/sdk/outboundHeaders.ts @@ -0,0 +1,81 @@ +/** + * Runtime identification headers. + * + * Outbound: Cloudflare Workers' `fetch` sends NO `User-Agent` header at + * all — unlike the old Deno runtime, where every outbound request carried + * Deno's implicit `User-Agent: Deno/x.y.z`. UA-less requests are a strong + * bot signal: Cloudflare managed WAF rules and Bot Fight Mode on partner + * origins block them outright. `installDefaultUserAgent()` restores the + * pre-migration behavior with an honest, allowlistable value, applied only + * when the caller didn't set a User-Agent of its own. + * + * Responses: the old runtime stamped `x-powered-by: deco@` on + * every response (deco-cx/deco `utils/http.ts` defaultHeaders). The worker + * entry reuses `DECO_POWERED_BY` for parity. + */ + +import pkg from "../../package.json"; + +/** + * Default outbound User-Agent. Stable prefix (`Deco/`) so partners can + * write one WAF allowlist rule that survives version bumps and platform + * changes; the URL comment tells whoever reads an access log who we are. + */ +export const DECO_USER_AGENT = `Deco/${pkg.version} (+https://deco.cx)`; + +/** Response identification header value — parity with deco-cx/deco. */ +export const DECO_POWERED_BY = `deco@${pkg.version}`; + +/** + * Cross-module guard on `globalThis` (same trick as logger.ts STATE_KEY): + * bundlers may duplicate this module across chunks, so a module-local + * boolean would not prevent double-wrapping. + */ +const INSTALLED_KEY = Symbol.for("deco.outboundHeaders.installed"); + +let restoreBaseFetch: (() => void) | undefined; + +/** + * Patch `globalThis.fetch` so every outbound request carries a User-Agent. + * + * Set-if-absent only: app-specific UAs (`deco-aws-app/1.0`, `decocx/1.0`, + * ...) always win, matching the old runtime where Deno's default applied + * only when unset. All other RequestInit members (method, body, signal, + * `cf`, ...) pass through untouched. Idempotent — safe to call more than + * once (e.g. worker entry + tests). + */ +export function installDefaultUserAgent(userAgent: string = DECO_USER_AGENT): void { + const g = globalThis as Record; + if (g[INSTALLED_KEY]) return; + g[INSTALLED_KEY] = true; + + const baseFetch = globalThis.fetch; + restoreBaseFetch = () => { + globalThis.fetch = baseFetch; + delete g[INSTALLED_KEY]; + }; + + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit): Promise => { + // Fetch-spec semantics (same rule instrumentedFetch.ts documents): + // when both a Request and `init.headers` are passed, `init.headers` + // REPLACES the Request's headers — they do not union. So the headers + // that will actually hit the wire are init.headers if present, else + // the Request's own headers, else none. + const base = + init?.headers !== undefined + ? init.headers + : input instanceof Request + ? input.headers + : undefined; + const headers = new Headers(base ?? undefined); + if (headers.has("user-agent")) return baseFetch(input, init); + headers.set("user-agent", userAgent); + return baseFetch(input, { ...init, headers }); + }) as typeof fetch; +} + +/** Test-only: restore the unpatched fetch. Do not call from app code. */ +export function _uninstallDefaultUserAgentForTests(): void { + restoreBaseFetch?.(); + restoreBaseFetch = undefined; +} diff --git a/packages/tanstack/src/sdk/workerEntry.ts b/packages/tanstack/src/sdk/workerEntry.ts index cc9b258..0f66fda 100644 --- a/packages/tanstack/src/sdk/workerEntry.ts +++ b/packages/tanstack/src/sdk/workerEntry.ts @@ -45,6 +45,7 @@ import { getCacheProfile, serverFnPagePath, } from "@decocms/blocks/sdk/cacheHeaders"; +import { parseSegmentCookie, SEGMENT_COOKIE, segmentCacheToken } from "@decocms/blocks/sdk/flags"; import { buildHtmlShell } from "@decocms/blocks-admin/sdk/htmlShell"; import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration"; import { @@ -61,6 +62,7 @@ import { instrumentWorker, type OtelOptions, } from "@decocms/blocks/sdk/otel"; +import { DECO_POWERED_BY, installDefaultUserAgent } from "./outboundHeaders"; import { setRuntimeEnv } from "@decocms/blocks/sdk/otelAdapters"; import { parseTraceparent } from "@decocms/blocks/sdk/otelHttpTracer"; import { RequestContext } from "@decocms/blocks/sdk/requestContext"; @@ -434,6 +436,25 @@ export interface DecoWorkerEntryOptions { * no-op — no buffers, no flushes, no network calls. */ observability?: OtelOptions | false; + + /** + * Default `User-Agent` for OUTBOUND `fetch` calls (loaders, commerce + * APIs, proxies). Cloudflare Workers sends no User-Agent at all, and + * UA-less requests are blocked by common WAF setups (Cloudflare managed + * rules / Bot Fight Mode) on partner origins. The old Deno runtime + * implicitly sent `Deno/x.y.z` on every outbound request, so this + * restores pre-migration behavior with an identifiable value. + * + * Applied only when the caller didn't set a User-Agent — app-specific + * UAs always win. + * + * - Pass a string to override the value (e.g. to match a partner's + * existing WAF allowlist during migration). + * - Pass `false` to leave `globalThis.fetch` untouched. + * + * @default `Deco/ (+https://deco.cx)` — see DECO_USER_AGENT. + */ + outboundUserAgent?: string | false; } // --------------------------------------------------------------------------- @@ -707,8 +728,18 @@ export function createDecoWorkerEntry( staticPaths: staticPathsOpt = DEFAULT_STATIC_PATHS, cdnCacheControl: cdnCacheControlOpt = "no-store", observability: observabilityOpt, + outboundUserAgent: outboundUserAgentOpt, } = options; + // Patch global fetch once so every outbound request — apps calling + // `fetch` directly, RequestContext.fetch, instrumentedFetch — carries a + // User-Agent. Workers sends none by default, which WAFs on partner + // origins read as bot traffic. Set-if-absent: callers that set their + // own UA are untouched. + if (outboundUserAgentOpt !== false) { + installDefaultUserAgent(outboundUserAgentOpt); + } + // Backfill `regionId` from Cloudflare geo when the consumer's buildSegment // doesn't set one. Without this, sites using website/matchers/location.ts // get a single cached response per device that leaks across regions: the @@ -830,6 +861,25 @@ export function createDecoWorkerEntry( return typeof __DECO_BUILD_HASH__ !== "undefined" ? __DECO_BUILD_HASH__ : ""; } + function readRequestCookie(request: Request, name: string): string | undefined { + for (const pair of (request.headers.get("cookie") ?? "").split(";")) { + const idx = pair.indexOf("="); + if (idx <= 0) continue; + if (pair.slice(0, idx).trim() !== name) continue; + const raw = pair.slice(idx + 1).trim(); + // Decode once so this matches TanStack's getCookies() used during SSR + // (see cms/resolve.ts) — otherwise the worker's cache key and the render + // could disagree on the cohort. The deco_segment value is raw base64 (no + // percent-escapes), so decoding is a no-op for it and safe in general. + try { + return decodeURIComponent(raw); + } catch { + return raw; + } + } + return undefined; + } + function buildCacheKey( request: Request, env: Record, @@ -904,6 +954,18 @@ export function createDecoWorkerEntry( url.searchParams.set("__fetch", "1"); } + // A/B cohort split: fold the sticky `deco_segment` cookie into the cache + // key so each variant keeps its own cached HTML. The cookie carries the + // traffic fingerprint (see sdk/flags.ts), so a `traffic` change lands + // cohorts in fresh buckets. Empty for non-A/B visitors (no cookie) → they + // keep sharing one entry. A freshly-assigned visitor sets the cookie on the + // response, which is not a "safe" cookie, so that one response bypasses the + // cache and never poisons the shared entry. + const flagToken = segmentCacheToken(parseSegmentCookie(readRequestCookie(request, SEGMENT_COOKIE))); + if (flagToken) { + url.searchParams.set("__abf", flagToken); + } + if (buildSegment) { const segment = buildSegment(request); url.searchParams.set("__seg", hashSegment(segment)); @@ -1311,6 +1373,18 @@ export function createDecoWorkerEntry( } } + // Old-runtime parity: deco-cx/deco stamped `x-powered-by: deco@` + // on every response (utils/http.ts defaultHeaders); partner-side logs + // and ops tooling use it to identify deco traffic. Set-if-absent so + // an upstream origin's own value wins. + if (!finalResponse.headers.has("x-powered-by")) { + try { + finalResponse.headers.set("x-powered-by", DECO_POWERED_BY); + } catch { + /* sealed headers (cached replay) — identification is best-effort */ + } + } + // Metrics + structured request log. Done after security headers so // the recorded status reflects what the client actually receives. // Both calls are no-ops when no meter / logger is configured. @@ -1318,8 +1392,9 @@ export function createDecoWorkerEntry( try { // Phase 2 / D-11 canonical labels — lift the cache decision + // profile + region off the response we just built so dashboards - // can answer "cache hit rate per route" from `http_requests_total` - // alone, no join to `cache_*_total` required. + // can answer "cache hit rate per route" from + // `http.server.request.duration` alone, no join to a cache counter + // required. const xCacheRaw = finalResponse.headers.get("X-Cache"); const cacheDecision = isCacheDecision(xCacheRaw) ? xCacheRaw : undefined; const colo = (request as unknown as { cf?: { colo?: string } }).cf?.colo; diff --git a/packages/tanstack/tsconfig.json b/packages/tanstack/tsconfig.json index 42386d3..7e8bd25 100644 --- a/packages/tanstack/tsconfig.json +++ b/packages/tanstack/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "outDir": "dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "package.json"] } From 1f3177d5d3768c1b7a7cb628a52dea36d1660415 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 16:32:14 -0300 Subject: [PATCH 55/85] fix(nextjs): admin route handlers crash with 'createContext is not a function' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next.js App Router route handlers evaluate their module graph against React's react-server build (Next's vendored rsc/react.js) and IGNORE "use client" directives — there is no client graph to move a module into. That build exports forwardRef/useMemo/createElement but NOT createContext, so any module-scope createContext call reachable from a route.ts crashes the whole route at import time: TypeError: {imported module .../vendored/rsc/react.js}.createContext is not a function All six admin handlers (metaGET, decofileGET/POST, invokePOST, renderGET/POST) crashed this way, each bisected individually against a real Next 16.2.6 + Turbopack consumer (faststore-fila's /.decofile). Two independent import paths reached a module-scope createContext: 1. blocks-admin -> @decocms/blocks/cms barrel -> sectionMixins.ts (detectDevice) and sdk/requestContext.ts (isMobileUA) -> useDevice.ts, whose back-compat re-export drags in useDeviceContext.tsx ("use client" + top-level createContext). Fix: split the pure UA parsing (Device, MOBILE_RE, TABLET_RE, isMobileUA, detectDevice) into a new react-free leaf sdk/detectDevice.ts; sectionMixins and requestContext import the leaf; useDevice.ts re-exports it so its public API is unchanged. New ./sdk/detectDevice subpath export. 2. @decocms/nextjs root barrel also exports the render components, whose graph reaches @decocms/blocks/hooks -> Picture.tsx — a top-level createContext with no "use client" at all (a latent bug for ANY react-server graph, Server Component pages included; it only survived because every current consumer happens to import the hooks barrel behind an existing client boundary). Fix: create the preload context lazily on first render — renders only ever happen under a full React build. Even with both fixed, mixing render components and route handlers in one barrel leaves a landmine (e.g. SectionErrorFallback's "use client" + class extends Component still crashes route graphs, since directives are ignored there and react-server has no Component). So route files now get a dedicated component-free subpath: export { decofileGET as GET } from "@decocms/nextjs/routeHandlers" Verified against faststore-fila under real npm-resolved node_modules (files copied over the installed 7.3.0 packages, no symlinks): /.decofile 200 with the full 2.5MB decofile JSON, storefront routes unaffected, zero createContext errors. Not a 7.3.0 regression — the crash reproduces identically on 7.2.3; this is latent since the package split. Co-Authored-By: Claude Fable 5 --- packages/blocks/package.json | 1 + packages/blocks/src/cms/sectionMixins.ts | 2 +- packages/blocks/src/hooks/Picture.tsx | 20 +++++++++- packages/blocks/src/sdk/detectDevice.ts | 45 +++++++++++++++++++++++ packages/blocks/src/sdk/requestContext.ts | 2 +- packages/blocks/src/sdk/useDevice.ts | 44 ++++++---------------- packages/nextjs/package.json | 3 +- packages/nextjs/src/routeHandlers.ts | 19 +++++++++- 8 files changed, 98 insertions(+), 38 deletions(-) create mode 100644 packages/blocks/src/sdk/detectDevice.ts diff --git a/packages/blocks/package.json b/packages/blocks/package.json index fe918a3..1a2c2c0 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -46,6 +46,7 @@ "./sdk/cookie": "./src/sdk/cookie.ts", "./sdk/crypto": "./src/sdk/crypto.ts", "./sdk/csp": "./src/sdk/csp.ts", + "./sdk/detectDevice": "./src/sdk/detectDevice.ts", "./sdk/djb2": "./src/sdk/djb2.ts", "./sdk/encoding": "./src/sdk/encoding.ts", "./sdk/env": "./src/sdk/env.ts", diff --git a/packages/blocks/src/cms/sectionMixins.ts b/packages/blocks/src/cms/sectionMixins.ts index f2b3dec..391add5 100644 --- a/packages/blocks/src/cms/sectionMixins.ts +++ b/packages/blocks/src/cms/sectionMixins.ts @@ -16,7 +16,7 @@ * }); * ``` */ -import { detectDevice } from "../sdk/useDevice"; +import { detectDevice } from "../sdk/detectDevice"; import type { SectionLoaderFn } from "./sectionLoaders"; /** diff --git a/packages/blocks/src/hooks/Picture.tsx b/packages/blocks/src/hooks/Picture.tsx index e47e83d..fb71155 100644 --- a/packages/blocks/src/hooks/Picture.tsx +++ b/packages/blocks/src/hooks/Picture.tsx @@ -1,5 +1,6 @@ import { type ComponentPropsWithoutRef, + type Context, createContext, forwardRef, type ReactNode, @@ -12,13 +13,27 @@ import { type FitOptions, getOptimizedMediaUrl, getSrcSet } from "./Image"; // Preload context — flows from to child elements // so each source can inject its own with the correct // media query for responsive art direction. +// +// Created LAZILY (first render), not at module scope: this file has no +// "use client" directive, so in a react-server graph (a Server Component +// page, or ANY Next.js route handler — which ignores "use client" +// entirely) the module evaluates against React's react-server build, where +// `createContext` does not exist. A module-scope call crashes the whole +// graph at import time ("createContext is not a function") even if +// Picture/Source never render. Deferring to render time is safe: renders +// only ever happen under a full React build (client or SSR), and both +// components resolve the same memoized context object. // ------------------------------------------------------------------------- interface PreloadContextValue { preload: boolean; } -const PreloadContext = createContext({ preload: false }); +let _preloadContext: Context | null = null; +function getPreloadContext(): Context { + _preloadContext ??= createContext({ preload: false }); + return _preloadContext; +} // ------------------------------------------------------------------------- // Source — composable with automatic srcSet optimization and @@ -41,7 +56,7 @@ export const Source = forwardRef(function Source { src, width, height, fetchPriority, fit = "cover", ...rest }, ref, ) { - const { preload } = useContext(PreloadContext); + const { preload } = useContext(getPreloadContext()); const optimizedSrc = getOptimizedMediaUrl({ originalSrc: src, @@ -93,6 +108,7 @@ export const Picture = forwardRef(function Pic ref, ) { const value = useMemo(() => ({ preload }), [preload]); + const PreloadContext = getPreloadContext(); return ( diff --git a/packages/blocks/src/sdk/detectDevice.ts b/packages/blocks/src/sdk/detectDevice.ts new file mode 100644 index 0000000..7d80aea --- /dev/null +++ b/packages/blocks/src/sdk/detectDevice.ts @@ -0,0 +1,45 @@ +/** + * Pure User-Agent device detection — no React, no RequestContext, no + * side effects. Import from HERE (not `./useDevice`) in any module that can + * end up in a Next.js App Router **route handler** graph. + * + * Why this file exists: `useDevice.ts` re-exports the `"use client"` + * context half (`./useDeviceContext`, which calls `createContext` at module + * scope) for backward compatibility. That re-export is harmless in Server + * Components — Next honors the `"use client"` boundary there — but route + * handlers (App Router route.ts files) have NO client graph: the directive + * is ignored, the module executes against Next's vendored **RSC** React + * (`vendored/rsc/react.js`), and that build has no `createContext`, so the + * whole route module crashes at evaluation time + * ("...createContext is not a function"). `@decocms/nextjs`'s admin route + * handlers reach device detection via `cms/sectionMixins.ts` + * (`detectDevice`) and `sdk/requestContext.ts` (`isMobileUA`) — both now + * import this leaf so their graphs never touch the client context file. + */ + +export type Device = "mobile" | "tablet" | "desktop"; + +// Android phones include "Mobile" in their UA; Android tablets do not. +// Check TABLET_RE first so `android(?!.*mobile)` captures tablets before +// the MOBILE_RE `android.*mobile` branch matches phones. +export const MOBILE_RE = /mobile|android.*mobile|iphone|ipod|webos|blackberry|opera mini|iemobile/i; +export const TABLET_RE = /ipad|tablet|kindle|silk|playbook|android(?!.*mobile)/i; + +/** + * Simple mobile-or-not check (mobile + tablet = true). + * Use this for cache key splitting or any context where you + * only need a mobile/desktop binary decision. + */ +export function isMobileUA(userAgent: string): boolean { + return MOBILE_RE.test(userAgent) || TABLET_RE.test(userAgent); +} + +/** + * Detect device type from a User-Agent string. + * Pure function — no side effects, works anywhere. + */ +export function detectDevice(userAgent: string): Device { + if (TABLET_RE.test(userAgent)) return "tablet"; + if (MOBILE_RE.test(userAgent)) return "mobile"; + return "desktop"; +} diff --git a/packages/blocks/src/sdk/requestContext.ts b/packages/blocks/src/sdk/requestContext.ts index 79395ba..957c9ef 100644 --- a/packages/blocks/src/sdk/requestContext.ts +++ b/packages/blocks/src/sdk/requestContext.ts @@ -87,7 +87,7 @@ export interface RequestContextData { // Storage // ------------------------------------------------------------------------- -import { isMobileUA } from "./useDevice"; +import { isMobileUA } from "./detectDevice"; const BOT_RE = /bot|crawl|spider|slurp|bingpreview|facebookexternalhit|linkedinbot|twitterbot|whatsapp|telegram|googlebot|yandex|baidu|duckduck/i; diff --git a/packages/blocks/src/sdk/useDevice.ts b/packages/blocks/src/sdk/useDevice.ts index 9abf4d3..3290160 100644 --- a/packages/blocks/src/sdk/useDevice.ts +++ b/packages/blocks/src/sdk/useDevice.ts @@ -21,52 +21,32 @@ * ``` */ +import { detectDevice } from "./detectDevice"; import { RequestContext } from "./requestContext"; -export type Device = "mobile" | "tablet" | "desktop"; +// The pure UA-parsing half (`Device`, `MOBILE_RE`, `TABLET_RE`, +// `isMobileUA`, `detectDevice`) lives in `./detectDevice` — a leaf with no +// React and no RequestContext — and is re-exported below for backward +// compatibility. Modules that can land in a Next.js route-handler graph +// (`cms/sectionMixins.ts`, `sdk/requestContext.ts`) MUST import that leaf +// directly, not this file: this file also re-exports the `"use client"` +// context half, which crashes route handlers (see ./detectDevice's doc +// comment for the vendored-RSC-React mechanics). +export { type Device, detectDevice, isMobileUA, MOBILE_RE, TABLET_RE } from "./detectDevice"; // `DeviceContext`, the `useDevice()` hook, and `DeviceProvider` live in // `./useDeviceContext` (a `"use client"` file) and are re-exported below for // full backward compatibility with this module's public API — see that -// file's doc comment for why the split exists. Everything that stays in -// *this* file is a plain, hook-free function, safe to import from -// server-only code (e.g. `cms/sectionMixins.ts`'s `withDevice()`/ -// `withMobile()` loader mixins) without dragging a client-only hook/context -// boundary into a Server Component's module graph. +// file's doc comment for why the split exists. export { DeviceContext, useDevice, DeviceProvider } from "./useDeviceContext"; -// Android phones include "Mobile" in their UA; Android tablets do not. -// Check TABLET_RE first so `android(?!.*mobile)` captures tablets before -// the MOBILE_RE `android.*mobile` branch matches phones. -export const MOBILE_RE = /mobile|android.*mobile|iphone|ipod|webos|blackberry|opera mini|iemobile/i; -export const TABLET_RE = /ipad|tablet|kindle|silk|playbook|android(?!.*mobile)/i; - -/** - * Simple mobile-or-not check (mobile + tablet = true). - * Use this for cache key splitting or any context where you - * only need a mobile/desktop binary decision. - */ -export function isMobileUA(userAgent: string): boolean { - return MOBILE_RE.test(userAgent) || TABLET_RE.test(userAgent); -} - -/** - * Detect device type from a User-Agent string. - * Pure function — no side effects, works anywhere. - */ -export function detectDevice(userAgent: string): Device { - if (TABLET_RE.test(userAgent)) return "tablet"; - if (MOBILE_RE.test(userAgent)) return "mobile"; - return "desktop"; -} - /** * Resolve the current device from the ambient runtime (RequestContext on the * server, `navigator.userAgent` on the client) — no React hooks involved. * Used both by the plain `check*()` helpers below and by `useDevice()`/ * `DeviceProvider` in `./useDeviceContext`. */ -export function resolveDeviceFromRuntime(): Device { +export function resolveDeviceFromRuntime(): ReturnType { // Server: use RequestContext UA header if (typeof document === "undefined") { const ctx = RequestContext.current; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 913c49a..46966f0 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -10,7 +10,8 @@ }, "main": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./routeHandlers": "./src/routeHandlers.ts" }, "scripts": { "build": "tsc", diff --git a/packages/nextjs/src/routeHandlers.ts b/packages/nextjs/src/routeHandlers.ts index 014a4a1..d001142 100644 --- a/packages/nextjs/src/routeHandlers.ts +++ b/packages/nextjs/src/routeHandlers.ts @@ -1,3 +1,20 @@ +/** + * Admin Route Handlers for Next.js App Router. + * + * IMPORT THESE FROM `@decocms/nextjs/routeHandlers` IN ROUTE FILES — never + * from the `@decocms/nextjs` root barrel. Route handlers (app router + * route.ts files) evaluate their whole module graph against React's + * react-server build and IGNORE `"use client"` directives (there is no + * client graph to move a module into). The root barrel also exports the + * render components (SectionRenderer, DecoRootLayout, ...), whose graph + * reaches `@decocms/blocks/hooks` — files with module-scope client-React + * usage (e.g. `class ... extends Component`) that the react-server build + * does not export. Importing the root barrel from a route file therefore + * crashes at module evaluation ("...createContext is not a function" / + * "Class extends value undefined is not a constructor") before the handler + * ever runs. This subpath keeps the route-handler graph free of all + * component code. + */ import { handleDecofileRead, handleDecofileReload, @@ -6,7 +23,7 @@ import { handleRender, } from "@decocms/blocks-admin"; -/** For app/live/_meta/route.ts: `export { metaGET as GET } from "@decocms/nextjs"` */ +/** For app/live/_meta/route.ts: `export { metaGET as GET } from "@decocms/nextjs/routeHandlers"` */ export async function metaGET(request: Request): Promise { return handleMeta(request); } From 9a6b29756e520f89279fe4b3ab29b4686c05e385 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 17:47:12 -0300 Subject: [PATCH 56/85] fix(blocks-cli): exclude test/spec/stories/gen files from codegen scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate-schema.ts's findTsxFiles and generate-sections.ts's walkDir both scanned every .ts/.tsx under the sections dir, so a co-located test file (e.g. sections.test.ts) could be emitted as a bogus section block — this happened for real in fila's meta.gen.json. Both generators now route their directory walks through a shared isExcludedCodegenFile predicate. Co-Authored-By: Claude Sonnet 5 --- .../blocks-cli/scripts/generate-schema.ts | 2 + .../scripts/generate-sections.test.ts | 71 +++++++++++++++++++ .../blocks-cli/scripts/generate-sections.ts | 6 +- .../scripts/lib/codegenExclusions.test.ts | 22 ++++++ .../scripts/lib/codegenExclusions.ts | 11 +++ 5 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 packages/blocks-cli/scripts/generate-sections.test.ts create mode 100644 packages/blocks-cli/scripts/lib/codegenExclusions.test.ts create mode 100644 packages/blocks-cli/scripts/lib/codegenExclusions.ts diff --git a/packages/blocks-cli/scripts/generate-schema.ts b/packages/blocks-cli/scripts/generate-schema.ts index 7230c6f..c6b3a2d 100644 --- a/packages/blocks-cli/scripts/generate-schema.ts +++ b/packages/blocks-cli/scripts/generate-schema.ts @@ -31,6 +31,7 @@ import { SyntaxKind, type Type, } from "ts-morph"; +import { isExcludedCodegenFile } from "./lib/codegenExclusions"; // --------------------------------------------------------------------------- // CLI arg parsing @@ -734,6 +735,7 @@ function findTsxFiles(dir: string): string[] { const results: string[] = []; if (!fs.existsSync(dir)) return results; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (isExcludedCodegenFile(entry.name)) continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) results.push(...findTsxFiles(full)); else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) results.push(full); diff --git a/packages/blocks-cli/scripts/generate-sections.test.ts b/packages/blocks-cli/scripts/generate-sections.test.ts new file mode 100644 index 0000000..addadde --- /dev/null +++ b/packages/blocks-cli/scripts/generate-sections.test.ts @@ -0,0 +1,71 @@ +/** + * Integration test for `generate-sections.ts`. + * + * Drives the script as a child process against a tmp sections-dir fixture, + * mirroring the pattern used by migrate-to-cf-observability.test.ts. The + * script has no `isMainModule()` guard (unlike generate-schema.ts) — it runs + * its filesystem walk and write on import — so it's exercised as a subprocess + * rather than imported directly. + * + * Verifies the operationally important behavior: a co-located test/spec/ + * stories/gen file sitting next to a real section must never be walked into + * sectionMeta (the fila incident this generator's `walkDir` reproduced: + * `sections.test.ts` became a bogus section in a site's generated output). + */ +import * as cp from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const SCRIPT = path.resolve(__dirname, "generate-sections.ts"); + +function runGenerator(args: string[]): { stdout: string; stderr: string; code: number } { + const r = cp.spawnSync("npx", ["tsx", SCRIPT, ...args], { encoding: "utf8" }); + return { stdout: r.stdout || "", stderr: r.stderr || "", code: r.status ?? 0 }; +} + +describe("generate-sections walkDir exclusions", () => { + let tmpDir: string; + let sectionsDir: string; + let outFile: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-")); + sectionsDir = path.join(tmpDir, "sections"); + outFile = path.join(tmpDir, "out", "sections.gen.ts"); + fs.mkdirSync(sectionsDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("excludes co-located test/spec/stories/gen files from the generated sectionMeta", () => { + fs.writeFileSync( + path.join(sectionsDir, "Hero.tsx"), + `export const eager = true;\nexport default function Hero() { return null; }\n`, + ); + fs.writeFileSync( + path.join(sectionsDir, "Hero.test.tsx"), + `export const eager = true;\nexport default function HeroTest() { return null; }\n`, + ); + fs.writeFileSync( + path.join(sectionsDir, "Hero.stories.tsx"), + `export const eager = true;\nexport default function HeroStories() { return null; }\n`, + ); + fs.writeFileSync( + path.join(sectionsDir, "sections.gen.ts"), + `export const eager = true;\n`, + ); + + const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]); + expect(code).toBe(0); + + const generated = fs.readFileSync(outFile, "utf-8"); + expect(generated).toContain("site/sections/Hero.tsx"); + expect(generated).not.toContain("Hero.test.tsx"); + expect(generated).not.toContain("Hero.stories.tsx"); + expect(generated).not.toContain("sections.gen.ts"); + }); +}); diff --git a/packages/blocks-cli/scripts/generate-sections.ts b/packages/blocks-cli/scripts/generate-sections.ts index 5cf0fef..d91def0 100644 --- a/packages/blocks-cli/scripts/generate-sections.ts +++ b/packages/blocks-cli/scripts/generate-sections.ts @@ -20,6 +20,7 @@ */ import fs from "node:fs"; import path from "node:path"; +import { isExcludedCodegenFile } from "./lib/codegenExclusions"; const args = process.argv.slice(2); function arg(name: string, fallback: string): string { @@ -92,7 +93,10 @@ function walkDir(dir: string, base: string = dir): string[] { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...walkDir(fullPath, base)); - } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) { + } else if ( + (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) && + !isExcludedCodegenFile(entry.name) + ) { results.push(fullPath); } } diff --git a/packages/blocks-cli/scripts/lib/codegenExclusions.test.ts b/packages/blocks-cli/scripts/lib/codegenExclusions.test.ts new file mode 100644 index 0000000..e132a58 --- /dev/null +++ b/packages/blocks-cli/scripts/lib/codegenExclusions.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { isExcludedCodegenFile } from "./codegenExclusions"; + +describe("isExcludedCodegenFile", () => { + it.each([ + "Hero.test.tsx", + "Hero.test.ts", + "Hero.spec.tsx", + "Hero.stories.tsx", + "sections.gen.ts", + "meta.gen.json", + ])("excludes %s", (name) => { + expect(isExcludedCodegenFile(name)).toBe(true); + }); + + it.each(["Hero.tsx", "Product/SearchResult.tsx", "testimonials.tsx", "generic.ts"])( + "keeps %s", + (name) => { + expect(isExcludedCodegenFile(name)).toBe(false); + }, + ); +}); diff --git a/packages/blocks-cli/scripts/lib/codegenExclusions.ts b/packages/blocks-cli/scripts/lib/codegenExclusions.ts new file mode 100644 index 0000000..1c86707 --- /dev/null +++ b/packages/blocks-cli/scripts/lib/codegenExclusions.ts @@ -0,0 +1,11 @@ +/** + * Files the codegen scanners must never treat as section/loader sources. + * `generate-schema.ts` once emitted a site's co-located test file as a + * section block (it scans every .ts/.tsx under the sections dir), so both + * generators route their directory walks through this predicate. + */ +const EXCLUDED_SUFFIX_RE = /\.(test|spec|stories|gen)\.(ts|tsx|js|jsx|json)$/; + +export function isExcludedCodegenFile(fileName: string): boolean { + return EXCLUDED_SUFFIX_RE.test(fileName); +} From 1a321d62e11ee81cd8c9f7f1032b15e3678192e8 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 17:53:21 -0300 Subject: [PATCH 57/85] fix(blocks-cli): repo-relative schema definition IDs, not absolute file:// paths meta.gen.json definition keys were derived from ts-morph's absolute file:/// paths, so the same repo produced different schema IDs (and /live/_meta ETags) on every machine. Relativize path-derived IDs to the site root via definitionIdForPath(); CMS-key-derived IDs were already stable and are untouched. Co-Authored-By: Claude Sonnet 5 --- .../scripts/generate-schema.test.ts | 25 ++++++++++++++++++- .../blocks-cli/scripts/generate-schema.ts | 16 ++++++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/blocks-cli/scripts/generate-schema.test.ts b/packages/blocks-cli/scripts/generate-schema.test.ts index e58fd63..849204b 100644 --- a/packages/blocks-cli/scripts/generate-schema.test.ts +++ b/packages/blocks-cli/scripts/generate-schema.test.ts @@ -1,6 +1,29 @@ import { Project } from "ts-morph"; import { describe, expect, it } from "vitest"; -import { WIDGET_TYPE_FORMATS, applyWidgetFormat, typeToJsonSchema } from "./generate-schema"; +import { + WIDGET_TYPE_FORMATS, + applyWidgetFormat, + definitionIdForPath, + typeToJsonSchema, +} from "./generate-schema"; + +describe("definitionIdForPath", () => { + it("is repo-relative, never absolute", () => { + const id = definitionIdForPath( + "/Users/anyone/code/mysite/src/sections/Hero.tsx", + "/Users/anyone/code/mysite", + ); + expect(Buffer.from(id, "base64").toString()).toBe("src/sections/Hero.tsx"); + }); + + it("normalizes file:// prefixes from ts-morph", () => { + const id = definitionIdForPath( + "file:///Users/anyone/code/mysite/src/sections/Hero.tsx", + "/Users/anyone/code/mysite", + ); + expect(Buffer.from(id, "base64").toString()).toBe("src/sections/Hero.tsx"); + }); +}); describe("applyWidgetFormat", () => { it("recovers an unresolved widget alias (empty schema) as string + format", () => { diff --git a/packages/blocks-cli/scripts/generate-schema.ts b/packages/blocks-cli/scripts/generate-schema.ts index c6b3a2d..950742f 100644 --- a/packages/blocks-cli/scripts/generate-schema.ts +++ b/packages/blocks-cli/scripts/generate-schema.ts @@ -73,6 +73,18 @@ function toBase64(str: string): string { return Buffer.from(str).toString("base64"); } +/** + * Definition IDs must be stable across machines: the raw ts-morph path is + * absolute (`file:///Users//...`), which made meta.gen.json differ + * per machine and destabilized the /live/_meta ETag. IDs are opaque to the + * admin — only internal $ref consistency matters — so relativize to root. + */ +export function definitionIdForPath(filePath: string, rootDir: string): string { + const cleaned = filePath.replace(/^file:\/+/, "/"); + const rel = path.relative(rootDir, cleaned).replaceAll("\\", "/"); + return toBase64(rel.startsWith("..") ? cleaned : rel); +} + /** * Map JSDoc tags to JSON Schema 7 keywords. * Supports all 20+ tags from deco-cx/deco. @@ -1143,7 +1155,7 @@ function generateMeta(): MetaResponse { const propCount = Object.keys(propsSchema.properties || {}).length; - const propsDefKey = toBase64(`file:///${filePath.replaceAll("\\", "/")}`) + "@Props"; + const propsDefKey = definitionIdForPath(filePath, root) + "@Props"; definitions[propsDefKey] = propsSchema; const sectionDefKey = toBase64(blockKey); @@ -1234,7 +1246,7 @@ function generateMeta(): MetaResponse { const propCount = Object.keys(propsSchema.properties || {}).length; - const propsDefKey = toBase64(`file:///${filePath.replaceAll("\\", "/")}`) + "@Props"; + const propsDefKey = definitionIdForPath(filePath, root) + "@Props"; definitions[propsDefKey] = propsSchema; const appDefKey = toBase64(blockKey); From bf4c1a1539eb009ead6428cc8d41e7a7dc4c823c Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 17:58:36 -0300 Subject: [PATCH 58/85] docs: nextjs glue-tier + faststore-fila migration plan Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-08-nextjs-glue-tier.md | 1021 +++++++++++++++++ 1 file changed, 1021 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-nextjs-glue-tier.md diff --git a/docs/superpowers/plans/2026-07-08-nextjs-glue-tier.md b/docs/superpowers/plans/2026-07-08-nextjs-glue-tier.md new file mode 100644 index 0000000..5491ada --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-nextjs-glue-tier.md @@ -0,0 +1,1021 @@ +# @decocms/nextjs Glue Tier + faststore-fila Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `@decocms/nextjs` the bootstrap/config/dispatch glue tier that `@decocms/tanstack` already has, then migrate `~/code/faststore-fila` onto it, deleting its hand-rolled admin-route and registry boilerplate. + +**Architecture:** Upstream (repo `~/code/deco-start`, branch `v7`): codegen hygiene fixes in `blocks-cli`, portability fix in `blocks-admin`, then three new `@decocms/nextjs` surfaces — `createNextSetup()` (one-call site bootstrap composing the existing `createSiteSetup` + `applySectionConventions` + admin wiring), `createDecoRouteHandlers()` (single catch-all dispatcher replacing 5 hand-written route files), and `withDeco()` (next.config wrapper adding the Studio-protocol rewrites + transpilePackages). Site (repo `~/code/faststore-fila`, branch `feat/nextjs-package-migration`): make `src/sections/` entry files the single source of truth (generated registry via `generate-sections --registry`), migrate `setup.ts` to `createNextSetup`, replace 5 route files + `adminRoute.ts` with one catch-all. Fila is verified against **packed tarballs** of the upstream changes BEFORE pushing v7 (which auto-releases); only after that verification does v7 get pushed and fila flipped to the published version. + +**Tech Stack:** Bun workspaces, TypeScript (packages ship raw `.ts` src), vitest (upstream), Next.js 16 App Router + jest 30 (fila), semantic-release lockstep versioning (`blocks-v*` tags, all 14 packages same version). + +## Global Constraints + +- **Lockstep versioning**: every push to `v7` releases ALL packages at one shared version. Upstream commits here use `feat:`/`fix:` types → next release is **7.4.0**. +- **Do NOT push `v7` until the fila tarball-verification task (Task 11) passes** — pushing publishes. +- **No breaking changes to existing exports**: every current export of `@decocms/nextjs`, `@decocms/blocks`, `@decocms/blocks-admin`, `@decocms/blocks-cli` keeps working. New behavior is additive (new subpaths, new opt-in flags). +- **Route-handler graphs must stay react-server-safe**: nothing importable from `@decocms/nextjs/routeHandlers`, `@decocms/nextjs/setup`, or `@decocms/nextjs/config` may reach module-scope client-React (`createContext`, `class X extends Component`, `useState`, …). See `packages/nextjs/src/routeHandlers.ts`'s doc comment for the mechanics (route handlers ignore `"use client"` and run on React's react-server build). +- **`withDeco` must be requireable from a CommonJS `next.config.js`** (fila's is CJS). `@decocms/nextjs` has `"type": "module"`, so the config helper ships as **`.cjs`** with a `.d.cts` type file. +- **generate-sections' existing output must stay byte-identical for existing consumers** unless the new `--registry` flag is passed (tanstack sites regenerate these files in CI). +- **No `import.meta` syntax anywhere in `packages/blocks`, `packages/blocks-admin`, `packages/nextjs` source** after Task 3 (breaks CJS consumers like ts-jest). Grep-enforced. +- Monorepo checks that must stay green after every upstream task: `bun run --filter='./packages/' test` and `typecheck`. +- Fila checks that must stay green after every fila task: `bun jest src/sdk/deco/`, `bun x tsc --noEmit`, `/opt/homebrew/Cellar/node/26.4.0/bin/yarn build`. (Known pre-existing failure NOT to fix: `test/server/index.test.ts` "should handle options and execute" — persisted-query hash drift from unrelated Trustvox work.) +- Fila has two lockfiles: `yarn.lock` (git-tracked, used by the real deploy pipeline) and `bun.lock` (gitignored, local dev). Any `package.json` dependency change requires BOTH `bun install`/`bun update` AND `/opt/homebrew/Cellar/node/26.4.0/bin/yarn install`. +- Decision on legacy alias keys (recorded, do not revisit): fila's `.deco/blocks` uses legacy keys `site/sections/Newsletter/Newsletter.tsx` (365 files) and `site/sections/Footer/Footer.tsx` (1 file) for components canonically named `NewsletterCallout`/`Footer`. These are NOT codemodded — Studio re-imports would reintroduce them. They stay as 1-line alias entry files in `src/sections/`. + +--- + +## Part 1 — Upstream (`~/code/deco-start`, branch `v7`) + +### Task 1: Codegen exclusions — skip test/story/generated files in both generators + +`generate-schema.ts` scans every `.tsx`/`.ts` under the sections dir and emitted a site's *test file* as a section block (real incident: `sections.test.ts` became a bogus section in fila's `meta.gen.json`). `generate-sections.ts`'s `walkDir` has the same hole. + +**Files:** +- Modify: `packages/blocks-cli/scripts/generate-schema.ts` (its `findTsxFiles` function) +- Modify: `packages/blocks-cli/scripts/generate-sections.ts` (its `walkDir` function, ~line 87) +- Test: `packages/blocks-cli/scripts/generate-sections.test.ts` (create), extend `packages/blocks-cli/scripts/generate-schema.test.ts` + +**Interfaces:** +- Produces: exported `isExcludedCodegenFile(fileName: string): boolean` from a new shared module `packages/blocks-cli/scripts/lib/codegenExclusions.ts`, used by both generators. + +- [ ] **Step 1: Write the failing test.** Create `packages/blocks-cli/scripts/lib/codegenExclusions.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { isExcludedCodegenFile } from "./codegenExclusions"; + +describe("isExcludedCodegenFile", () => { + it.each([ + "Hero.test.tsx", + "Hero.test.ts", + "Hero.spec.tsx", + "Hero.stories.tsx", + "sections.gen.ts", + "meta.gen.json", + ])("excludes %s", (name) => { + expect(isExcludedCodegenFile(name)).toBe(true); + }); + + it.each(["Hero.tsx", "Product/SearchResult.tsx", "testimonials.tsx", "generic.ts"])( + "keeps %s", + (name) => { + expect(isExcludedCodegenFile(name)).toBe(false); + }, + ); +}); +``` + +Note `testimonials.tsx` and `generic.ts` — the regex must anchor on the dotted suffix, not substring-match `test`/`gen`. + +- [ ] **Step 2: Run it, verify it fails** (module doesn't exist): `bun run --filter='./packages/blocks-cli' test` → FAIL. + +- [ ] **Step 3: Implement** `packages/blocks-cli/scripts/lib/codegenExclusions.ts`: + +```ts +/** + * Files the codegen scanners must never treat as section/loader sources. + * `generate-schema.ts` once emitted a site's co-located test file as a + * section block (it scans every .ts/.tsx under the sections dir), so both + * generators route their directory walks through this predicate. + */ +const EXCLUDED_SUFFIX_RE = /\.(test|spec|stories|gen)\.(ts|tsx|js|jsx|json)$/; + +export function isExcludedCodegenFile(fileName: string): boolean { + return EXCLUDED_SUFFIX_RE.test(fileName); +} +``` + +- [ ] **Step 4: Wire it into both generators.** In `generate-schema.ts`, find `findTsxFiles` and filter each file entry: `if (isExcludedCodegenFile(entry.name)) continue;` (import from `./lib/codegenExclusions`). In `generate-sections.ts`'s `walkDir`, change the file-accept branch to: + +```ts +} else if ( + (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) && + !isExcludedCodegenFile(entry.name) +) { + results.push(fullPath); +} +``` + +- [ ] **Step 5: Add an integration assertion** to the existing `generate-schema.test.ts` if it has a fixture-directory test (read it first; if it builds fixture dirs on disk, drop a `Section.test.tsx` fixture in and assert it does NOT appear in output; if it only unit-tests exported helpers, the unit test from Step 1 suffices — do not build new fixture machinery). + +- [ ] **Step 6: Run tests + typecheck**: `bun run --filter='./packages/blocks-cli' test && bun run --filter='./packages/blocks-cli' typecheck` → PASS. + +- [ ] **Step 7: Commit**: `git commit -m "fix(blocks-cli): exclude test/spec/stories/gen files from codegen scans"` + +### Task 2: generate-schema — repo-relative definition IDs + +`meta.gen.json` definition keys are currently `btoa("file:////Users/gimenes/code//src/sections/X.tsx") + "@Props"` — machine-dependent, so the same repo generates different schema IDs on different machines (noisy diffs, unstable ETags). + +**Files:** +- Modify: `packages/blocks-cli/scripts/generate-schema.ts` +- Test: extend `packages/blocks-cli/scripts/generate-schema.test.ts` + +**Interfaces:** +- Consumes: the `root` variable already in scope in `generate-schema.ts` (the site root the script resolves at startup — locate it; it's used for `tsConfigFilePath: path.join(root, "tsconfig.json")` at ~line 754). +- Produces: definition IDs of the form `btoa("")` (e.g. `btoa("src/sections/BannerPair.tsx") + "@Props"`), stable across machines. All emitted `$ref`s must use the same transformed IDs — this is an internal-consistency requirement, not a wire-format promise (the admin treats IDs as opaque). + +- [ ] **Step 1: Locate the ID construction.** `grep -n "file:" packages/blocks-cli/scripts/generate-schema.ts` and `grep -n "toBase64" packages/blocks-cli/scripts/generate-schema.ts`. The `file:///` prefix comes from ts-morph source-file paths (likely `sourceFile.getFilePath()` or a symbol's declaration path) being fed into a definition-ID helper. Identify every call site that feeds a path into `toBase64`. + +- [ ] **Step 2: Write the failing test.** In `generate-schema.test.ts`, add (adapt to the file's existing style — it exports `applyWidgetFormat`/`typeToJsonSchema`; if the ID helper isn't exported, export it as `definitionIdForPath(filePath: string, root: string): string`): + +```ts +import { definitionIdForPath } from "./generate-schema"; + +describe("definitionIdForPath", () => { + it("is repo-relative, never absolute", () => { + const id = definitionIdForPath( + "/Users/anyone/code/mysite/src/sections/Hero.tsx", + "/Users/anyone/code/mysite", + ); + expect(Buffer.from(id, "base64").toString()).toBe("src/sections/Hero.tsx"); + }); + + it("normalizes file:// prefixes from ts-morph", () => { + const id = definitionIdForPath( + "file:///Users/anyone/code/mysite/src/sections/Hero.tsx", + "/Users/anyone/code/mysite", + ); + expect(Buffer.from(id, "base64").toString()).toBe("src/sections/Hero.tsx"); + }); +}); +``` + +- [ ] **Step 3: Run, verify FAIL** (helper not exported / behavior absolute). + +- [ ] **Step 4: Implement.** Add to `generate-schema.ts`: + +```ts +/** + * Definition IDs must be stable across machines: the raw ts-morph path is + * absolute (`file:///Users//...`), which made meta.gen.json differ + * per machine and destabilized the /live/_meta ETag. IDs are opaque to the + * admin — only internal $ref consistency matters — so relativize to root. + */ +export function definitionIdForPath(filePath: string, rootDir: string): string { + const cleaned = filePath.replace(/^file:\/+/, "/"); + const rel = path.relative(rootDir, cleaned).replaceAll("\\", "/"); + return toBase64(rel.startsWith("..") ? cleaned : rel); +} +``` + +Then replace every `toBase64()` call site found in Step 1 with `definitionIdForPath(, root)` — ONLY for file-path-derived IDs. IDs derived from CMS keys (`toBase64("site/sections/X.tsx")`) are already stable; leave them. + +- [ ] **Step 5: Run the full blocks-cli suite**: `bun run --filter='./packages/blocks-cli' test && bun run --filter='./packages/blocks-cli' typecheck` → PASS. If existing schema tests assert on old absolute-path IDs, update those assertions — they're asserting the bug. + +- [ ] **Step 6: Commit**: `git commit -m "fix(blocks-cli): repo-relative schema definition IDs, not absolute file:// paths"` + +### Task 3: blocks-admin — remove `import.meta` syntax (CJS portability) + +`packages/blocks-admin/src/admin/decofile.ts:82` has `const isViteDev = !!import.meta.env?.DEV;`. `import.meta` is a *syntax error* in CommonJS output, so any CJS consumer compiling the raw-TS package (ts-jest in fila) explodes; fila currently carries a `jest.mock('@decocms/blocks-admin')` workaround. + +**Files:** +- Modify: `packages/blocks-admin/src/admin/decofile.ts:82` (and any other `import.meta` occurrence the grep in Step 1 finds) +- Test: `packages/blocks-admin/src/admin/decofile.test.ts` (existing — must stay green) + +- [ ] **Step 1: Find all occurrences**: `grep -rn "import\.meta" packages/blocks/src packages/blocks-admin/src packages/nextjs/src`. Expected: only `decofile.ts:82`. Fix every hit the same way. + +- [ ] **Step 2: Read the gated behavior.** Open `decofile.ts` around line 82 and understand what `isViteDev` gates (dev-only decofile reload semantics). Preserve that behavior under Vite dev. + +- [ ] **Step 3: Replace** with a runtime-agnostic dev check that is valid syntax in both ESM and CJS: + +```ts +// `import.meta.env?.DEV` was a Vite-ism AND a syntax error for CJS +// consumers (ts-jest compiles this raw-TS package to CJS, where +// `import.meta` cannot be represented at all). Vite statically defines +// process.env.NODE_ENV in both dev and build, Next/Node set it natively, +// so NODE_ENV is the portable signal for the same dev-only behavior. +const isViteDev = + typeof process !== "undefined" && process.env.NODE_ENV === "development"; +``` + +Rename the variable to `isDevRuntime` (update its uses) since it's no longer Vite-specific — unless reads of the surrounding code show genuinely Vite-only semantics (e.g. it must be FALSE on `next dev`); in that case keep the NODE_ENV check but document the widened scope in the comment and verify Step 5's fila regression run. + +- [ ] **Step 4: Grep gate**: `grep -rn "import\.meta" packages/blocks/src packages/blocks-admin/src packages/nextjs/src` → zero hits. + +- [ ] **Step 5: Tests**: `bun run --filter='./packages/blocks-admin' test && bun run --filter='./packages/blocks-admin' typecheck` → PASS. Behavioral sanity: `cd ~/code/deco-start/examples/tanstack-smoke 2>/dev/null` — if the tanstack smoke example exists and has a dev script, boot it briefly and confirm decofile reload still logs; otherwise rely on the vitest suite (`decofile.test.ts` covers reload semantics). + +- [ ] **Step 6: Commit**: `git commit -m "fix(blocks-admin): drop import.meta syntax so CJS consumers (ts-jest) can compile"` + +### Task 4: generate-sections — `--registry` flag emitting a lazy-import section map + +`createSiteSetup` needs a `sections` map (`{"./sections/X.tsx": () => import(...)}`) that Vite sites get from `import.meta.glob`. Next has no glob — so the generator (which already walks `src/sections/`) emits the equivalent map behind an opt-in flag. Keys use the glob-style `./sections/...` form so the map is a drop-in for `SiteSetupOptions.sections` (whose transform is `site/${path.slice(2)}`). + +**Files:** +- Modify: `packages/blocks-cli/scripts/generate-sections.ts` +- Test: `packages/blocks-cli/scripts/generate-sections.test.ts` (created in Task 1, or create now) + +**Interfaces:** +- Produces (in generated `sections.gen.ts`, only when `--registry` is passed): `export const sectionImports: Record Promise> = { "./sections/Hero.tsx": () => import("../../sections/Hero"), ... };` +- Existing exports (`sectionMeta`, `syncComponents`, `loadingFallbacks`) unchanged; output without the flag stays byte-identical. + +- [ ] **Step 1: Write the failing test.** The generator is a CLI script; test it end-to-end with a temp fixture (vitest, node fs): + +```ts +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const SCRIPT = path.resolve(__dirname, "generate-sections.ts"); + +function runFixture(extraArgs: string[]): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gen-sections-")); + fs.mkdirSync(path.join(dir, "src/sections/Nested"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "src/sections/Hero.tsx"), + "export const sync = true\nexport default function Hero() { return null }\n", + ); + fs.writeFileSync( + path.join(dir, "src/sections/Nested/Promo.tsx"), + "export default function Promo() { return null }\n", + ); + execFileSync("bun", ["x", "tsx", SCRIPT, ...extraArgs], { cwd: dir }); + return fs.readFileSync(path.join(dir, "src/server/cms/sections.gen.ts"), "utf-8"); +} + +describe("generate-sections --registry", () => { + it("emits sectionImports keyed glob-style with relative dynamic imports", () => { + const out = runFixture(["--registry"]); + expect(out).toContain('export const sectionImports'); + expect(out).toContain('"./sections/Hero.tsx": () => import("../../sections/Hero")'); + expect(out).toContain('"./sections/Nested/Promo.tsx": () => import("../../sections/Nested/Promo")'); + }); + + it("does not emit sectionImports without the flag", () => { + const out = runFixture([]); + expect(out).not.toContain("sectionImports"); + }); +}); +``` + +Note: without `--registry`, `Promo.tsx` (no convention exports) isn't in `entries` at all today — the registry must be built from **all scanned section files**, not just convention-carrying `entries`. + +- [ ] **Step 2: Run, verify FAIL.** + +- [ ] **Step 3: Implement.** In `generate-sections.ts`: parse the flag (`const EMIT_REGISTRY = args.includes("--registry");`). After the existing `sectionFiles` walk, when `EMIT_REGISTRY`, append to `lines` before the final write: + +```ts +if (EMIT_REGISTRY) { + lines.push(""); + lines.push("/**"); + lines.push(" * Lazy section registry — the Next.js/webpack equivalent of Vite's"); + lines.push(' * `import.meta.glob("./sections/**/*.tsx")`. Keys use the glob-style'); + lines.push(" * `./sections/...` form so this map drops straight into"); + lines.push(" * `createSiteSetup({ sections })` / `createNextSetup({ sections })`."); + lines.push(" */"); + lines.push("export const sectionImports: Record Promise> = {"); + for (const filePath of sectionFiles) { + const rel = path.relative(sectionsDir, filePath).replace(/\\/g, "/"); + const importPath = relativeImportPath(outFile, filePath); + lines.push(` "./sections/${rel}": () => import("${importPath}"),`); + } + lines.push("};"); +} +``` + +- [ ] **Step 4: Run tests**: `bun run --filter='./packages/blocks-cli' test` → PASS (both new tests and the byte-stability of default output implicitly via the no-flag test). + +- [ ] **Step 5: Commit**: `git commit -m "feat(blocks-cli): generate-sections --registry emits a lazy section-import map for non-Vite bundlers"` + +### Task 5: `@decocms/nextjs/setup` — `createNextSetup()` + +One-call, route-handler-safe bootstrap for Next sites. Composes existing framework pieces; owns nothing novel. + +**Files:** +- Create: `packages/nextjs/src/setup.ts` +- Create: `packages/nextjs/src/setup.test.ts` +- Modify: `packages/nextjs/package.json` (exports map: add `"./setup": "./src/setup.ts"`) + +**Interfaces:** +- Consumes: `createSiteSetup` from `@decocms/blocks/setup`; `applySectionConventions`, `loadBlocks` from `@decocms/blocks/cms`; `loadDecofileDirectory` from `@decocms/blocks/cms/loadDecofileDirectory`; lazy `setMetaData`, `setRenderShell`, `setPreviewWrapper` from `@decocms/blocks-admin`. +- Produces: `createNextSetup(options: NextSetupOptions): () => Promise` — returns a memoized `ensureSetup`. Task 6's dispatcher and fila's Task 10 consume this exact signature. + +- [ ] **Step 1: Write the failing test** `packages/nextjs/src/setup.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listRegisteredSections, loadBlocks, setBlocks } from "@decocms/blocks/cms"; +import { createNextSetup } from "./setup"; + +describe("createNextSetup", () => { + beforeEach(() => { + setBlocks({}); + }); + + it("returns a memoized ensureSetup that registers blocks, sections, meta", async () => { + const meta = vi.fn().mockResolvedValue({ + major: 1, + version: "test", + namespace: "site", + site: "test", + manifest: { blocks: { sections: {} } }, + schema: { definitions: {}, root: {} }, + platform: "test", + cloudProvider: "test", + }); + const ensureSetup = createNextSetup({ + blocksDir: false, + blocks: { myBlock: { __resolveType: "site/sections/Hero.tsx" } }, + sections: { "./sections/Hero.tsx": async () => ({ default: () => null }) }, + meta, + }); + + await ensureSetup(); + await ensureSetup(); // memoized — meta loader must run once + + expect(meta).toHaveBeenCalledTimes(1); + expect(loadBlocks().myBlock).toBeDefined(); + expect(listRegisteredSections()).toContain("site/sections/Hero.tsx"); + }); + + it("applies section conventions when provided", async () => { + const ensureSetup = createNextSetup({ + blocksDir: false, + sections: { "./sections/Footer.tsx": async () => ({ default: () => null }) }, + conventions: { meta: { "site/sections/Footer.tsx": { layout: true } } }, + }); + await ensureSetup(); + const { isLayoutSection } = await import("@decocms/blocks/cms"); + expect(isLayoutSection("site/sections/Footer.tsx")).toBe(true); + }); +}); +``` + +Check the real exported names before finalizing the test: `isLayoutSection` and `listRegisteredSections` exist in `@decocms/blocks/cms` (see `packages/blocks/src/cms/index.ts`). If `isLayoutSection`'s signature differs, assert via whatever the barrel exposes for layout-section checks. + +- [ ] **Step 2: Run, verify FAIL** (`bun run --filter='./packages/nextjs' test`). + +- [ ] **Step 3: Implement** `packages/nextjs/src/setup.ts`: + +```ts +/** + * One-call site bootstrap for Next.js — the App Router sibling of the + * Vite flow (`createSiteSetup` + `createAdminSetup` + import.meta.glob). + * Next has no import.meta.glob and no Vite plugin, so this composes the + * same framework pieces from a generated section registry + * (`generate-sections --registry`) and a filesystem decofile directory. + * + * ROUTE-HANDLER-SAFE: this module (and everything it imports eagerly) must + * never reach module-scope client-React — it is imported by route files + * via the site's setup module. Admin setters are imported lazily for the + * same reason createAdminSetup keeps meta lazy: they're only needed when + * an admin request actually arrives... and because @decocms/blocks-admin + * is a heavier graph than the CMS core. + * + * @example site's `src/deco/setup.ts` + * ```ts + * import { createNextSetup } from "@decocms/nextjs/setup"; + * import { sectionImports, sectionMeta, syncComponents } from "./sections.gen"; + * + * export const ensureSetup = createNextSetup({ + * sections: sectionImports, + * conventions: { meta: sectionMeta, syncComponents }, + * meta: () => import("./meta.gen.json").then((m) => m.default), + * }); + * ``` + */ +import type { ApplySectionConventionsInput } from "@decocms/blocks/cms"; +import { applySectionConventions, loadBlocks } from "@decocms/blocks/cms"; +import { loadDecofileDirectory } from "@decocms/blocks/cms/loadDecofileDirectory"; +import { createSiteSetup, type SiteSetupOptions } from "@decocms/blocks/setup"; + +export interface NextSetupOptions { + /** + * Directory of decofile JSON snapshots, relative to the site root. + * Pass `false` to skip filesystem loading (blocks come from `blocks`). + * @default ".deco/blocks" + */ + blocksDir?: string | false; + + /** Extra/override blocks, merged OVER the directory's blocks. */ + blocks?: Record; + + /** + * Lazy section registry — `sectionImports` from + * `generate-sections --registry` (keys `./sections/X.tsx`). + */ + sections: Record Promise>; + + /** `{ meta: sectionMeta, syncComponents, loadingFallbacks }` from sections.gen.ts. */ + conventions?: Omit; + + /** Lazy admin meta schema: `() => import("./meta.gen.json").then(m => m.default)`. */ + meta?: () => Promise; + + /** Admin preview shell (CSS/font URLs) — see blocks-admin setRenderShell. */ + renderShell?: { css?: string; fonts?: string[] }; + + /** Admin preview wrapper component. */ + previewWrapper?: React.ComponentType; + + productionOrigins?: SiteSetupOptions["productionOrigins"]; + customMatchers?: SiteSetupOptions["customMatchers"]; + onResolveError?: SiteSetupOptions["onResolveError"]; + onDanglingReference?: SiteSetupOptions["onDanglingReference"]; + + /** + * Site-specific wiring that must run after the core setup (section + * loaders, SEO keys for legacy decofiles, curated post-processing). + * Receives the loaded blocks. + */ + extend?: (blocks: Record) => void | Promise; +} + +export function createNextSetup(options: NextSetupOptions): () => Promise { + let setupPromise: Promise | null = null; + + return function ensureSetup(): Promise { + setupPromise ??= (async () => { + const dirBlocks = + options.blocksDir === false + ? {} + : await loadDecofileDirectory(options.blocksDir ?? ".deco/blocks"); + const blocks = { ...dirBlocks, ...options.blocks }; + + createSiteSetup({ + sections: options.sections, + blocks, + productionOrigins: options.productionOrigins, + customMatchers: options.customMatchers, + onResolveError: options.onResolveError, + onDanglingReference: options.onDanglingReference, + }); + + if (options.conventions) { + applySectionConventions({ + ...options.conventions, + sectionGlob: options.sections, + }); + } + + if (options.meta || options.renderShell || options.previewWrapper) { + const admin = await import("@decocms/blocks-admin"); + if (options.meta) admin.setMetaData((await options.meta()) as never); + if (options.renderShell) admin.setRenderShell(options.renderShell); + if (options.previewWrapper) admin.setPreviewWrapper(options.previewWrapper); + } + + await options.extend?.(loadBlocks()); + })(); + return setupPromise; + }; +} +``` + +Verify exact imported names against the barrels before finishing: `ApplySectionConventionsInput` is exported from `@decocms/blocks/cms`; `SiteSetupOptions` from `@decocms/blocks/setup`; `setRenderShell`'s option type in blocks-admin (`{ css: string; fonts?: string[] }` — if `css` is required there, keep `renderShell.css` required in `NextSetupOptions` accordingly). + +- [ ] **Step 4: Add the exports entry** in `packages/nextjs/package.json`: `"./setup": "./src/setup.ts"` (after `"./routeHandlers"`). + +- [ ] **Step 5: Run tests + typecheck**: `bun run --filter='./packages/nextjs' test && bun run --filter='./packages/nextjs' typecheck` → PASS. + +- [ ] **Step 6: Commit**: `git commit -m "feat(nextjs): createNextSetup — one-call Next.js site bootstrap"` + +### Task 6: `createDecoRouteHandlers()` — single catch-all dispatcher + +Replaces per-URL route files. Mounted at `app/deco/[[...deco]]/route.ts`; `withDeco`'s rewrites (Task 7) funnel the Studio-protocol URLs (`/.decofile`, `/live/_meta`, `/live/previews/*`) under `/deco/*`. + +**Files:** +- Modify: `packages/nextjs/src/routeHandlers.ts` (append; existing named exports unchanged) +- Test: `packages/nextjs/src/routeHandlers.test.ts` (extend) + +**Interfaces:** +- Consumes: `handleDecofileRead`, `handleDecofileReload`, `handleInvoke`, `handleMeta`, `handleRender` from `@decocms/blocks-admin` (already imported at top of file). +- Produces: `createDecoRouteHandlers(options?: { setup?: () => Promise }): { GET(request: Request): Promise; POST(request: Request): Promise }`. URL contract (post-rewrite paths): `/deco/decofile` (GET read, POST reload), `/deco/meta` (GET), `/deco/previews/` (GET+POST, URL rebuilt to `/live/previews/` before delegating — `handleRender` parses that literal prefix), `/deco/invoke/` (POST, passed through untouched — `handleInvoke` parses the key from the path), `/deco/render` (GET+POST). Anything else → 404 JSON. + +- [ ] **Step 1: Write the failing tests** (extend `routeHandlers.test.ts`; mock the blocks-admin handlers): + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + handleDecofileRead: vi.fn(async () => new Response("decofile")), + handleDecofileReload: vi.fn(async () => new Response("reloaded")), + handleInvoke: vi.fn(async () => new Response("invoked")), + handleMeta: vi.fn(() => new Response("meta")), + handleRender: vi.fn(async (req: Request) => new Response(new URL(req.url).pathname)), +})); +vi.mock("@decocms/blocks-admin", () => mocks); + +import { createDecoRouteHandlers } from "./routeHandlers"; + +describe("createDecoRouteHandlers", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs setup before dispatching and routes decofile GET/POST", async () => { + const order: string[] = []; + const setup = vi.fn(async () => { order.push("setup"); }); + const { GET, POST } = createDecoRouteHandlers({ setup }); + + await GET(new Request("http://x/deco/decofile")); + expect(setup).toHaveBeenCalled(); + expect(mocks.handleDecofileRead).toHaveBeenCalled(); + + await POST(new Request("http://x/deco/decofile", { method: "POST" })); + expect(mocks.handleDecofileReload).toHaveBeenCalled(); + }); + + it("routes meta, render, and invoke", async () => { + const { GET, POST } = createDecoRouteHandlers(); + await GET(new Request("http://x/deco/meta")); + expect(mocks.handleMeta).toHaveBeenCalled(); + await POST(new Request("http://x/deco/render", { method: "POST" })); + expect(mocks.handleRender).toHaveBeenCalled(); + await POST(new Request("http://x/deco/invoke/site/actions/x", { method: "POST" })); + expect(mocks.handleInvoke).toHaveBeenCalled(); + }); + + it("rebuilds /deco/previews/* URLs to the /live/previews/* prefix handleRender parses", async () => { + const { GET } = createDecoRouteHandlers(); + const res = await GET(new Request("http://x/deco/previews/pages-Home-123?props=x")); + expect(await res.text()).toBe("/live/previews/pages-Home-123"); + const calledUrl = new URL(mocks.handleRender.mock.calls[0][0].url); + expect(calledUrl.searchParams.get("props")).toBe("x"); + }); + + it("404s unknown deco paths", async () => { + const { GET } = createDecoRouteHandlers(); + const res = await GET(new Request("http://x/deco/nope")); + expect(res.status).toBe(404); + }); +}); +``` + +- [ ] **Step 2: Run, verify FAIL.** + +- [ ] **Step 3: Implement** (append to `routeHandlers.ts`): + +```ts +export interface DecoRouteHandlersOptions { + /** + * Site bootstrap, awaited before every admin request — pass the + * ensureSetup returned by createNextSetup (@decocms/nextjs/setup). + */ + setup?: () => Promise; +} + +/** + * Single catch-all dispatcher for the whole Studio admin protocol. Mount + * at `app/deco/[[...deco]]/route.ts` and wrap next.config with + * `withDeco()` (@decocms/nextjs/config), whose rewrites map the protocol + * URLs Next can't express as segments (`/.decofile`, `/live/_meta`, + * `/live/previews/*`) into `/deco/*`: + * + * ```ts + * import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers"; + * import { ensureSetup } from "../../../deco/setup"; + * export const dynamic = "force-dynamic"; + * export const { GET, POST } = createDecoRouteHandlers({ setup: ensureSetup }); + * ``` + */ +export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}): { + GET(request: Request): Promise; + POST(request: Request): Promise; +} { + async function dispatch(request: Request): Promise { + await options.setup?.(); + + const url = new URL(request.url); + const action = url.pathname.replace(/^\/deco\//, ""); + + if (action === "decofile") { + return request.method === "POST" ? handleDecofileReload(request) : handleDecofileRead(); + } + if (action === "meta") return handleMeta(request); + if (action === "render") return handleRender(request); + if (action.startsWith("invoke/")) return handleInvoke(request); + if (action === "previews" || action.startsWith("previews/")) { + // handleRender parses the literal "/live/previews/" prefix — rebuild + // the pre-rewrite URL (rewrites hand route handlers the DESTINATION + // path, so the prefix information is otherwise lost). + const rest = action === "previews" ? "" : action.slice("previews/".length); + const rebuilt = new URL(url); + rebuilt.pathname = `/live/previews/${rest}`; + return handleRender(new Request(rebuilt, request)); + } + return new Response(JSON.stringify({ error: `Unknown deco route: ${url.pathname}` }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + return { GET: dispatch, POST: dispatch }; +} +``` + +- [ ] **Step 4: Run tests**: `bun run --filter='./packages/nextjs' test` → PASS (new tests + the pre-existing `routeHandlers.test.ts` case). + +- [ ] **Step 5: Commit**: `git commit -m "feat(nextjs): createDecoRouteHandlers catch-all admin dispatcher"` + +### Task 7: `withDeco()` next.config wrapper + +**Files:** +- Create: `packages/nextjs/src/config.cjs` (CJS — requireable from a CJS `next.config.js`; the package is `"type": "module"` so `.js` would be ESM) +- Create: `packages/nextjs/src/config.d.cts` +- Create: `packages/nextjs/src/config.test.ts` +- Modify: `packages/nextjs/package.json` (exports: `"./config": { "types": "./src/config.d.cts", "default": "./src/config.cjs" }`) + +**Interfaces:** +- Produces: `withDeco(nextConfig?: NextConfig): NextConfig` — merges (1) rewrites `[{source:"/.decofile",destination:"/deco/decofile"},{source:"/live/_meta",destination:"/deco/meta"},{source:"/live/previews/:path*",destination:"/deco/previews/:path*"}]` ahead of any user rewrites, handling user `rewrites` as absent, async function returning array, or async function returning `{beforeFiles,afterFiles,fallback}`; (2) `transpilePackages` deduped with `["@decocms/blocks","@decocms/blocks-admin","@decocms/nextjs"]`. + +- [ ] **Step 1: Write the failing test** `packages/nextjs/src/config.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { withDeco, DECO_REWRITES } = require("./config.cjs"); + +describe("withDeco", () => { + it("adds rewrites and transpilePackages to a bare config", async () => { + const cfg = withDeco({}); + expect(cfg.transpilePackages).toEqual( + expect.arrayContaining(["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"]), + ); + expect(await cfg.rewrites()).toEqual(DECO_REWRITES); + }); + + it("prepends deco rewrites to a user's array-returning rewrites()", async () => { + const cfg = withDeco({ + rewrites: async () => [{ source: "/a", destination: "/b" }], + }); + const out = await cfg.rewrites(); + expect(out.slice(0, DECO_REWRITES.length)).toEqual(DECO_REWRITES); + expect(out.at(-1)).toEqual({ source: "/a", destination: "/b" }); + }); + + it("merges into a user's object-form rewrites via beforeFiles", async () => { + const cfg = withDeco({ + rewrites: async () => ({ + beforeFiles: [{ source: "/x", destination: "/y" }], + afterFiles: [], + fallback: [], + }), + }); + const out = await cfg.rewrites(); + expect(out.beforeFiles.slice(0, DECO_REWRITES.length)).toEqual(DECO_REWRITES); + expect(out.beforeFiles.at(-1)).toEqual({ source: "/x", destination: "/y" }); + }); + + it("dedupes transpilePackages", () => { + const cfg = withDeco({ transpilePackages: ["@decocms/blocks", "other"] }); + expect(cfg.transpilePackages.filter((p: string) => p === "@decocms/blocks")).toHaveLength(1); + expect(cfg.transpilePackages).toContain("other"); + }); +}); +``` + +- [ ] **Step 2: Run, verify FAIL.** + +- [ ] **Step 3: Implement** `packages/nextjs/src/config.cjs`: + +```js +/** + * next.config wrapper for Deco sites. CommonJS on purpose: next.config.js + * is CJS in most sites and this package is "type": "module", so a .js + * file here would be ESM and unrequireable on Node < 22. + * + * Adds: + * 1. Rewrites for the Studio-protocol URLs Next cannot express as route + * segments — `/.decofile` (segments can't start with a dot) and + * `/live/_meta` (`_`-prefixed segments are Next "private folders", + * silently excluded from routing) — plus `/live/previews/*`, all + * funneled to `/deco/*` where a single catch-all route + * (`app/deco/[[...deco]]/route.ts` + createDecoRouteHandlers) serves + * the whole protocol. + * 2. transpilePackages for the raw-TS @decocms packages. + */ +const DECO_REWRITES = [ + { source: "/.decofile", destination: "/deco/decofile" }, + { source: "/live/_meta", destination: "/deco/meta" }, + { source: "/live/previews/:path*", destination: "/deco/previews/:path*" }, +]; + +const DECO_TRANSPILE = ["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"]; + +function withDeco(nextConfig = {}) { + const userRewrites = nextConfig.rewrites; + return { + ...nextConfig, + transpilePackages: [ + ...new Set([...(nextConfig.transpilePackages ?? []), ...DECO_TRANSPILE]), + ], + async rewrites() { + const user = + typeof userRewrites === "function" ? await userRewrites() : (userRewrites ?? []); + if (Array.isArray(user)) return [...DECO_REWRITES, ...user]; + return { ...user, beforeFiles: [...DECO_REWRITES, ...(user.beforeFiles ?? [])] }; + }, + }; +} + +module.exports = { withDeco, DECO_REWRITES }; +``` + +And `packages/nextjs/src/config.d.cts`: + +```ts +import type { NextConfig } from "next"; + +export declare const DECO_REWRITES: Array<{ source: string; destination: string }>; +export declare function withDeco(nextConfig?: NextConfig): NextConfig; +``` + +- [ ] **Step 4: Exports map** in `packages/nextjs/package.json`: + +```json +"./config": { + "types": "./src/config.d.cts", + "default": "./src/config.cjs" +} +``` + +- [ ] **Step 5: Run tests + typecheck + knip**: `bun run --filter='./packages/nextjs' test && bun run --filter='./packages/nextjs' typecheck` → PASS. If `lint:unused` (knip) flags the .cjs, add it to knip's entry config in the package. + +- [ ] **Step 6: Commit**: `git commit -m "feat(nextjs): withDeco next.config wrapper (Studio rewrites + transpilePackages)"` + +### Task 8: Migrate `examples/nextjs-smoke` to the new APIs + package README + +The smoke fixture currently hand-rolls exactly what Tasks 5–7 shipped (ad-hoc rewrites in its next.config, `app/deco-decofile/route.ts`, `app/live/meta/route.ts`). Migrating it is the in-repo validation that the new APIs actually compose. + +**Files:** +- Modify: `examples/nextjs-smoke/next.config.ts` (wrap with `withDeco`, delete the ad-hoc rewrites) +- Create: `examples/nextjs-smoke/src/app/deco/[[...deco]]/route.ts` +- Delete: `examples/nextjs-smoke/src/app/deco-decofile/route.ts`, `examples/nextjs-smoke/src/app/live/meta/route.ts` +- Modify: `examples/nextjs-smoke/src/setup.ts` (use `createNextSetup`; read the current file first and port its existing registrations into the options / `extend`) +- Create: `packages/nextjs/README.md` + +- [ ] **Step 1: Read the current fixture** (`src/setup.ts`, both route files, `next.config.ts`, `package.json` scripts) to know what behavior must be preserved. + +- [ ] **Step 2: Rewrite `next.config.ts`**: + +```ts +import type { NextConfig } from "next"; +import { withDeco } from "@decocms/nextjs/config"; + +const nextConfig: NextConfig = {}; + +export default withDeco(nextConfig); +``` + +(Port any non-rewrite options the current config carries.) + +- [ ] **Step 3: Create the catch-all route** `src/app/deco/[[...deco]]/route.ts`: + +```ts +import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers"; +import { ensureSetup } from "../../../setup"; + +export const dynamic = "force-dynamic"; + +export const { GET, POST } = createDecoRouteHandlers({ setup: ensureSetup }); +``` + +(Adjust the relative import to the fixture's real setup path; the setup module must export `ensureSetup` after Step 4.) + +- [ ] **Step 4: Rewrite `src/setup.ts`** around `createNextSetup`, exporting `ensureSetup`. Keep whatever blocks/sections the fixture registers today (port them into `blocks`/`sections`/`extend`). Delete the two old route files. + +- [ ] **Step 5: Build the fixture**: `cd examples/nextjs-smoke && bun install && bun run build` → succeeds. Then `bun run dev` briefly (background, log to file), `curl -s -o /dev/null -w "%{http_code}" localhost:3000/.decofile` → 200, same for `/live/_meta` → 200 (or the fixture's designed response), kill dev. + +- [ ] **Step 6: Write `packages/nextjs/README.md`** — the complete recipe a new Next site follows. Contents (all code, no hand-waving): install line; `withDeco` in next.config (both CJS `require` and TS `import` forms); the catch-all route file verbatim; `src/deco/setup.ts` with `createNextSetup` verbatim; the two package scripts with **non-colliding names** (`"generate:deco-meta"` and `"generate:deco-sections"` — call out explicitly that FastStore sites already own `generate:schema`); the `src/sections/` entry-file convention (thin re-export files; component internals stay elsewhere; `export const sync/layout/seo/cache` conventions; warning that EVERY `.tsx` in the dir becomes a section key); the route-handler import rule (subpaths, never the root barrel, and why in one paragraph). + +- [ ] **Step 7: Monorepo gate**: `bun run typecheck && bun run test` (all packages) → all green. + +- [ ] **Step 8: Commit**: `git commit -m "feat(nextjs): migrate nextjs-smoke to withDeco/createDecoRouteHandlers/createNextSetup + README recipe"` + +--- + +## Part 2 — Site (`~/code/faststore-fila`, branch `feat/nextjs-package-migration`) + +Tasks 9–11 run against **packed tarballs** of the Task 1–8 work (NOT bun link — Vite/webpack behave differently with symlinks; this session already proved fixes "verified" via link can be false). Install them like this before Task 9: + +```bash +cd ~/code/deco-start +for p in blocks blocks-admin blocks-cli nextjs; do (cd packages/$p && npm pack --pack-destination /tmp/deco-tarballs/); done +cd ~/code/faststore-fila +for p in blocks blocks-admin blocks-cli nextjs; do + rm -rf node_modules/@decocms/$p + mkdir -p node_modules/@decocms/$p + tar -xzf /tmp/deco-tarballs/decocms-$p-*.tgz -C node_modules/@decocms/$p --strip-components=1 +done +``` + +(Workspace `package.json`s say version `0.0.0` with real semver ranges on their `@decocms/*` deps — those deps are already satisfied by the extracted set itself plus the hoisted tree. After extraction run `node -e "require('@decocms/nextjs/package.json')"`-style sanity checks only; do NOT run `bun install`, which would clobber the extraction.) + +### Task 9: fila — conventions + generated section registry + +**Files:** +- Modify: all 20 files in `~/code/faststore-fila/src/sections/**/*.tsx` (add convention exports) +- Modify: `~/code/faststore-fila/package.json` (add `generate:deco-sections` script) +- Create (generated): `~/code/faststore-fila/src/sdk/deco/sections.gen.ts` +- Rewrite: `~/code/faststore-fila/src/sdk/deco/sections.ts` +- Modify: `~/code/faststore-fila/src/sdk/deco/sectionShims.test.ts` + +**Interfaces:** +- Produces: `sections.gen.ts` exporting `sectionImports`, `sectionMeta`, `syncComponents` (from `generate-sections --registry`); rewritten `sections.ts` whose module side effect registers everything on both server and client bundles (this is what pages/hydration rely on — see the current file's doc comment about `sideEffects: false`). + +- [ ] **Step 1: Add convention exports to every entry file.** Every one of the 20 files gets `export const sync = true` appended (fila registers every section synchronously today — all components are statically imported in the current `sections.ts`, and hydration relies on `getSyncComponent`). The two Footer entries (`Footer.tsx`, `Footer/Footer.tsx`) ALSO get `export const layout = true` (replaces the manual `registerLayoutSections` call in setup.ts). Example — `src/sections/HeroSlideshow.tsx` becomes: + +```tsx +// Schema-codegen + registry entry — NOT imported by app pages directly. +// `generate:deco-meta` and `generate:deco-sections` scan src/sections/ and +// derive each block key from the file path (`site/sections/`), so +// this file's location IS the registry key. The re-export points at the +// real component so ts-morph can extract the Props schema. +export { default } from 'src/components/sections/HeroSlideshow' + +// Bundled synchronously (static import) — required for hydration parity; +// see src/sdk/deco/sections.ts. +export const sync = true +``` + +- [ ] **Step 2: Add the script** to fila `package.json` scripts: + +```json +"generate:deco-sections": "tsx node_modules/@decocms/blocks-cli/scripts/generate-sections.ts --registry --out-file src/sdk/deco/sections.gen.ts" +``` + +Run it: `bun run generate:deco-sections`. Inspect `src/sdk/deco/sections.gen.ts`: 20 keys in `sectionMeta` (all `sync: true`, both Footer keys `layout: true`), 20 static `import * as _syncN` lines, 20 entries in `sectionImports`. + +- [ ] **Step 3: Rewrite `src/sdk/deco/sections.ts`** — the hand map dies; the file becomes the both-bundles registration side effect: + +```ts +/** + * Section registry — runs as a module-load side effect on both server and + * client bundles so `getResolvedComponent`/`getSyncComponent` find the + * same components on both sides of hydration. + * + * The registry itself is GENERATED (src/sdk/deco/sections.gen.ts, from the + * entry files in src/sections/ via `bun run generate:deco-sections`) — the + * key set lives in the filesystem, not in a hand-written map. Add a + * section by adding an entry file under src/sections/ and re-running the + * generator (sectionShims.test.ts fails if you forget). + * + * `SECTIONS` stays a NAMED export consumed by setup.ts/pages because the + * project's `package.json` sets `"sideEffects": false` — webpack drops + * side-effect-only imports, so importing a value is what keeps this module + * (and its registration loop) in the bundle. + */ +import { applySectionConventions, registerSections } from '@decocms/blocks/cms' + +import { sectionImports, sectionMeta, syncComponents } from './sections.gen' + +// Same key transform createSiteSetup applies: "./sections/X.tsx" → "site/sections/X.tsx" +const lazySections: Record Promise> = {} +for (const [globKey, loader] of Object.entries(sectionImports)) { + lazySections[`site/${globKey.slice(2)}`] = loader +} +registerSections(lazySections as never) +applySectionConventions({ + meta: sectionMeta, + syncComponents, + sectionGlob: sectionImports as never, +}) + +export const SECTIONS = syncComponents +``` + +Check every current importer of `SECTIONS` (`grep -rn "from './sections'\|from 'src/sdk/deco/sections'" src/`) — they import it only to defeat tree-shaking (`void _SECTIONS`), so the changed value shape (namespace modules instead of bare components) is fine; confirm no importer indexes into it. If one does, adapt that importer to `getSyncComponent`. + +- [ ] **Step 4: Regenerate meta and update the drift test.** `bun run generate:deco-meta`. Rewrite `sectionShims.test.ts`'s registry-comparison to compare against the GENERATED registry instead of the deleted hand map: + +```ts +// (imports section stays; replace `import { SECTIONS } from './sections'`) +import { sectionMeta } from './sections.gen' +// registryKeys becomes: +const registryKeys = Object.keys(sectionMeta).sort() +``` + +Keep all three assertions (shims ↔ registry ↔ meta.gen.json). Note the jest.mock lines for `@generated` may become unnecessary once the hand map (whose component imports pulled `@generated` in) is gone — try removing them; restore if the suite fails. + +- [ ] **Step 5: Verify**: `bun jest src/sdk/deco/ && bun x tsc --noEmit` → PASS. Boot `bun dev` (background, log file), curl `/` → 200 and one PLP (e.g. `/sale`) → 200, confirm sections render (grep the HTML for a known section marker, e.g. `hero` markup), kill dev. + +- [ ] **Step 6: Commit**: `git commit -m "refactor(deco): generate the section registry from src/sections entries (kill the hand map)"` + +### Task 10: fila — `setup.ts` on `createNextSetup` + +**Files:** +- Modify: `~/code/faststore-fila/src/sdk/deco/setup.ts` (the `ensureSetup` body, lines ~123–200) + +**Interfaces:** +- Consumes: `createNextSetup` from `@decocms/nextjs/setup` (Task 5), `sectionImports`/`sectionMeta`/`syncComponents` from `./sections.gen` (Task 9). +- Produces: `ensureSetup: () => Promise` — same name/signature as today (adminRoute.ts, pages, `getAllCmsPagePaths`, `resolveCmsPage` keep calling it unchanged). + +- [ ] **Step 1: Rewrite the setup composition.** Replace the current `let setupPromise` + `export function ensureSetup()` block with: + +```ts +import { createNextSetup } from '@decocms/nextjs/setup' +import { sectionImports, sectionMeta, syncComponents } from './sections.gen' +``` + +```ts +export const ensureSetup = createNextSetup({ + blocksDir: '.deco/blocks', + // Curated overrides win over the imported decofile snapshots. + blocks: { + [HOME_BLOCK_KEY]: homeBlock as unknown as Record, + [PDP_BLOCK_KEY]: pdpBlock as unknown as Record, + }, + sections: sectionImports, + conventions: { meta: sectionMeta, syncComponents }, + meta: () => import('./meta.gen.json').then((m) => m.default), + onResolveError: (error, resolveType, context) => { + // eslint-disable-next-line no-console + console.error(`[deco] ${context} "${resolveType}" failed:`, error) + }, + extend: (allBlocks) => { + pageFacetsByPath = buildPageFacetsByPath(allBlocks) + allPagePaths = collectAllPagePaths(allBlocks) + + // Legacy fila-store decofiles put SEO blocks under these commerce/ + // website keys — not section entries, so registered here, not via + // file conventions. (See the original comment block for the scan + // numbers: 328 pages SeoPLPV2, 53 SeoV2.) + registerSeoSections([ + 'commerce/sections/Seo/SeoPLPV2.tsx', + 'website/sections/Seo/SeoV2.tsx', + ]) + + registerSectionLoaders({ + /* keep the existing 'site/sections/Product/SearchResult.tsx' and + FilaProductDetails loader bodies verbatim — move them here + unchanged from the old ensureSetup */ + }) + }, +}) +``` + +Details that must survive the move: (1) `registerLayoutSections` call DELETED — the `layout = true` conventions from Task 9 replace it; (2) the two `jest.mock`-sensitive lazy admin imports are now inside `createNextSetup` — fila's `setup.test.ts` keeps its `jest.mock('@decocms/blocks-admin', ...)` ONLY if Task 3's fix hasn't landed in the installed tarball (it has — try deleting the mock; keep the `@generated` mocks); (3) `createSiteSetup` (inside `createNextSetup`) additionally calls `registerBuiltinMatchers()` — NEW behavior for fila (matcher-carrying decofile pages start evaluating device/date/cookie matchers instead of falling to defaults). Verify Step 3's page-diff below and mention it in the commit message. + +- [ ] **Step 2: Static checks**: `bun x tsc --noEmit && bun jest src/sdk/deco/` → PASS. + +- [ ] **Step 3: Behavioral diff.** Boot `bun dev`; capture `/`, `/sale`, and one PDP with `curl -s | wc -c` AND spot-grep for a stable marker (title tags). Compare byte sizes to a pre-change capture (git stash the working tree, capture, pop — or accept small diffs and eyeball the HTML for missing sections). `/.decofile` → 200, `/live/_meta` → 200. Kill dev. + +- [ ] **Step 4: Commit**: `git commit -m "refactor(deco): ensureSetup via createNextSetup (framework bootstrap, site logic in extend)"` + +### Task 11: fila — `withDeco` + catch-all route; delete the boilerplate + +**Files:** +- Modify: `~/code/faststore-fila/next.config.js` +- Create: `~/code/faststore-fila/src/app/deco/[[...deco]]/route.ts` +- Delete: `src/app/.decofile/route.ts`, `src/app/live/%5Fmeta/route.ts`, `src/app/deco/render/route.ts`, `src/app/live/previews/[[...path]]/route.ts`, `src/app/deco/invoke/[[...path]]/route.ts`, `src/sdk/deco/adminRoute.ts` + +- [ ] **Step 1: next.config.js** — wrap with `withDeco` and delete the now-redundant manual `transpilePackages` trio (keep the storeConfig-derived extras): + +```js +const { withDeco } = require('@decocms/nextjs/config') +// ... +module.exports = withDeco(nextConfig) +``` + +If the existing config exports via a compose chain, wrap at the outermost point. Delete `'@decocms/blocks'`, `'@decocms/blocks-admin'`, `'@decocms/nextjs'` from the manual `transpilePackages` array (withDeco adds them; keep the comment explaining why transpilation is needed, pointing at withDeco). + +- [ ] **Step 2: The one route file** `src/app/deco/[[...deco]]/route.ts`: + +```ts +// The ENTIRE Studio admin protocol: /.decofile, /live/_meta, +// /live/previews/* (all rewritten here by withDeco in next.config.js), +// plus the natively-addressable /deco/invoke/* and /deco/render. +// Replaces five hand-written route files + adminRoute.ts. +// +// Imports the /routeHandlers subpath, NOT the @decocms/nextjs root barrel: +// route handlers evaluate against React's react-server build and ignore +// "use client", so the root barrel's component graph crashes at import +// time ("createContext is not a function"). +import { createDecoRouteHandlers } from '@decocms/nextjs/routeHandlers' + +import { ensureSetup } from 'src/sdk/deco/setup' + +export const dynamic = 'force-dynamic' + +export const { GET, POST } = createDecoRouteHandlers({ setup: ensureSetup }) +``` + +- [ ] **Step 3: Delete** the 5 old route files and `adminRoute.ts`. `grep -rn "adminRoute" src/` → zero hits. + +- [ ] **Step 4: Full endpoint verification** (dev server, background + log): + - `curl -s -o /dev/null -w "%{http_code}" localhost:3000/.decofile` → 200, response starts with `{"` and is ~2.5MB + - `POST /.decofile` → 200 (reload) + - `/live/_meta` → 200 with `etag`; repeat with `If-None-Match` → 304 + - `/deco/invoke/site/loaders/anything` POST → a NON-404 admin response (401/400/500 from the handler is fine — proves dispatch reached `handleInvoke`) + - `/live/previews/` → 200 HTML shell + - `/deco/render?resolveChain=...` → 200 + - `/` and `/sale` → 200 + - log grep: zero `createContext` errors, zero unhandled rejections + +- [ ] **Step 5: Full gates**: `bun jest` (only the known pre-existing `test/server/index.test.ts` failure allowed), `bun x tsc --noEmit`, `/opt/homebrew/Cellar/node/26.4.0/bin/yarn build` → PASS. + +- [ ] **Step 6: Commit**: `git commit -m "refactor(deco): withDeco + single catch-all admin route, delete 5 route files + adminRoute"` + +### Task 12: Release + flip fila to the published version + +- [ ] **Step 1: Upstream final gate**: in `~/code/deco-start`: `bun run typecheck && bun run test` all green; `git log --oneline origin/v7..v7` shows exactly the Task 1–8 commits. + +- [ ] **Step 2: Push v7**: `git push origin v7`. Monitor `gh run list --repo decocms/blocks --branch v7 --limit 1` until complete; verify `npm view @decocms/nextjs@7.4.0 version` → `7.4.0` (all 14 packages, spot-check 3). + +- [ ] **Step 3: Flip fila to published packages**: in `~/code/faststore-fila`: set the four `@decocms/*` ranges to `^7.4.0` in `package.json` (`blocks`, `blocks-admin`, `nextjs` in dependencies-or-devDeps as currently placed, `blocks-cli` in devDependencies), then `bun install` (replaces the tarball extractions), `bun update @decocms/blocks @decocms/blocks-admin @decocms/blocks-cli @decocms/nextjs`, then `/opt/homebrew/Cellar/node/26.4.0/bin/yarn install`. Verify `node -e "console.log(require('./node_modules/@decocms/nextjs/package.json').version)"` → `7.4.0`. + +- [ ] **Step 4: Re-run the Task 11 Step 4 endpoint verification + Step 5 gates** against the published install (this is the guard against "works from tarball, broken from registry" — the manifest.gen.ts files-field incident class). + +- [ ] **Step 5: Update fila `CLAUDE.md`**: in the deco-related sections, document: entry files in `src/sections/` are the single source of truth (`generate:deco-sections` + `generate:deco-meta`), `createNextSetup` in `src/sdk/deco/setup.ts`, the single catch-all admin route + withDeco, and the route-file subpath-import rule. + +- [ ] **Step 6: Commit + push fila**: single commit `"refactor(deco): adopt @decocms/nextjs 7.4.0 glue tier (withDeco, catch-all route, createNextSetup, generated registry)"` — then `git push origin feat/nextjs-package-migration`. Before pushing, `git fetch` and check for remote force-updates (this branch was force-rebased by another session once already); rebase if needed, re-run `yarn install --frozen-lockfile` as the lockfile gate. From 97695d5d0fa8362ca10bd7fce8a134c75162e777 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 18:05:25 -0300 Subject: [PATCH 59/85] fix(blocks-admin): drop import.meta syntax so CJS consumers (ts-jest) can compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `import.meta.env?.DEV` in decofile.ts's dev-bypass check is a syntax error when a CJS consumer (ts-jest, as in fila's Next.js jest suite) compiles this raw-TS package — `import.meta` has no CJS representation at all, so the file fails to parse before any test even runs. Replace it with a NODE_ENV check (valid in both ESM and CJS) and rename isViteDev -> isDevRuntime since it's no longer Vite-specific. This intentionally widens the dev-bypass to `next dev` (previously always false there, since Next never defines import.meta.env) and narrows it slightly under vitest (NODE_ENV=test, not "development"), so decofile.test.ts now forces NODE_ENV=development in beforeAll/afterAll to preserve its original no-auth-bypass coverage. Co-Authored-By: Claude Sonnet 5 --- .../blocks-admin/src/admin/decofile.test.ts | 16 +++++++++--- packages/blocks-admin/src/admin/decofile.ts | 25 +++++++++++++------ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/blocks-admin/src/admin/decofile.test.ts b/packages/blocks-admin/src/admin/decofile.test.ts index e109482..64ef825 100644 --- a/packages/blocks-admin/src/admin/decofile.test.ts +++ b/packages/blocks-admin/src/admin/decofile.test.ts @@ -1,9 +1,19 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { KV_KEYS, type KVNamespace, getRevision, loadBlocks, setBlocks } from "@decocms/blocks/cms"; import { handleDecofileReload, setFastDeployKVGetter } from "./decofile"; -// In vitest, import.meta.env.DEV is true, so handleDecofileReload skips the -// auth check (same branch the dev Vite plugin uses) — no token needed here. +// handleDecofileReload's dev-bypass keys off NODE_ENV === "development" (see +// decofile.ts), not import.meta.env.DEV. Vitest itself runs with +// NODE_ENV=test (not "development"), so force it here to preserve this +// suite's original intent: exercise the reload handler via the same no-auth +// branch the dev Vite plugin / `next dev` use, without needing a token. +const ORIGINAL_NODE_ENV = process.env.NODE_ENV; +beforeAll(() => { + process.env.NODE_ENV = "development"; +}); +afterAll(() => { + process.env.NODE_ENV = ORIGINAL_NODE_ENV; +}); // decofile.ts no longer hard-imports getFastDeployKV (that would create a // admin → tanstack dependency, which is backwards). Instead the diff --git a/packages/blocks-admin/src/admin/decofile.ts b/packages/blocks-admin/src/admin/decofile.ts index 0bdd449..8b83aa0 100644 --- a/packages/blocks-admin/src/admin/decofile.ts +++ b/packages/blocks-admin/src/admin/decofile.ts @@ -105,14 +105,23 @@ export async function handleDecofileReload( // both the reload token and the fast-deploy KV binding. const runtimeEnv = env ?? getRuntimeEnv(); - // In dev mode the Vite plugin POSTs new blocks here to hot-reload without - // module invalidation (which breaks TanStack Start/Router state). Skip auth - // so the plugin can POST from localhost. - // Uses import.meta.env.DEV directly (not isDevMode()) because isDevMode() - // bypass auth. Vite statically replaces import.meta.env.DEV with `false` - // in production builds, so this branch is dead-code-eliminated. - const isViteDev = !!(import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV; - if (!isViteDev) { + // In dev mode the Vite plugin (tanstack) or `next dev` POSTs new blocks + // here to hot-reload without module invalidation (which breaks TanStack + // Start/Router state). Skip auth so the plugin can POST from localhost. + // + // `import.meta.env?.DEV` was a Vite-ism AND a syntax error for CJS + // consumers (ts-jest compiles this raw-TS package to CJS, where + // `import.meta` cannot be represented at all). It was also always `false` + // under Next.js — Turbopack/webpack never define `import.meta.env` — so + // this dev-bypass never applied to `next dev` before this change. Checking + // NODE_ENV instead widens the bypass to any Node process with + // NODE_ENV=development, including `next dev`. That widening is intentional: + // dev environments shouldn't need a production reload token, and the check + // remains fail-closed (auth required) for any other NODE_ENV value (unset, + // "test", "production"). + const isDevRuntime = + typeof process !== "undefined" && process.env.NODE_ENV === "development"; + if (!isDevRuntime) { const authHeader = request.headers.get("Authorization") || ""; const expectedToken = (runtimeEnv?.DECO_RELEASE_RELOAD_TOKEN as string | undefined) ?? From a519e6ea0efd2afa52d43c13416f27db8f84c5d2 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 18:10:15 -0300 Subject: [PATCH 60/85] fix(blocks): drop last import.meta occurrence in env.ts env.ts was the one signal left after 97695d5 removed import.meta from decofile.ts. It's importable via the @decocms/blocks/sdk barrel, so any CJS consumer (ts-jest) compiling this raw-TS package would hit an ESM-only syntax error. Replace the import.meta.env.DEV read with a try/catch'd process.env.NODE_ENV read (readNodeEnv()) that bundlers can still statically define at build time, preserving the NODE_ENV and DECO_PREVIEW fallback signals unchanged. Adds env.test.ts covering isDevMode() across production/development/DECO_PREVIEW. Co-Authored-By: Claude Sonnet 5 --- packages/blocks/src/sdk/env.test.ts | 33 +++++++++++++++++++++++++++ packages/blocks/src/sdk/env.ts | 35 ++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 packages/blocks/src/sdk/env.test.ts diff --git a/packages/blocks/src/sdk/env.test.ts b/packages/blocks/src/sdk/env.test.ts new file mode 100644 index 0000000..ef2e41e --- /dev/null +++ b/packages/blocks/src/sdk/env.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// isDevMode() memoises its result in module-level state after the first +// call, so each case below resets modules and re-imports fresh to get an +// unmemoised read for the NODE_ENV value it's stubbing. +describe("isDevMode", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("is false when NODE_ENV is production", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("DECO_PREVIEW", ""); + vi.resetModules(); + const { isDevMode } = await import("./env"); + expect(isDevMode()).toBe(false); + }); + + it("is true when NODE_ENV is development", async () => { + vi.stubEnv("NODE_ENV", "development"); + vi.resetModules(); + const { isDevMode } = await import("./env"); + expect(isDevMode()).toBe(true); + }); + + it("is true when DECO_PREVIEW=true even if NODE_ENV is production", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("DECO_PREVIEW", "true"); + vi.resetModules(); + const { isDevMode } = await import("./env"); + expect(isDevMode()).toBe(true); + }); +}); diff --git a/packages/blocks/src/sdk/env.ts b/packages/blocks/src/sdk/env.ts index 040e4f5..72022a5 100644 --- a/packages/blocks/src/sdk/env.ts +++ b/packages/blocks/src/sdk/env.ts @@ -7,12 +7,37 @@ let _isDev: boolean | null = null; +/** + * Reads `process.env.NODE_ENV` defensively. + * + * Wrapped in try/catch so the bare `process` reference can't throw in runtimes + * without a `process` global (workerd without `nodejs_compat`). Bundlers that + * statically define `process.env.NODE_ENV` (Vite/esbuild in Workers builds — + * the same role the old `import.meta.env.DEV` signal served) replace this + * expression with a string constant at build time, so no runtime `process` + * global is needed in that path at all. + */ +function readNodeEnv(): string | undefined { + try { + return process.env.NODE_ENV; + } catch { + return undefined; + } +} + /** * Returns `true` when running in a development environment. * * Detection order: - * 1. `import.meta.env.DEV` — Vite build-time constant (reliable in Workers/Miniflare) - * 2. `NODE_ENV=development` — standard Node/Vite convention + * 1. `readNodeEnv() === "development"` — bundler-define-friendly read of + * `process.env.NODE_ENV` (see `readNodeEnv` above). This replaces the + * previous `import.meta.env.DEV` signal: `import.meta` is ESM-only syntax + * and is a hard syntax error when CJS consumers (ts-jest) compile this + * raw-TS package, and `env.ts` is reachable via the `@decocms/blocks/sdk` + * barrel, so any such consumer importing the barrel would fail to compile. + * 2. `NODE_ENV=development` read off `globalThis.process.env` — standard + * Node/Vite convention, for runtimes where `process` is a real global. + * 3. `DECO_PREVIEW=true` — explicit preview-mode override. * * The result is memoised after the first evaluation. */ @@ -21,11 +46,9 @@ export function isDevMode(): boolean { const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined; - // Vite statically replaces import.meta.env.DEV at build time (true in dev, false in prod). - // In Miniflare/Workers, process.env is unavailable, so this is the reliable signal. - const vitaDev = !!(import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV; + const nodeEnvDev = readNodeEnv() === "development"; - _isDev = vitaDev || env?.NODE_ENV === "development" || env?.DECO_PREVIEW === "true"; + _isDev = nodeEnvDev || env?.NODE_ENV === "development" || env?.DECO_PREVIEW === "true"; return _isDev; } From 2edf4e038eb6b20c444f241e7a4adc68f74eeaed Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 18:16:16 -0300 Subject: [PATCH 61/85] feat(blocks-cli): generate-sections --registry emits a lazy section-import map for non-Vite bundlers createSiteSetup's `sections` option is normally satisfied by Vite's import.meta.glob, which Next has no equivalent of. Behind an opt-in --registry flag, generate-sections now walks the same section files it already scans and emits `sectionImports`, a glob-style-keyed Record Promise> that drops straight into SiteSetupOptions.sections (and createNextSetup's sectionGlob later). Built from every scanned file, not just ones carrying convention exports, so files like a bare Promo.tsx are still registered. Output without the flag is untouched, keeping Vite sites' regenerated sections.gen.ts byte-stable in CI. Co-Authored-By: Claude Sonnet 5 --- .../scripts/generate-sections.test.ts | 72 +++++++++++++++++++ .../blocks-cli/scripts/generate-sections.ts | 26 +++++++ 2 files changed, 98 insertions(+) diff --git a/packages/blocks-cli/scripts/generate-sections.test.ts b/packages/blocks-cli/scripts/generate-sections.test.ts index addadde..9bdb93b 100644 --- a/packages/blocks-cli/scripts/generate-sections.test.ts +++ b/packages/blocks-cli/scripts/generate-sections.test.ts @@ -69,3 +69,75 @@ describe("generate-sections walkDir exclusions", () => { expect(generated).not.toContain("sections.gen.ts"); }); }); + +describe("generate-sections --registry", () => { + let tmpDir: string; + let sectionsDir: string; + let outFile: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-registry-")); + sectionsDir = path.join(tmpDir, "sections"); + outFile = path.join(tmpDir, "out", "sections.gen.ts"); + fs.mkdirSync(path.join(sectionsDir, "Nested"), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function expectedImportPath(filePath: string): string { + let rel = path.relative(path.dirname(outFile), filePath).replace(/\\/g, "/"); + if (!rel.startsWith(".")) rel = `./${rel}`; + return rel.replace(/\.tsx?$/, ""); + } + + it("emits sectionImports keyed glob-style with relative dynamic imports, built from all scanned section files (not just convention-carrying ones)", () => { + const heroPath = path.join(sectionsDir, "Hero.tsx"); + const promoPath = path.join(sectionsDir, "Nested", "Promo.tsx"); + fs.writeFileSync( + heroPath, + "export const sync = true\nexport default function Hero() { return null }\n", + ); + // No convention exports — regression guard: without --registry this file + // never makes it into `entries`, so the registry must be built from the + // raw `sectionFiles` walk, not from `entries`. + fs.writeFileSync( + promoPath, + "export default function Promo() { return null }\n", + ); + + const { code } = runGenerator([ + "--sections-dir", sectionsDir, + "--out-file", outFile, + "--registry", + ]); + expect(code).toBe(0); + + const generated = fs.readFileSync(outFile, "utf-8"); + expect(generated).toContain("export const sectionImports"); + expect(generated).toContain( + `"./sections/Hero.tsx": () => import("${expectedImportPath(heroPath)}")`, + ); + expect(generated).toContain( + `"./sections/Nested/Promo.tsx": () => import("${expectedImportPath(promoPath)}")`, + ); + }); + + it("does not emit sectionImports without the --registry flag", () => { + fs.writeFileSync( + path.join(sectionsDir, "Hero.tsx"), + "export const sync = true\nexport default function Hero() { return null }\n", + ); + fs.writeFileSync( + path.join(sectionsDir, "Nested", "Promo.tsx"), + "export default function Promo() { return null }\n", + ); + + const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]); + expect(code).toBe(0); + + const generated = fs.readFileSync(outFile, "utf-8"); + expect(generated).not.toContain("sectionImports"); + }); +}); diff --git a/packages/blocks-cli/scripts/generate-sections.ts b/packages/blocks-cli/scripts/generate-sections.ts index d91def0..25ac41b 100644 --- a/packages/blocks-cli/scripts/generate-sections.ts +++ b/packages/blocks-cli/scripts/generate-sections.ts @@ -17,6 +17,12 @@ * CLI: * --sections-dir override input (default: src/sections) * --out-file override output (default: src/server/cms/sections.gen.ts) + * --registry also emit `sectionImports` — a lazy section-import map + * keyed glob-style (`./sections/...`), the Next.js/webpack + * equivalent of Vite's `import.meta.glob("./sections/**\/*.tsx")`. + * Built from every scanned section file, not just the ones + * carrying convention exports. Off by default so existing + * Vite sites regenerating sections.gen.ts in CI see zero diff. */ import fs from "node:fs"; import path from "node:path"; @@ -30,6 +36,7 @@ function arg(name: string, fallback: string): string { const sectionsDir = path.resolve(process.cwd(), arg("sections-dir", "src/sections")); const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/sections.gen.ts")); +const EMIT_REGISTRY = args.includes("--registry"); interface SectionMeta { eager?: boolean; @@ -227,6 +234,25 @@ if (allFallbacks.length > 0) { } lines.push(""); +// Lazy section-import registry — opt-in via --registry, built from every +// scanned section file (not just convention-carrying `entries`). +if (EMIT_REGISTRY) { + lines.push(""); + lines.push("/**"); + lines.push(" * Lazy section registry — the Next.js/webpack equivalent of Vite's"); + lines.push(' * `import.meta.glob("./sections/**/*.tsx")`. Keys use the glob-style'); + lines.push(" * `./sections/...` form so this map drops straight into"); + lines.push(" * `createSiteSetup({ sections })` / `createNextSetup({ sections })`."); + lines.push(" */"); + lines.push("export const sectionImports: Record Promise> = {"); + for (const filePath of sectionFiles) { + const rel = path.relative(sectionsDir, filePath).replace(/\\/g, "/"); + const importPath = relativeImportPath(outFile, filePath); + lines.push(` "./sections/${rel}": () => import("${importPath}"),`); + } + lines.push("};"); +} + fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync(outFile, lines.join("\n")); From be848436faf0f88fc72cdcaa84a63c287f04f8e1 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 18:21:53 -0300 Subject: [PATCH 62/85] =?UTF-8?q?feat(nextjs):=20createNextSetup=20?= =?UTF-8?q?=E2=80=94=20one-call=20Next.js=20site=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes createSiteSetup, applySectionConventions, loadBlocks (all route-handler-safe) with loadDecofileDirectory for filesystem decofile loading, and lazily imports @decocms/blocks-admin for meta/renderShell/ previewWrapper so the eager import graph never touches module-scope client-React. Returns a memoized ensureSetup() for Task 6's dispatcher and fila's Task 10 to call before every admin/route-handler request. Co-Authored-By: Claude Sonnet 5 --- packages/nextjs/package.json | 3 +- packages/nextjs/src/setup.test.ts | 55 +++++++++++++++ packages/nextjs/src/setup.ts | 112 ++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 packages/nextjs/src/setup.test.ts create mode 100644 packages/nextjs/src/setup.ts diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 46966f0..8adc854 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -11,7 +11,8 @@ "main": "./src/index.ts", "exports": { ".": "./src/index.ts", - "./routeHandlers": "./src/routeHandlers.ts" + "./routeHandlers": "./src/routeHandlers.ts", + "./setup": "./src/setup.ts" }, "scripts": { "build": "tsc", diff --git a/packages/nextjs/src/setup.test.ts b/packages/nextjs/src/setup.test.ts new file mode 100644 index 0000000..6e5d979 --- /dev/null +++ b/packages/nextjs/src/setup.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment node +// +// createSiteSetup (via @decocms/blocks/setup) only calls setBlocks() when +// `typeof document === "undefined"` — it's the server-only half of the +// Vite dual-environment split. Next.js Route Handlers run server-side with +// no DOM, which is exactly what createNextSetup targets, so this suite runs +// under vitest's "node" environment rather than the package default +// (jsdom, used by the component-rendering tests in this same package) to +// match that real invocation context. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listRegisteredSections, loadBlocks, setBlocks } from "@decocms/blocks/cms"; +import { createNextSetup } from "./setup"; + +describe("createNextSetup", () => { + beforeEach(() => { + setBlocks({}); + }); + + it("returns a memoized ensureSetup that registers blocks, sections, meta", async () => { + const meta = vi.fn().mockResolvedValue({ + major: 1, + version: "test", + namespace: "site", + site: "test", + manifest: { blocks: { sections: {} } }, + schema: { definitions: {}, root: {} }, + platform: "test", + cloudProvider: "test", + }); + const ensureSetup = createNextSetup({ + blocksDir: false, + blocks: { myBlock: { __resolveType: "site/sections/Hero.tsx" } }, + sections: { "./sections/Hero.tsx": async () => ({ default: () => null }) }, + meta, + }); + + await ensureSetup(); + await ensureSetup(); // memoized — meta loader must run once + + expect(meta).toHaveBeenCalledTimes(1); + expect(loadBlocks().myBlock).toBeDefined(); + expect(listRegisteredSections()).toContain("site/sections/Hero.tsx"); + }); + + it("applies section conventions when provided", async () => { + const ensureSetup = createNextSetup({ + blocksDir: false, + sections: { "./sections/Footer.tsx": async () => ({ default: () => null }) }, + conventions: { meta: { "site/sections/Footer.tsx": { layout: true } } }, + }); + await ensureSetup(); + const { isLayoutSection } = await import("@decocms/blocks/cms"); + expect(isLayoutSection("site/sections/Footer.tsx")).toBe(true); + }); +}); diff --git a/packages/nextjs/src/setup.ts b/packages/nextjs/src/setup.ts new file mode 100644 index 0000000..cedb02f --- /dev/null +++ b/packages/nextjs/src/setup.ts @@ -0,0 +1,112 @@ +/** + * One-call site bootstrap for Next.js — the App Router sibling of the + * Vite flow (`createSiteSetup` + `createAdminSetup` + import.meta.glob). + * Next has no import.meta.glob and no Vite plugin, so this composes the + * same framework pieces from a generated section registry + * (`generate-sections --registry`) and a filesystem decofile directory. + * + * ROUTE-HANDLER-SAFE: this module (and everything it imports eagerly) must + * never reach module-scope client-React — it is imported by route files + * via the site's setup module. Admin setters are imported lazily for the + * same reason createAdminSetup keeps meta lazy: they're only needed when + * an admin request actually arrives... and because @decocms/blocks-admin + * is a heavier graph than the CMS core. + * + * @example site's `src/deco/setup.ts` + * ```ts + * import { createNextSetup } from "@decocms/nextjs/setup"; + * import { sectionImports, sectionMeta, syncComponents } from "./sections.gen"; + * + * export const ensureSetup = createNextSetup({ + * sections: sectionImports, + * conventions: { meta: sectionMeta, syncComponents }, + * meta: () => import("./meta.gen.json").then((m) => m.default), + * }); + * ``` + */ +import type { ApplySectionConventionsInput } from "@decocms/blocks/cms"; +import { applySectionConventions, loadBlocks } from "@decocms/blocks/cms"; +import { loadDecofileDirectory } from "@decocms/blocks/cms/loadDecofileDirectory"; +import { createSiteSetup, type SiteSetupOptions } from "@decocms/blocks/setup"; + +export interface NextSetupOptions { + /** + * Directory of decofile JSON snapshots, relative to the site root. + * Pass `false` to skip filesystem loading (blocks come from `blocks`). + * @default ".deco/blocks" + */ + blocksDir?: string | false; + + /** Extra/override blocks, merged OVER the directory's blocks. */ + blocks?: Record; + + /** + * Lazy section registry — `sectionImports` from + * `generate-sections --registry` (keys `./sections/X.tsx`). + */ + sections: Record Promise>; + + /** `{ meta: sectionMeta, syncComponents, loadingFallbacks }` from sections.gen.ts. */ + conventions?: Omit; + + /** Lazy admin meta schema: `() => import("./meta.gen.json").then(m => m.default)`. */ + meta?: () => Promise; + + /** Admin preview shell (CSS/font URLs) — see blocks-admin setRenderShell. */ + renderShell?: { css?: string; fonts?: string[] }; + + /** Admin preview wrapper component. */ + previewWrapper?: React.ComponentType; + + productionOrigins?: SiteSetupOptions["productionOrigins"]; + customMatchers?: SiteSetupOptions["customMatchers"]; + onResolveError?: SiteSetupOptions["onResolveError"]; + onDanglingReference?: SiteSetupOptions["onDanglingReference"]; + + /** + * Site-specific wiring that must run after the core setup (section + * loaders, SEO keys for legacy decofiles, curated post-processing). + * Receives the loaded blocks. + */ + extend?: (blocks: Record) => void | Promise; +} + +export function createNextSetup(options: NextSetupOptions): () => Promise { + let setupPromise: Promise | null = null; + + return function ensureSetup(): Promise { + setupPromise ??= (async () => { + const dirBlocks = + options.blocksDir === false + ? {} + : await loadDecofileDirectory(options.blocksDir ?? ".deco/blocks"); + const blocks = { ...dirBlocks, ...options.blocks }; + + createSiteSetup({ + sections: options.sections, + blocks, + productionOrigins: options.productionOrigins, + customMatchers: options.customMatchers, + onResolveError: options.onResolveError, + onDanglingReference: options.onDanglingReference, + }); + + if (options.conventions) { + applySectionConventions({ + ...options.conventions, + sectionGlob: options.sections, + }); + } + + if (options.meta || options.renderShell || options.previewWrapper) { + const admin = await import("@decocms/blocks-admin"); + if (options.meta) admin.setMetaData((await options.meta()) as never); + if (options.renderShell) admin.setRenderShell(options.renderShell); + if (options.previewWrapper) admin.setPreviewWrapper(options.previewWrapper); + } + + await options.extend?.(loadBlocks()); + })(); + return setupPromise; + }; +} From d393fe00804b4a0b738dd177f60027c5eedcf9a9 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 18:26:48 -0300 Subject: [PATCH 63/85] fix(nextjs): clear createNextSetup memo on rejected bootstrap setupPromise ??= (...) permanently cached a rejected first attempt, so one transient failure (fs blip during loadDecofileDirectory, flaky meta() fetch) would poison the warm serverless instance forever. Now the memo is cleared on rejection so the next ensureSetup() call retries, while the triggering call still rejects with the original error. Co-Authored-By: Claude Sonnet 5 --- packages/nextjs/src/setup.test.ts | 25 +++++++++++++++++++++++++ packages/nextjs/src/setup.ts | 16 +++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/setup.test.ts b/packages/nextjs/src/setup.test.ts index 6e5d979..7dca7a6 100644 --- a/packages/nextjs/src/setup.test.ts +++ b/packages/nextjs/src/setup.test.ts @@ -52,4 +52,29 @@ describe("createNextSetup", () => { const { isLayoutSection } = await import("@decocms/blocks/cms"); expect(isLayoutSection("site/sections/Footer.tsx")).toBe(true); }); + + it("clears the memo on a rejected bootstrap so the next call retries", async () => { + const meta = vi + .fn() + .mockRejectedValueOnce(new Error("transient fetch failure")) + .mockResolvedValueOnce({ + major: 1, + version: "test", + namespace: "site", + site: "test", + manifest: { blocks: { sections: {} } }, + schema: { definitions: {}, root: {} }, + platform: "test", + cloudProvider: "test", + }); + const ensureSetup = createNextSetup({ + blocksDir: false, + sections: { "./sections/Hero.tsx": async () => ({ default: () => null }) }, + meta, + }); + + await expect(ensureSetup()).rejects.toThrow("transient fetch failure"); + await expect(ensureSetup()).resolves.toBeUndefined(); + expect(meta).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/nextjs/src/setup.ts b/packages/nextjs/src/setup.ts index cedb02f..152dcca 100644 --- a/packages/nextjs/src/setup.ts +++ b/packages/nextjs/src/setup.ts @@ -71,6 +71,13 @@ export interface NextSetupOptions { extend?: (blocks: Record) => void | Promise; } +/** + * Returns a memoized `ensureSetup` function. A successful bootstrap is + * cached for the lifetime of the module (warm serverless instance); a + * *rejected* bootstrap is NOT cached — the memo is cleared on failure so + * the next call retries from scratch, while the triggering call still + * rejects with the original error. + */ export function createNextSetup(options: NextSetupOptions): () => Promise { let setupPromise: Promise | null = null; @@ -106,7 +113,14 @@ export function createNextSetup(options: NextSetupOptions): () => Promise } await options.extend?.(loadBlocks()); - })(); + })().catch((error) => { + // A failed bootstrap must not poison the warm instance: clear the memo + // so the next request retries (transient fs/fetch failures are the + // common case in serverless cold starts). The error still propagates + // to THIS caller so the triggering request fails loudly. + setupPromise = null; + throw error; + }); return setupPromise; }; } From 21a6f946089f4eff677ebd7541ed13d6ea2f9f91 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 18:31:50 -0300 Subject: [PATCH 64/85] feat(nextjs): createDecoRouteHandlers catch-all admin dispatcher Adds createDecoRouteHandlers() to routeHandlers.ts: a single GET/POST dispatcher for app/deco/[[...deco]]/route.ts that routes /deco/decofile, /deco/meta, /deco/render, /deco/invoke/*, and /deco/previews/* (rebuilt to the /live/previews/* prefix handleRender parses) to the existing blocks-admin handlers, running an optional setup() before each request and 404-ing unknown paths. Co-Authored-By: Claude Sonnet 5 --- packages/nextjs/src/routeHandlers.test.ts | 58 ++++++++++++++++++++++- packages/nextjs/src/routeHandlers.ts | 56 ++++++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/routeHandlers.test.ts b/packages/nextjs/src/routeHandlers.test.ts index 24cf19c..0122e61 100644 --- a/packages/nextjs/src/routeHandlers.test.ts +++ b/packages/nextjs/src/routeHandlers.test.ts @@ -1,6 +1,17 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + handleDecofileRead: vi.fn(async () => new Response("decofile")), + handleDecofileReload: vi.fn(async () => new Response("reloaded")), + handleInvoke: vi.fn(async () => new Response("invoked")), + handleMeta: vi.fn(() => new Response("meta")), + handleRender: vi.fn(async (req: Request) => new Response(new URL(req.url).pathname)), + setMetaData: vi.fn(), +})); +vi.mock("@decocms/blocks-admin", () => mocks); + import { setMetaData } from "@decocms/blocks-admin"; -import { metaGET } from "./routeHandlers"; +import { createDecoRouteHandlers, metaGET } from "./routeHandlers"; describe("routeHandlers (next)", () => { it("metaGET returns the schema response", async () => { @@ -9,3 +20,46 @@ describe("routeHandlers (next)", () => { expect(response.status).toBe(200); }); }); + +describe("createDecoRouteHandlers", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs setup before dispatching and routes decofile GET/POST", async () => { + const order: string[] = []; + const setup = vi.fn(async () => { + order.push("setup"); + }); + const { GET, POST } = createDecoRouteHandlers({ setup }); + + await GET(new Request("http://x/deco/decofile")); + expect(setup).toHaveBeenCalled(); + expect(mocks.handleDecofileRead).toHaveBeenCalled(); + + await POST(new Request("http://x/deco/decofile", { method: "POST" })); + expect(mocks.handleDecofileReload).toHaveBeenCalled(); + }); + + it("routes meta, render, and invoke", async () => { + const { GET, POST } = createDecoRouteHandlers(); + await GET(new Request("http://x/deco/meta")); + expect(mocks.handleMeta).toHaveBeenCalled(); + await POST(new Request("http://x/deco/render", { method: "POST" })); + expect(mocks.handleRender).toHaveBeenCalled(); + await POST(new Request("http://x/deco/invoke/site/actions/x", { method: "POST" })); + expect(mocks.handleInvoke).toHaveBeenCalled(); + }); + + it("rebuilds /deco/previews/* URLs to the /live/previews/* prefix handleRender parses", async () => { + const { GET } = createDecoRouteHandlers(); + const res = await GET(new Request("http://x/deco/previews/pages-Home-123?props=x")); + expect(await res.text()).toBe("/live/previews/pages-Home-123"); + const calledUrl = new URL(mocks.handleRender.mock.calls[0][0].url); + expect(calledUrl.searchParams.get("props")).toBe("x"); + }); + + it("404s unknown deco paths", async () => { + const { GET } = createDecoRouteHandlers(); + const res = await GET(new Request("http://x/deco/nope")); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/nextjs/src/routeHandlers.ts b/packages/nextjs/src/routeHandlers.ts index d001142..fbdb663 100644 --- a/packages/nextjs/src/routeHandlers.ts +++ b/packages/nextjs/src/routeHandlers.ts @@ -52,3 +52,59 @@ export async function renderGET(request: Request): Promise { export async function renderPOST(request: Request): Promise { return handleRender(request); } + +export interface DecoRouteHandlersOptions { + /** + * Site bootstrap, awaited before every admin request — pass the + * ensureSetup returned by createNextSetup (@decocms/nextjs/setup). + */ + setup?: () => Promise; +} + +/** + * Single catch-all dispatcher for the whole Studio admin protocol. Mount + * at `app/deco/[[...deco]]/route.ts` and wrap next.config with + * `withDeco()` (@decocms/nextjs/config), whose rewrites map the protocol + * URLs Next can't express as segments (`/.decofile`, `/live/_meta`, + * `/live/previews/*`) into `/deco/*`: + * + * ```ts + * import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers"; + * import { ensureSetup } from "../../../deco/setup"; + * export const dynamic = "force-dynamic"; + * export const { GET, POST } = createDecoRouteHandlers({ setup: ensureSetup }); + * ``` + */ +export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}): { + GET(request: Request): Promise; + POST(request: Request): Promise; +} { + async function dispatch(request: Request): Promise { + await options.setup?.(); + + const url = new URL(request.url); + const action = url.pathname.replace(/^\/deco\//, ""); + + if (action === "decofile") { + return request.method === "POST" ? handleDecofileReload(request) : handleDecofileRead(); + } + if (action === "meta") return handleMeta(request); + if (action === "render") return handleRender(request); + if (action.startsWith("invoke/")) return handleInvoke(request); + if (action === "previews" || action.startsWith("previews/")) { + // handleRender parses the literal "/live/previews/" prefix — rebuild + // the pre-rewrite URL (rewrites hand route handlers the DESTINATION + // path, so the prefix information is otherwise lost). + const rest = action === "previews" ? "" : action.slice("previews/".length); + const rebuilt = new URL(url); + rebuilt.pathname = `/live/previews/${rest}`; + return handleRender(new Request(rebuilt, request)); + } + return new Response(JSON.stringify({ error: `Unknown deco route: ${url.pathname}` }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + return { GET: dispatch, POST: dispatch }; +} From 8419f9418bb504e80ed94a4055830b9f9ad64400 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 18:40:53 -0300 Subject: [PATCH 65/85] fix(nextjs): restore POST-only gating on createDecoRouteHandlers' invoke branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catch-all dispatcher wired the same handler to GET and POST with no method check on invoke/*. handleInvoke has no auth and falls back to a ?props= query string for GET, so an unauthenticated tag on a third-party page could trigger mutating VTEX actions cross-site (GET is a CORS simple request, no preflight). The per-URL invokePOST export this dispatcher replaces was POST-only for exactly this reason — restore it, and 405 non-GET on meta (read-only schema endpoint) for symmetry. Also pins the previews-rebuild's `new Request(rebuilt, request)` body- forwarding semantics with a test, and documents why a plain-object init would throw "duplex option is required" on a future refactor. That test needs Node's native Request (jsdom's fetch polyfill drops the body on Request-as-init), so routeHandlers.test.ts now runs under @vitest-environment node, matching this package's setup.test.ts precedent. Co-Authored-By: Claude Sonnet 5 --- packages/nextjs/src/routeHandlers.test.ts | 48 +++++++++++++++++++++++ packages/nextjs/src/routeHandlers.ts | 39 +++++++++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/routeHandlers.test.ts b/packages/nextjs/src/routeHandlers.test.ts index 0122e61..170e27c 100644 --- a/packages/nextjs/src/routeHandlers.test.ts +++ b/packages/nextjs/src/routeHandlers.test.ts @@ -1,3 +1,13 @@ +// @vitest-environment node +// +// Route Handlers run server-side with no DOM (same rationale as +// setup.test.ts in this package). This matters concretely here: jsdom's +// bundled Request polyfill does not reproduce Node/edge's Request-as-init +// body-stream forwarding (`new Request(url, existingRequest)`) — under +// jsdom the body-forwarding test below silently loses the body. Node's +// native Request (used in this environment, and in the real Next.js +// runtime) preserves it, which is what the previews-rebuild code path +// relies on. import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -57,9 +67,47 @@ describe("createDecoRouteHandlers", () => { expect(calledUrl.searchParams.get("props")).toBe("x"); }); + it("forwards a POST body through the /deco/previews/* URL rebuild (Request-as-init carries the body stream)", async () => { + // Pins the `new Request(rebuilt, request)` semantics documented on the + // rebuild line: passing a Request as `init` clones the body stream + // without a `duplex` option. A refactor to a plain init object would + // throw "duplex option is required" for this POST body. + mocks.handleRender.mockImplementationOnce(async (req: Request) => { + const body = await req.json(); + return new Response(JSON.stringify({ pathname: new URL(req.url).pathname, body })); + }); + const { POST } = createDecoRouteHandlers(); + const res = await POST( + new Request("http://x/deco/previews/pages-Home-123", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hello: "world" }), + }), + ); + const json = await res.json(); + expect(json.pathname).toBe("/live/previews/pages-Home-123"); + expect(json.body).toEqual({ hello: "world" }); + }); + it("404s unknown deco paths", async () => { const { GET } = createDecoRouteHandlers(); const res = await GET(new Request("http://x/deco/nope")); expect(res.status).toBe(404); }); + + it("405s GET /deco/invoke/* with Allow: POST (CSRF protection — see comment on the invoke branch)", async () => { + const { GET } = createDecoRouteHandlers(); + const res = await GET(new Request("http://x/deco/invoke/site/actions/x")); + expect(res.status).toBe(405); + expect(res.headers.get("Allow")).toBe("POST"); + expect(mocks.handleInvoke).not.toHaveBeenCalled(); + }); + + it("405s POST /deco/meta with Allow: GET", async () => { + const { POST } = createDecoRouteHandlers(); + const res = await POST(new Request("http://x/deco/meta", { method: "POST" })); + expect(res.status).toBe(405); + expect(res.headers.get("Allow")).toBe("GET"); + expect(mocks.handleMeta).not.toHaveBeenCalled(); + }); }); diff --git a/packages/nextjs/src/routeHandlers.ts b/packages/nextjs/src/routeHandlers.ts index fbdb663..157bd68 100644 --- a/packages/nextjs/src/routeHandlers.ts +++ b/packages/nextjs/src/routeHandlers.ts @@ -88,9 +88,37 @@ export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}): if (action === "decofile") { return request.method === "POST" ? handleDecofileReload(request) : handleDecofileRead(); } - if (action === "meta") return handleMeta(request); + if (action === "meta") { + // GET only: this is a read-only schema endpoint, no legitimate POST use. + if (request.method !== "GET") { + return new Response(JSON.stringify({ error: "Method not allowed: meta is GET-only" }), { + status: 405, + headers: { "Content-Type": "application/json", Allow: "GET" }, + }); + } + return handleMeta(request); + } if (action === "render") return handleRender(request); - if (action.startsWith("invoke/")) return handleInvoke(request); + if (action.startsWith("invoke/")) { + // POST only: handleInvoke has no auth of its own and falls back to a + // `?props=` query string for GET requests. A GET is a CORS + // "simple request" (no preflight), so an unauthenticated + // `` + // on a third-party page would be able to trigger mutating VTEX + // actions cross-site (CSRF). The per-route `invokePOST` export this + // dispatcher replaces was POST-only for exactly this reason — keep + // that restriction here. + if (request.method !== "POST") { + return new Response( + JSON.stringify({ error: "Method not allowed: invoke is POST-only (CSRF protection)" }), + { + status: 405, + headers: { "Content-Type": "application/json", Allow: "POST" }, + }, + ); + } + return handleInvoke(request); + } if (action === "previews" || action.startsWith("previews/")) { // handleRender parses the literal "/live/previews/" prefix — rebuild // the pre-rewrite URL (rewrites hand route handlers the DESTINATION @@ -98,6 +126,13 @@ export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}): const rest = action === "previews" ? "" : action.slice("previews/".length); const rebuilt = new URL(url); rebuilt.pathname = `/live/previews/${rest}`; + // `request` (a Request) is passed as the `init` argument here, not a + // plain object — the Request-as-init form clones headers/method/body + // (including the body *stream*) without needing an explicit `duplex` + // option. Rewriting this as `new Request(rebuilt, { ...request })` or + // any plain-object init would throw "duplex option is required" for + // POST bodies with a body stream — see the test below that posts a + // JSON body through this branch and asserts it still parses. return handleRender(new Request(rebuilt, request)); } return new Response(JSON.stringify({ error: `Unknown deco route: ${url.pathname}` }), { From fd19501dbca736c4019898821d53860b7e9ffae1 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 18:47:36 -0300 Subject: [PATCH 66/85] feat(nextjs): withDeco next.config wrapper (Studio rewrites + transpilePackages) Adds packages/nextjs/src/config.cjs (CJS, requireable from a CJS next.config.js despite the package being "type": "module") exporting withDeco() and DECO_REWRITES, plus a src/config.d.cts declaration and a "./config" exports map entry. withDeco() prepends the three Studio rewrites (/.decofile, /live/_meta, /live/previews/:path*) that Task 6's dispatcher expects, ahead of any user rewrites in array or object form, and dedupes transpilePackages with the @decocms packages. Co-Authored-By: Claude Sonnet 5 --- packages/nextjs/package.json | 6 +++- packages/nextjs/src/config.cjs | 37 +++++++++++++++++++++++ packages/nextjs/src/config.d.cts | 4 +++ packages/nextjs/src/config.test.ts | 47 ++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/nextjs/src/config.cjs create mode 100644 packages/nextjs/src/config.d.cts create mode 100644 packages/nextjs/src/config.test.ts diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 8adc854..a7d0b8b 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -12,7 +12,11 @@ "exports": { ".": "./src/index.ts", "./routeHandlers": "./src/routeHandlers.ts", - "./setup": "./src/setup.ts" + "./setup": "./src/setup.ts", + "./config": { + "types": "./src/config.d.cts", + "default": "./src/config.cjs" + } }, "scripts": { "build": "tsc", diff --git a/packages/nextjs/src/config.cjs b/packages/nextjs/src/config.cjs new file mode 100644 index 0000000..56ee330 --- /dev/null +++ b/packages/nextjs/src/config.cjs @@ -0,0 +1,37 @@ +/** + * next.config wrapper for Deco sites. CommonJS on purpose: next.config.js + * is CJS in most sites and this package is "type": "module", so a .js + * file here would be ESM and unrequireable on Node < 22. + * + * Adds: + * 1. Rewrites for the Studio-protocol URLs Next cannot express as route + * segments — `/.decofile` (segments can't start with a dot) and + * `/live/_meta` (`_`-prefixed segments are Next "private folders", + * silently excluded from routing) — plus `/live/previews/*`, all + * funneled to `/deco/*` where a single catch-all route + * (`app/deco/[[...deco]]/route.ts` + createDecoRouteHandlers) serves + * the whole protocol. + * 2. transpilePackages for the raw-TS @decocms packages. + */ +const DECO_REWRITES = [ + { source: "/.decofile", destination: "/deco/decofile" }, + { source: "/live/_meta", destination: "/deco/meta" }, + { source: "/live/previews/:path*", destination: "/deco/previews/:path*" }, +]; + +const DECO_TRANSPILE = ["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"]; + +function withDeco(nextConfig = {}) { + const userRewrites = nextConfig.rewrites; + return { + ...nextConfig, + transpilePackages: [...new Set([...(nextConfig.transpilePackages ?? []), ...DECO_TRANSPILE])], + async rewrites() { + const user = typeof userRewrites === "function" ? await userRewrites() : (userRewrites ?? []); + if (Array.isArray(user)) return [...DECO_REWRITES, ...user]; + return { ...user, beforeFiles: [...DECO_REWRITES, ...(user.beforeFiles ?? [])] }; + }, + }; +} + +module.exports = { withDeco, DECO_REWRITES }; diff --git a/packages/nextjs/src/config.d.cts b/packages/nextjs/src/config.d.cts new file mode 100644 index 0000000..6985da5 --- /dev/null +++ b/packages/nextjs/src/config.d.cts @@ -0,0 +1,4 @@ +import type { NextConfig } from "next"; + +export declare const DECO_REWRITES: Array<{ source: string; destination: string }>; +export declare function withDeco(nextConfig?: NextConfig): NextConfig; diff --git a/packages/nextjs/src/config.test.ts b/packages/nextjs/src/config.test.ts new file mode 100644 index 0000000..57fb3d2 --- /dev/null +++ b/packages/nextjs/src/config.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { DECO_REWRITES, withDeco } from "./config.cjs"; + +// withDeco()'s declared return type is the standard (loosely typed) Next.js +// NextConfig, whose `rewrites`/`transpilePackages` are optional and +// union-typed. The runtime object always has them (withDeco always sets +// them), so tests assert that with `!` / casts rather than loosening the +// public declaration. +describe("withDeco", () => { + it("adds rewrites and transpilePackages to a bare config", async () => { + const cfg = withDeco({}); + expect(cfg.transpilePackages).toEqual( + expect.arrayContaining(["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"]), + ); + expect(await cfg.rewrites!()).toEqual(DECO_REWRITES); + }); + + it("prepends deco rewrites to a user's array-returning rewrites()", async () => { + const cfg = withDeco({ + rewrites: async () => [{ source: "/a", destination: "/b" }], + }); + const out = (await cfg.rewrites!()) as Array<{ source: string; destination: string }>; + expect(out.slice(0, DECO_REWRITES.length)).toEqual(DECO_REWRITES); + expect(out.at(-1)).toEqual({ source: "/a", destination: "/b" }); + }); + + it("merges into a user's object-form rewrites via beforeFiles", async () => { + const cfg = withDeco({ + rewrites: async () => ({ + beforeFiles: [{ source: "/x", destination: "/y" }], + afterFiles: [], + fallback: [], + }), + }); + const out = (await cfg.rewrites!()) as { + beforeFiles: Array<{ source: string; destination: string }>; + }; + expect(out.beforeFiles.slice(0, DECO_REWRITES.length)).toEqual(DECO_REWRITES); + expect(out.beforeFiles.at(-1)).toEqual({ source: "/x", destination: "/y" }); + }); + + it("dedupes transpilePackages", () => { + const cfg = withDeco({ transpilePackages: ["@decocms/blocks", "other"] }); + expect(cfg.transpilePackages!.filter((p: string) => p === "@decocms/blocks")).toHaveLength(1); + expect(cfg.transpilePackages).toContain("other"); + }); +}); From fed6996ca1835beb65c6a3a8c625be0e47fc38b1 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 19:03:20 -0300 Subject: [PATCH 67/85] feat(nextjs): migrate nextjs-smoke to withDeco/createDecoRouteHandlers/createNextSetup + README recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates examples/nextjs-smoke off its ad-hoc rewrites/route files onto the three new @decocms/nextjs surfaces built in Tasks 5-7 (withDeco, createDecoRouteHandlers, createNextSetup), and adds packages/nextjs/README.md as the copy-paste recipe a new Next.js site follows. While validating end-to-end against a real `next build`/`next dev`, found and fixed two real bugs surfaced by the migration (not just fixture wiring): - routeHandlers.ts's dispatch derived the requested action solely from a `/deco/`-prefixed pathname, on the assumption that a next.config rewrite hands route handlers the destination path. Verified empirically (dev and `next start`) that Next.js instead hands `request.url`/`nextUrl.pathname` as the ORIGINAL, pre-rewrite path — so every rewritten protocol URL (`/.decofile`, `/live/_meta`, `/live/previews/*`) 404'd. `resolveAction` now accepts both forms; added regression tests hitting the rewrite-source paths directly (the prior tests only ever constructed already-/deco/* Requests, so they'd have kept passing against the broken dispatcher). - Picture.tsx (reachable from DecoRootLayout/SectionRenderer via the hooks/index.ts barrel) imports createContext/useContext without "use client", which crashes Next's react-server module-graph check for any page that imports the root barrel. Added "use client", same precedent as SectionErrorFallback.tsx's existing fix for the same class of issue. Also dropped a stray console.log left in blocks-admin's handleRender. Verification: `examples/nextjs-smoke` builds clean; dev server curls of /.decofile, /live/_meta, and / (CMS-resolved Hero content) all 200; repo-wide `bun run typecheck && bun run test` green across all packages. Co-Authored-By: Claude Sonnet 5 --- examples/nextjs-smoke/next.config.ts | 25 +- .../src/app/deco-decofile/route.ts | 1 - .../src/app/deco/[[...deco]]/route.ts | 6 + examples/nextjs-smoke/src/app/layout.tsx | 14 +- .../nextjs-smoke/src/app/live/meta/route.ts | 1 - examples/nextjs-smoke/src/setup.ts | 25 +- packages/blocks-admin/src/admin/render.ts | 1 - packages/blocks/src/hooks/Picture.tsx | 25 +- packages/nextjs/README.md | 246 ++++++++++++++++++ packages/nextjs/src/routeHandlers.test.ts | 32 +++ packages/nextjs/src/routeHandlers.ts | 37 ++- 11 files changed, 363 insertions(+), 50 deletions(-) delete mode 100644 examples/nextjs-smoke/src/app/deco-decofile/route.ts create mode 100644 examples/nextjs-smoke/src/app/deco/[[...deco]]/route.ts delete mode 100644 examples/nextjs-smoke/src/app/live/meta/route.ts create mode 100644 packages/nextjs/README.md diff --git a/examples/nextjs-smoke/next.config.ts b/examples/nextjs-smoke/next.config.ts index 83bb736..22d1ac7 100644 --- a/examples/nextjs-smoke/next.config.ts +++ b/examples/nextjs-smoke/next.config.ts @@ -1,25 +1,6 @@ import type { NextConfig } from "next"; +import { withDeco } from "@decocms/nextjs/config"; -// Two Next.js route-segment quirks the brief's file list didn't anticipate, -// both worked around the same way — a real segment name plus a rewrite back -// to the protocol path the admin actually calls: -// -// 1. Segments can't start with a dot — `/.decofile` is served from -// `app/deco-decofile/route.ts`. -// 2. A segment prefixed with `_` (`_meta`) is a Next.js "private folder" -// (https://nextjs.org/docs/app/getting-started/project-structure#private-folders) -// and is excluded from routing entirely — confirmed empirically: building -// the fixture with `app/live/_meta/route.ts` produced no `/live/_meta` -// entry anywhere in `.next/app-path-routes-manifest.json` (silently -// dropped, no build warning). `/live/_meta` is served from -// `app/live/meta/route.ts` instead. -const nextConfig: NextConfig = { - async rewrites() { - return [ - { source: "/.decofile", destination: "/deco-decofile" }, - { source: "/live/_meta", destination: "/live/meta" }, - ]; - }, -}; +const nextConfig: NextConfig = {}; -export default nextConfig; +export default withDeco(nextConfig); diff --git a/examples/nextjs-smoke/src/app/deco-decofile/route.ts b/examples/nextjs-smoke/src/app/deco-decofile/route.ts deleted file mode 100644 index 712de3f..0000000 --- a/examples/nextjs-smoke/src/app/deco-decofile/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { decofileGET as GET, decofilePOST as POST } from "@decocms/nextjs"; diff --git a/examples/nextjs-smoke/src/app/deco/[[...deco]]/route.ts b/examples/nextjs-smoke/src/app/deco/[[...deco]]/route.ts new file mode 100644 index 0000000..30c671f --- /dev/null +++ b/examples/nextjs-smoke/src/app/deco/[[...deco]]/route.ts @@ -0,0 +1,6 @@ +import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers"; +import { ensureSetup } from "../../../setup"; + +export const dynamic = "force-dynamic"; + +export const { GET, POST } = createDecoRouteHandlers({ setup: ensureSetup }); diff --git a/examples/nextjs-smoke/src/app/layout.tsx b/examples/nextjs-smoke/src/app/layout.tsx index e601aba..55560b6 100644 --- a/examples/nextjs-smoke/src/app/layout.tsx +++ b/examples/nextjs-smoke/src/app/layout.tsx @@ -1,6 +1,16 @@ import { DecoRootLayout } from "@decocms/nextjs"; -import "../setup"; +import { ensureSetup } from "../setup"; -export default function RootLayout({ children }: { children: React.ReactNode }) { +// `createNextSetup` (unlike the old `createSiteSetup`/`createAdminSetup` +// pair this replaced) does NOT bootstrap the site as a module-load side +// effect — it returns a memoized `ensureSetup` that must be awaited before +// any CMS resolution runs. The admin catch-all route +// (`app/deco/[[...deco]]/route.ts`) awaits it itself via +// `createDecoRouteHandlers({ setup: ensureSetup })`, but the page-render +// path (`app/[[...slug]]/page.tsx` → `createDecoPage`) has no such hook, so +// the root layout — the one server component every page route shares — +// awaits it here instead. +export default async function RootLayout({ children }: { children: React.ReactNode }) { + await ensureSetup(); return {children}; } diff --git a/examples/nextjs-smoke/src/app/live/meta/route.ts b/examples/nextjs-smoke/src/app/live/meta/route.ts deleted file mode 100644 index c02a50b..0000000 --- a/examples/nextjs-smoke/src/app/live/meta/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { metaGET as GET } from "@decocms/nextjs"; diff --git a/examples/nextjs-smoke/src/setup.ts b/examples/nextjs-smoke/src/setup.ts index 58b1095..ff82af2 100644 --- a/examples/nextjs-smoke/src/setup.ts +++ b/examples/nextjs-smoke/src/setup.ts @@ -1,13 +1,13 @@ -import { createSiteSetup } from "@decocms/blocks/setup"; -import { createAdminSetup } from "@decocms/blocks-admin/setup"; +import { createNextSetup } from "@decocms/nextjs/setup"; // Unlike the Vite-based tanstack-smoke fixture (which passes // `import.meta.glob("./sections/**/*.tsx")` — a Vite-only construct not // available under Next's webpack build), this fixture builds the same // Vite-glob-shaped map (`"./sections/X.tsx" -> loader`) by hand. -// `createSiteSetup` strips the leading "./" and prefixes with "site/", so -// this resolves to the same `site/sections/Hero.tsx` registry key that the -// `pages-home` block below references via `__resolveType`. +// `createNextSetup` forwards this to `createSiteSetup`, which strips the +// leading "./" and prefixes with "site/", so this resolves to the same +// `site/sections/Hero.tsx` registry key that the `pages-home` block below +// references via `__resolveType`. // // `blocks` here is real content (not `{}`) on purpose: this fixture exists // to prove the whole CMS resolution pipeline — page match, section @@ -17,7 +17,15 @@ import { createAdminSetup } from "@decocms/blocks-admin/setup"; // packages/blocks/src/cms/loader.ts) and a section must be registered // through `registerSections` (the async-loader form) — `resolveDecoPage`'s // resolution pipeline does not read `registerSectionsSync`'s registry. -createSiteSetup({ +// +// `blocksDir: false` — this fixture has no `.deco/blocks` directory on +// disk; all block content is supplied inline via `blocks` below. +// `meta: () => Promise.resolve({})` wires a (trivial but present) admin +// meta schema so `/deco/meta` (and its `/live/_meta` rewrite alias) 200s +// instead of the "Schema not initialized" 503 `handleMeta` returns when no +// meta has been set. +export const ensureSetup = createNextSetup({ + blocksDir: false, sections: { "./sections/Hero.tsx": () => import("./sections/Hero"), }, @@ -27,9 +35,6 @@ createSiteSetup({ sections: [{ __resolveType: "site/sections/Hero.tsx", label: "next-smoke" }], }, }, -}); - -createAdminSetup({ meta: () => Promise.resolve({}), - css: "", + renderShell: { css: "" }, }); diff --git a/packages/blocks-admin/src/admin/render.ts b/packages/blocks-admin/src/admin/render.ts index 3ecda3f..869fed3 100644 --- a/packages/blocks-admin/src/admin/render.ts +++ b/packages/blocks-admin/src/admin/render.ts @@ -132,7 +132,6 @@ export async function handleRender(request: Request): Promise { const url = new URL(request.url); const resolveChain = url.searchParams.get("resolveChain"); const propsParam = url.searchParams.get("props"); - console.log("handleRender", request.url); const pathPrefix = "/live/previews/"; const rawPathComponent = url.pathname.startsWith(pathPrefix) diff --git a/packages/blocks/src/hooks/Picture.tsx b/packages/blocks/src/hooks/Picture.tsx index fb71155..2028519 100644 --- a/packages/blocks/src/hooks/Picture.tsx +++ b/packages/blocks/src/hooks/Picture.tsx @@ -1,3 +1,15 @@ +"use client"; + +// `"use client"` added by @decocms/nextjs's next-smoke fixture build +// (Picture is reachable from `DecoRootLayout`/`SectionRenderer` via the +// `hooks/index.ts` barrel, so it is part of the react-server module graph +// on any Next.js Server Component page even when a given page never +// renders it): `createContext`/`useContext` are unavailable under React's +// react-server condition, and Next statically flags any reachable module +// that imports them without this directive ("You're importing a component +// that needs createContext...") — same class of fix, same precedent, as +// `SectionErrorFallback.tsx`'s `"use client"` (class component / +// componentDidCatch, also client-only). import { type ComponentPropsWithoutRef, type Context, @@ -14,15 +26,10 @@ import { type FitOptions, getOptimizedMediaUrl, getSrcSet } from "./Image"; // so each source can inject its own with the correct // media query for responsive art direction. // -// Created LAZILY (first render), not at module scope: this file has no -// "use client" directive, so in a react-server graph (a Server Component -// page, or ANY Next.js route handler — which ignores "use client" -// entirely) the module evaluates against React's react-server build, where -// `createContext` does not exist. A module-scope call crashes the whole -// graph at import time ("createContext is not a function") even if -// Picture/Source never render. Deferring to render time is safe: renders -// only ever happen under a full React build (client or SSR), and both -// components resolve the same memoized context object. +// Still created LAZILY (first render, not module scope) even under +// `"use client"`: this keeps a single memoized context object regardless of +// how many client bundle chunks end up importing this module, rather than +// relying on module-instance identity across chunks. // ------------------------------------------------------------------------- interface PreloadContextValue { diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md new file mode 100644 index 0000000..9a50059 --- /dev/null +++ b/packages/nextjs/README.md @@ -0,0 +1,246 @@ +# `@decocms/nextjs` + +Deco framework binding for Next.js App Router — the Next.js sibling of +`@decocms/tanstack`. Three surfaces, composed together: + +- **`@decocms/nextjs/config`** — `withDeco(nextConfig)`, a `next.config` + wrapper that adds the rewrites and `transpilePackages` Deco's admin + protocol needs. +- **`@decocms/nextjs/routeHandlers`** — `createDecoRouteHandlers({ setup })`, + a single catch-all dispatcher for the whole Studio admin protocol + (decofile read/reload, meta schema, invoke, live previews). +- **`@decocms/nextjs/setup`** — `createNextSetup(options)`, a one-call site + bootstrap: the Next.js analogue of Vite's + `createSiteSetup` + `createAdminSetup` + `import.meta.glob`. + +This document is the complete recipe a new Next.js site follows to wire all +three together. Every code block below is meant to be copied, not +paraphrased. + +## Install + +```bash +bun add @decocms/nextjs @decocms/blocks @decocms/blocks-admin +``` + +`next`, `react`, and `react-dom` are peer dependencies — the site already +has them. + +## 1. `next.config` — `withDeco` + +`withDeco` adds three rewrites (mapping the Studio-protocol URLs Next.js +cannot express as route segments onto `/deco/*`, where the catch-all route +below serves them) and appends the three `@decocms/*` packages (which ship +raw TypeScript, not a prebuilt `dist/`) to `transpilePackages`. + +TypeScript (`next.config.ts`): + +```ts +import type { NextConfig } from "next"; +import { withDeco } from "@decocms/nextjs/config"; + +const nextConfig: NextConfig = { + // ...your own config +}; + +export default withDeco(nextConfig); +``` + +CommonJS (`next.config.js`) — most Next.js sites still use this form: + +```js +/** @type {import('next').NextConfig} */ +const { withDeco } = require("@decocms/nextjs/config"); + +const nextConfig = { + // ...your own config +}; + +module.exports = withDeco(nextConfig); +``` + +`withDeco` merges with a `rewrites`/`transpilePackages` you already have — +it never replaces them. If your own `rewrites()` returns the array form, +Deco's rewrites are prepended; if it returns the `{ beforeFiles, afterFiles, +fallback }` object form, Deco's rewrites are prepended to `beforeFiles`. + +## 2. The catch-all route + +Mount `createDecoRouteHandlers` at `app/deco/[[...deco]]/route.ts` — this +one file serves the entire admin protocol (decofile, meta, invoke, live +previews) for both the rewritten public URLs (`/.decofile`, `/live/_meta`, +`/live/previews/*`) and their `/deco/*` destinations directly. + +```ts +// src/app/deco/[[...deco]]/route.ts +import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers"; +import { ensureSetup } from "../../deco/setup"; + +export const dynamic = "force-dynamic"; + +export const { GET, POST } = createDecoRouteHandlers({ setup: ensureSetup }); +``` + +`dynamic = "force-dynamic"` is required — this route must never be +statically cached (decofile reloads, invoke calls, and previews all need a +fresh response per request). + +### Route-handler import rule: subpaths, never the root barrel + +**Always import from `@decocms/nextjs/routeHandlers` (or `/config`, +`/setup`) in a `route.ts` file. Never `import { ... } from "@decocms/nextjs"` +there.** + +App Router route handlers evaluate their entire module graph against +React's **react-server** build, and this happens regardless of any +`"use client"` directive in that graph — route handlers have no client +bundle to move a `"use client"` module into, so the directive is simply +ignored. The root barrel (`@decocms/nextjs`'s `.` export) re-exports the +render components (`DecoRootLayout`, `SectionRenderer`, `DecoPageRenderer`, +...), whose module graph reaches component code that uses client-only React +APIs (`createContext`, `useContext`, class components with +`componentDidCatch`, etc.) at module scope. Importing the root barrel from +a route file pulls that whole graph into react-server evaluation and +crashes at **import time**, before your handler ever runs, with errors like +`"...createContext is not a function"` or `"Class extends value undefined +is not a constructor"` — even if the route handler itself never touches +those components. The `/routeHandlers`, `/config`, and `/setup` subpaths +are each scoped to keep their own module graph free of component code, so +they're safe to import from anywhere, including a route file. + +## 3. `src/deco/setup.ts` — `createNextSetup` + +```ts +// src/deco/setup.ts +import { createNextSetup } from "@decocms/nextjs/setup"; +import { sectionImports, sectionMeta, syncComponents, loadingFallbacks } from "./sections.gen"; + +export const ensureSetup = createNextSetup({ + sections: sectionImports, + conventions: { meta: sectionMeta, syncComponents, loadingFallbacks }, + meta: () => import("./meta.gen.json").then((m) => m.default), +}); +``` + +`createNextSetup` returns a **memoized** `ensureSetup()` function — a +successful bootstrap is cached for the life of the warm serverless +instance; a *rejected* bootstrap clears the memo so the next call retries +from scratch (the triggering call still rejects with the original error). + +Two call sites need `ensureSetup()`: + +1. **The catch-all route** (above) passes it as `{ setup: ensureSetup }` — + `createDecoRouteHandlers` awaits it before dispatching every admin + request. +2. **The root layout** (`app/layout.tsx`) must await it directly before + rendering, since page rendering (`createDecoPage`'s resolver) has no + setup hook of its own: + + ```tsx + // src/app/layout.tsx + import { DecoRootLayout } from "@decocms/nextjs"; + import { ensureSetup } from "../deco/setup"; + + export default async function RootLayout({ children }: { children: React.ReactNode }) { + await ensureSetup(); + return {children}; + } + ``` + + (`app/layout.tsx` is a Server Component, not a route handler, so it's + fine to import the root barrel here — see the rule above.) + +### `NextSetupOptions` at a glance + +| Option | Purpose | +| --- | --- | +| `blocksDir` | Directory of decofile JSON snapshots (`.deco/blocks` by default). Pass `false` to skip filesystem loading entirely (e.g. when all content comes from `blocks`). | +| `blocks` | Extra/override blocks, merged **over** the directory's blocks. | +| `sections` | The lazy section registry — `sectionImports` from `generate-sections --registry` (see below). | +| `conventions` | `{ meta, syncComponents, loadingFallbacks }` from `sections.gen.ts` — wires the `export const sync/layout/seo/cache/eager/clientOnly` conventions (see below). | +| `meta` | Lazy admin meta schema loader: `() => import("./meta.gen.json").then(m => m.default)`. Wire this even with a trivial schema — without it, `/deco/meta` (and its `/live/_meta` alias) 503s with `"Schema not initialized"`. | +| `renderShell` | Admin preview shell config (`{ css, fonts }`). | +| `previewWrapper` | Admin preview wrapper component. | +| `productionOrigins`, `customMatchers`, `onResolveError`, `onDanglingReference` | Passed through to `createSiteSetup`. | +| `extend` | Site-specific wiring that must run *after* core setup (section loaders, legacy SEO key shims, curated post-processing). Receives the loaded blocks. | + +## 4. `package.json` scripts — non-colliding names + +Add two codegen scripts. **Do not name either of these `generate:schema`** — +FastStore sites already own that script name for their own commerce-schema +codegen, and a collision silently shadows one of the two generators +depending on script-merge order. Use these names instead: + +```json +{ + "scripts": { + "generate:deco-meta": "tsx node_modules/@decocms/blocks-cli/scripts/generate-schema.ts --out src/deco/meta.gen.json", + "generate:deco-sections": "tsx node_modules/@decocms/blocks-cli/scripts/generate-sections.ts --registry --out-file src/deco/sections.gen.ts" + } +} +``` + +- `generate:deco-meta` runs `blocks-cli`'s `generate-schema.ts`, which scans + `src/sections/`, `src/loaders/`, and `src/apps/` for `Props` interfaces + and emits the JSON Schema the admin's `/deco/meta` endpoint serves + (`meta.gen.json`). +- `generate:deco-sections` runs `generate-sections.ts` **with `--registry`** + — the flag that additionally emits `sectionImports`, the Next.js/webpack + equivalent of Vite's `import.meta.glob("./sections/**/*.tsx")` (Next has + no `import.meta.glob` or Vite plugin, so this is generated instead of + computed at build time). Without `--registry` you only get + `sectionMeta`/`syncComponents`/`loadingFallbacks`, not the lazy loader map + `setup.ts` needs for its `sections` option. + +Run both any time `src/sections/` changes (wire into a `predev`/`prebuild` +step, or a watch script, as your site prefers). + +## 5. `src/sections/` — the entry-file convention + +Every non-test `.tsx`/`.ts` file directly under `src/sections/` (recursively) +becomes a section key, keyed as `site/sections/`. +**This is not opt-in** — `generate-sections.ts` walks the whole directory +and turns every matching file into a registry entry, whether or not it +carries any convention exports. Files ending in `.test.ts(x)`, `.spec.ts(x)`, +`.stories.ts(x)`, or `.gen.ts(x)` are the only ones excluded. + +The established pattern is a **thin re-export entry file** per section, with +the actual component implementation living elsewhere (e.g. alongside its +own subcomponents, styles, and tests, outside `src/sections/`): + +```ts +// src/sections/Hero.tsx — thin entry file, becomes "site/sections/Hero.tsx" +export { default } from "../components/Hero/Hero"; +export type { HeroProps as Props } from "../components/Hero/Hero"; +``` + +This keeps `src/sections/` a clean, flat index of exactly what's +CMS-addressable, instead of a directory contest between "real" component +code and registry wiring. + +### Convention exports + +A section's entry file (or the file it re-exports from — the scanner reads +the entry file's own source, so re-exported `export const` conventions must +be re-declared or forwarded on the entry file itself, not just the +implementation file) can opt into these, each read as a literal +`export const = `: + +| Export | Effect | +| --- | --- | +| `export const sync = true` | Bundled synchronously (not lazy-loaded) — for above-the-fold, always-rendered sections. | +| `export const layout = true` | Cached as a layout section (Header, Footer, Theme) — resolved once, not per-page. | +| `export const seo = true` | SEO section — its resolved props are merged into page-level SEO. | +| `export const cache = "listing"` | SWR-cached section loader results, keyed by the given cache name. | +| `export const eager = true` | Prefers eager rendering (defers only past the fold threshold). | +| `export const neverDefer = true` | Always eager, ignoring the fold threshold entirely. | +| `export const clientOnly = true` | Skips SSR — client-only rendering. | +| `export function LoadingFallback` | Skeleton component shown while the section loads. | + +## Verifying your setup + +`examples/nextjs-smoke` in this monorepo is a minimal, real Next.js App +Router build exercising all three surfaces end to end — `withDeco` rewrites, +the catch-all route, `createNextSetup`, and a resolved page render. Use it +as a working reference if any of the above doesn't compose the way you +expect. diff --git a/packages/nextjs/src/routeHandlers.test.ts b/packages/nextjs/src/routeHandlers.test.ts index 170e27c..8eaf7f6 100644 --- a/packages/nextjs/src/routeHandlers.test.ts +++ b/packages/nextjs/src/routeHandlers.test.ts @@ -95,6 +95,38 @@ describe("createDecoRouteHandlers", () => { expect(res.status).toBe(404); }); + // Regression coverage for the rewrite-source paths, not just their + // /deco/* destinations: a Next.js App Router route handler reached via a + // next.config.js `rewrites()` entry sees `request.url` as the ORIGINAL, + // pre-rewrite path (verified empirically against a real `next build` + + // `next start`) — NOT the /deco/* destination the rewrite maps to. Every + // test above this one only ever constructs an already-/deco/*-shaped + // Request, so it would keep passing even if the dispatcher only matched + // that form and 404'd every real rewritten request — which is exactly + // the bug these tests catch. + it("routes the rewrite-source /.decofile path (not just its /deco/decofile destination)", async () => { + const { GET, POST } = createDecoRouteHandlers(); + await GET(new Request("http://x/.decofile")); + expect(mocks.handleDecofileRead).toHaveBeenCalled(); + await POST(new Request("http://x/.decofile", { method: "POST" })); + expect(mocks.handleDecofileReload).toHaveBeenCalled(); + }); + + it("routes the rewrite-source /live/_meta path (not just its /deco/meta destination)", async () => { + const { GET } = createDecoRouteHandlers(); + const res = await GET(new Request("http://x/live/_meta")); + expect(mocks.handleMeta).toHaveBeenCalled(); + expect(res.status).not.toBe(404); + }); + + it("routes the rewrite-source /live/previews/* path straight through to handleRender with the prefix intact", async () => { + const { GET } = createDecoRouteHandlers(); + const res = await GET(new Request("http://x/live/previews/pages-Home-123?props=x")); + expect(await res.text()).toBe("/live/previews/pages-Home-123"); + const calledUrl = new URL(mocks.handleRender.mock.calls[0][0].url); + expect(calledUrl.searchParams.get("props")).toBe("x"); + }); + it("405s GET /deco/invoke/* with Allow: POST (CSRF protection — see comment on the invoke branch)", async () => { const { GET } = createDecoRouteHandlers(); const res = await GET(new Request("http://x/deco/invoke/site/actions/x")); diff --git a/packages/nextjs/src/routeHandlers.ts b/packages/nextjs/src/routeHandlers.ts index 157bd68..5ccbcc8 100644 --- a/packages/nextjs/src/routeHandlers.ts +++ b/packages/nextjs/src/routeHandlers.ts @@ -74,7 +74,33 @@ export interface DecoRouteHandlersOptions { * export const dynamic = "force-dynamic"; * export const { GET, POST } = createDecoRouteHandlers({ setup: ensureSetup }); * ``` + * + * `resolveAction` accepts BOTH the rewrite's public source path (e.g. + * `/.decofile`, `/live/_meta`, `/live/previews/*`) AND the rewrite's own + * destination path (`/deco/*`) — not just the latter. This is load-bearing: + * verified empirically against a real `next build && next start` (both + * dev and production servers) that a Next.js App Router route handler + * reached via a `next.config.js`-level `rewrites()` entry sees + * `request.url` (and `NextRequest.nextUrl.pathname`) as the ORIGINAL, + * pre-rewrite path the client requested — rewrites are transparent to the + * client and, in this respect, to the handler too. Only a *direct* request + * to `/deco/*` (bypassing the rewrite, e.g. `/deco/invoke/*`, which has no + * public alias) arrives with a `/deco/`-prefixed pathname. Matching only + * the `/deco/*` form (as an earlier version of this dispatcher did) makes + * every rewritten protocol URL 404 — the dispatcher never sees a + * `/deco/decofile`-shaped path in that case, it sees `/.decofile` verbatim. */ +function resolveAction(pathname: string): string { + if (pathname.startsWith("/deco/")) return pathname.slice("/deco/".length); + if (pathname === "/.decofile") return "decofile"; + if (pathname === "/live/_meta") return "meta"; + if (pathname === "/live/previews") return "previews"; + if (pathname.startsWith("/live/previews/")) { + return `previews/${pathname.slice("/live/previews/".length)}`; + } + return pathname; +} + export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}): { GET(request: Request): Promise; POST(request: Request): Promise; @@ -83,7 +109,7 @@ export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}): await options.setup?.(); const url = new URL(request.url); - const action = url.pathname.replace(/^\/deco\//, ""); + const action = resolveAction(url.pathname); if (action === "decofile") { return request.method === "POST" ? handleDecofileReload(request) : handleDecofileRead(); @@ -120,9 +146,12 @@ export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}): return handleInvoke(request); } if (action === "previews" || action.startsWith("previews/")) { - // handleRender parses the literal "/live/previews/" prefix — rebuild - // the pre-rewrite URL (rewrites hand route handlers the DESTINATION - // path, so the prefix information is otherwise lost). + // handleRender parses the literal "/live/previews/" prefix. + // `resolveAction` above already normalizes BOTH a direct + // `/deco/previews/*` hit and a rewritten `/live/previews/*` hit down + // to this same `action` shape, so rebuilding unconditionally here is + // a no-op in the rewrite case (rebuilt === original) and the + // necessary reconstruction in the direct-hit case. const rest = action === "previews" ? "" : action.slice("previews/".length); const rebuilt = new URL(url); rebuilt.pathname = `/live/previews/${rest}`; From 9256fef29ab24e7704057515dc975bd397f82335 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 19:09:58 -0300 Subject: [PATCH 68/85] fix(nextjs): README route import depth + pin POST /live/_meta 405 Co-Authored-By: Claude Haiku 4.5 --- packages/nextjs/README.md | 2 +- packages/nextjs/src/routeHandlers.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index 9a50059..a1693dc 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -74,7 +74,7 @@ previews) for both the rewritten public URLs (`/.decofile`, `/live/_meta`, ```ts // src/app/deco/[[...deco]]/route.ts import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers"; -import { ensureSetup } from "../../deco/setup"; +import { ensureSetup } from "../../../deco/setup"; export const dynamic = "force-dynamic"; diff --git a/packages/nextjs/src/routeHandlers.test.ts b/packages/nextjs/src/routeHandlers.test.ts index 8eaf7f6..a7fa027 100644 --- a/packages/nextjs/src/routeHandlers.test.ts +++ b/packages/nextjs/src/routeHandlers.test.ts @@ -119,6 +119,14 @@ describe("createDecoRouteHandlers", () => { expect(res.status).not.toBe(404); }); + it("405s POST /live/_meta with Allow: GET (the PRE-rewrite URL form)", async () => { + const { POST } = createDecoRouteHandlers(); + const res = await POST(new Request("http://x/live/_meta", { method: "POST" })); + expect(res.status).toBe(405); + expect(res.headers.get("Allow")).toBe("GET"); + expect(mocks.handleMeta).not.toHaveBeenCalled(); + }); + it("routes the rewrite-source /live/previews/* path straight through to handleRender with the prefix intact", async () => { const { GET } = createDecoRouteHandlers(); const res = await GET(new Request("http://x/live/previews/pages-Home-123?props=x")); From 3443077f935a9ad9f3a38fa341d89b0576b018a6 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 19:23:40 -0300 Subject: [PATCH 69/85] fix(blocks-cli): registry doc comment contained **/ which truncated the block comment Co-Authored-By: Claude Sonnet 5 --- .../scripts/generate-sections.test.ts | 55 +++++++++++++++++++ .../blocks-cli/scripts/generate-sections.ts | 6 +- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/blocks-cli/scripts/generate-sections.test.ts b/packages/blocks-cli/scripts/generate-sections.test.ts index 9bdb93b..2536c0a 100644 --- a/packages/blocks-cli/scripts/generate-sections.test.ts +++ b/packages/blocks-cli/scripts/generate-sections.test.ts @@ -16,6 +16,7 @@ import * as cp from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; const SCRIPT = path.resolve(__dirname, "generate-sections.ts"); @@ -140,4 +141,58 @@ describe("generate-sections --registry", () => { const generated = fs.readFileSync(outFile, "utf-8"); expect(generated).not.toContain("sectionImports"); }); + + it("emits a doc comment that does not self-terminate early, and the resulting file is importable (regression: a literal `**/` inside the emitted /** */ comment used to close it prematurely, leaving prose as bare statements and making every --registry output invalid TypeScript)", () => { + const heroPath = path.join(sectionsDir, "Hero.tsx"); + fs.writeFileSync( + heroPath, + "export const sync = true\nexport default function Hero() { return null }\n", + ); + + const { code } = runGenerator([ + "--sections-dir", sectionsDir, + "--out-file", outFile, + "--registry", + ]); + expect(code).toBe(0); + + const generated = fs.readFileSync(outFile, "utf-8"); + + // Pin the regression directly: the doc comment's body (everything + // between the opening `/**` and its own closing `*/`) must contain + // exactly one `*/` — the intended closing marker itself, at the very + // end. A premature `*/` embedded in the prose (e.g. from a literal + // `**/*.tsx` glob pattern) would close the block comment early and + // leave the rest of the doc text as bare top-level statements. + const docCommentStart = generated.indexOf("/**\n * Lazy section registry"); + const exportStart = generated.indexOf("export const sectionImports"); + expect(docCommentStart).toBeGreaterThan(-1); + expect(exportStart).toBeGreaterThan(docCommentStart); + const docComment = generated.slice(docCommentStart + "/**".length, exportStart); + const closeMarkerCount = (docComment.match(/\*\//g) ?? []).length; + expect(closeMarkerCount).toBe(1); + expect(docComment.trimEnd().endsWith("*/")).toBe(true); + + // Strongest available check: actually import the generated file through + // tsx (esbuild) and confirm it parses/executes as valid TS/ESM and + // exports `sectionImports`. A premature `*/` would leave trailing prose + // as bare top-level statements, which fails to parse. `tsx -e` evaluates + // its argument as CommonJS (named exports of a dynamic `import()` come + // back CJS-interop-wrapped), so write a real `.mjs` file instead and run + // that — mirrors the check used to hand-verify this fix. + const checkerFile = path.join(tmpDir, "check-import.mjs"); + fs.writeFileSync( + checkerFile, + [ + `import { pathToFileURL } from "node:url";`, + `const m = await import(${JSON.stringify(pathToFileURL(outFile).href)});`, + `if (typeof m.sectionImports !== "object" || m.sectionImports === null) {`, + ` throw new Error("sectionImports missing or not an object");`, + `}`, + ].join("\n"), + ); + + const importResult = cp.spawnSync("npx", ["tsx", checkerFile], { encoding: "utf8" }); + expect(importResult.status, importResult.stderr).toBe(0); + }, 30_000); }); diff --git a/packages/blocks-cli/scripts/generate-sections.ts b/packages/blocks-cli/scripts/generate-sections.ts index 25ac41b..4c77a88 100644 --- a/packages/blocks-cli/scripts/generate-sections.ts +++ b/packages/blocks-cli/scripts/generate-sections.ts @@ -240,9 +240,9 @@ if (EMIT_REGISTRY) { lines.push(""); lines.push("/**"); lines.push(" * Lazy section registry — the Next.js/webpack equivalent of Vite's"); - lines.push(' * `import.meta.glob("./sections/**/*.tsx")`. Keys use the glob-style'); - lines.push(" * `./sections/...` form so this map drops straight into"); - lines.push(" * `createSiteSetup({ sections })` / `createNextSetup({ sections })`."); + lines.push(" * `import.meta.glob` scan over every file under ./sections, recursively."); + lines.push(" * Keys use the glob-style `./sections/...` form so this map drops"); + lines.push(" * straight into `createSiteSetup({ sections })` / `createNextSetup({ sections })`."); lines.push(" */"); lines.push("export const sectionImports: Record Promise> = {"); for (const filePath of sectionFiles) { From 0507d3741b86804f400a3b81f6168582cccb4ba2 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 20:04:52 -0300 Subject: [PATCH 70/85] =?UTF-8?q?ci:=20pin=20release=20npm=20to=2011.x=20?= =?UTF-8?q?=E2=80=94=20npm=2012.0.0=20ships=20a=20broken=20sigstore/proven?= =?UTF-8?q?ance=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm 12.0.0 (published 2026-07-08T21:06Z, ~1h before our release run) fails every 'npm publish' with "Cannot find module 'sigstore'" from libnpmpublish's provenance.js. Two release runs died on it; the morning's 7.3.1 release on the same runner image succeeded because @latest still resolved to 11.18.0 then. Pin the major; revisit after npm 12 stabilizes. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e461ddc..3a1cea6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,12 @@ jobs: # npm Trusted Publishing (OIDC) requires npm >=11.5.1 to perform the # OIDC token exchange. Node 22's bundled npm is older than that. - - run: npm install -g npm@latest + # Pinned to the 11.x major, NOT @latest: npm 12.0.0 (released + # 2026-07-08T21:06Z) ships with a broken libnpmpublish provenance path + # ("Cannot find module 'sigstore'"), which killed two release runs the + # evening it came out. Bump deliberately after verifying `npm publish` + # works on the new major. + - run: npm install -g npm@11 # main publishes the single legacy @decocms/start package (v*.*.* tags). # v7 publishes the 5 split @decocms/* packages under an entirely From a9a4e1d89a01c61b2c9678ce719b5ab4ff3f7e8d Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 21:01:14 -0300 Subject: [PATCH 71/85] =?UTF-8?q?docs:=20plan=20=E2=80=94=20generator=20de?= =?UTF-8?q?faults=20move=20into=20.deco/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-08-deco-folder-defaults.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-deco-folder-defaults.md diff --git a/docs/superpowers/plans/2026-07-08-deco-folder-defaults.md b/docs/superpowers/plans/2026-07-08-deco-folder-defaults.md new file mode 100644 index 0000000..16f306c --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-deco-folder-defaults.md @@ -0,0 +1,73 @@ +# Generator Defaults → `.deco/` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox syntax. + +**Goal:** blocks-cli generators default their outputs into `.deco/` (the framework's folder); release as 7.5.0; migrate the three tanstack sites and fila. + +**Architecture:** Flip four default paths in `packages/blocks-cli/scripts/` (generate-blocks, generate-loaders, generate-sections, generate-schema) with a loud legacy-artifact warning; `generate-invoke` deliberately stays at `src/server/invoke.gen.ts` (app server-function code — TanStack Start's compiler transforms its `createServerFn().handler()` calls; framework artifacts vs app code is the declared boundary). Update the scaffolding CLI (`migrate.ts`), skills under `.agents/skills/`, and `packages/nextjs/README.md`. Release (feat → 7.5.0, lockstep). Then per site: bump, `git mv` artifacts, repoint imports, regenerate, verify, push. + +**Tech Stack:** same as prior plan (bun monorepo, vitest; sites: vite/tanstack ×3 + Next/fila). + +## Global Constraints + +- User-approved BREAKING behavior change shipped as `feat:` (7.5.0) — v7 is the accepted breaking line; do NOT add a `BREAKING CHANGE` footer (it would trigger 8.0.0). +- New defaults: `generate-blocks` → `.deco/blocks.gen.ts` (and its sibling `.json` — read the script: the `.json` path may be derived or separate); `generate-loaders` → `.deco/loaders.gen.ts`; `generate-sections` → `.deco/sections.gen.ts`; `generate-schema` → `.deco/meta.gen.json`. `generate-invoke` default UNCHANGED (`src/server/invoke.gen.ts`) — document why in its header comment. +- Legacy guard in each flipped script: when NO explicit out flag was passed AND the old default file exists on disk, print a one-line stderr warning (old path found, new default, "move the file and update its importers") — then write to the NEW default anyway. +- Explicit flags always win, no warning when a flag is passed. +- Existing tests must stay green; add coverage for the flipped default + the warning where the script already has a test harness (generate-sections.test.ts / generate-schema tests); don't build new harnesses for scripts without one (generate-blocks has generate-blocks.test.ts — check what it covers). +- `.deco/` may not exist in a fresh site — every flipped script must `mkdirSync(dirname(outFile), { recursive: true })` (some already do; verify each). +- Do NOT edit `docs/superpowers/plans/2026-07-08-nextjs-glue-tier.md` (historical record). +- Monorepo gates per task: `bun run --filter='./packages/blocks-cli' test` + `typecheck`. +- Site gates: tanstack sites' known pre-existing typecheck baselines (~32/41/42 errors) must not grow; dev-boot smoke: `/` 200 + `/live/_meta` 200; fila: tsc clean, deco jest suites, `/.decofile` + `/live/_meta` 200. +- No `git clean`; never delete untracked files beyond explicit moves. + +--- + +### Task 1: blocks-cli — flip defaults + legacy warnings + scaffolding/doc updates + +**Files:** +- Modify: `packages/blocks-cli/scripts/generate-blocks.ts`, `generate-loaders.ts`, `generate-sections.ts`, `generate-schema.ts` (default path constants + legacy warning + mkdir check) +- Modify: `packages/blocks-cli/scripts/generate-invoke.ts` (header comment only: why it stays in src/) +- Modify: `packages/blocks-cli/scripts/migrate.ts` (the site scaffolder writes files at / references the old defaults — grep `src/server/cms`, `src/server/admin` and update every scaffolded path + template import statements it emits) +- Modify: `packages/nextjs/README.md` (recipe: scripts no longer need `--out`/`--out-file` flags; artifact paths in prose/examples → `.deco/…`; consumers import via a `deco/*` tsconfig path alias → show adding `"deco/*": [".deco/*"]` to paths) +- Modify: `examples/nextjs-smoke` + `examples/tanstack-smoke` if they generate or import any of the four artifacts (grep first; update paths + regenerate if so) +- Test: extend `generate-sections.test.ts` + generate-schema tests + +**Steps:** +- [ ] Write failing tests: (a) generate-sections with no `--out-file` writes `.deco/sections.gen.ts` in the fixture dir; (b) when the fixture pre-seeds `src/server/cms/sections.gen.ts` and no flag is passed, stderr contains a legacy warning naming both paths and the new file is still written. Mirror for generate-schema if its test harness supports subprocess fixtures (it may be unit-only — then cover schema via one subprocess case following generate-sections.test.ts's pattern). +- [ ] Implement: shared helper `warnLegacyArtifact(oldPath, newPath)` in `scripts/lib/` (one-line stderr), flip the four defaults, ensure mkdir-recursive before write in each. +- [ ] generate-blocks: read the script first — it emits BOTH `blocks.gen.ts` and `blocks.gen.json` (tanstack sites have both); make both land in `.deco/` and keep their relative sibling import intact. +- [ ] Update migrate.ts scaffolding + skills references are Task 2's; here update only in-package docs (script header comments, README). +- [ ] Gates: `bun run --filter='./packages/blocks-cli' test && typecheck`; also `bun run --filter='./packages/nextjs' test` (README-only, but cheap). +- [ ] Commit: `feat(blocks-cli): generator outputs default into .deco/ (framework artifacts live in the framework's folder)` — body documents the four flips, the invoke exception + rationale, and the legacy warning. + +### Task 2: deco-start skills + remaining docs + +**Files:** every hit of `grep -rln "src/server/cms\|src/server/admin" .agents docs --include="*.md"` EXCEPT the two historical plan files under docs/superpowers/plans/. Known: `.agents/skills/deco-to-tanstack-migration/references/{search.md,platform-hooks-factories.md,platform-hooks/README.md,server-functions/README.md}`, `.agents/skills/deco-migrate-script/SKILL.md`. + +**Steps:** +- [ ] For each file: update artifact paths to `.deco/…` where they refer to generate-blocks/loaders/sections/schema outputs; `src/server/invoke.gen.ts` references stay (unchanged default) — read surrounding context, don't blind-replace. +- [ ] Re-grep to confirm only historical plans + invoke references remain. +- [ ] Commit: `docs(skills): generated-artifact paths moved to .deco/`. + +### Task 3: release 7.5.0 + +- [ ] `bun run typecheck && bun run test` (monorepo) green; push v7; monitor `gh run list --repo decocms/blocks --branch v7`; on success verify `npm view @decocms/blocks-cli@7.5.0 version` + spot-check 3 more packages. (Recovery notes if the run fails: delete any orphaned `blocks-v7.5.0` tag before rerunning; npm is pinned to 11.x in release.yml — don't unpin.) + +### Task 4: migrate the three tanstack sites (parallel-safe: separate repos) + +Per site (`~/code/baggagio-tanstack`, `~/code/casaevideo-tanstack`, `~/code/lebiscuit-tanstack`) — each may have UNCOMMITTED @decocms bump changes in its tree from earlier sessions (package.json/bun.lock at ^7.3.1/^7.4.0): fold them into this commit. + +- [ ] `bun update ` to ^7.5.0; verify `node_modules/@decocms/blocks-cli/package.json` says 7.5.0. +- [ ] `git mv src/server/cms/{blocks.gen.json,blocks.gen.ts,loaders.gen.ts,sections.gen.ts} .deco/ && git mv src/server/admin/meta.gen.json .deco/` (paths per site — verify with `find src -name "*.gen.*"` first; `site-globals.gen.ts` and `invoke.gen.ts` and `routeTree.gen.ts` STAY). +- [ ] Repoint importers (grep `blocks.gen\|loaders.gen\|sections.gen\|meta.gen` in src/): `src/setup.ts` (three imports), `src/setup/commerce-loaders.ts` (loaders.gen). These sites have no `deco/*` alias — use relative imports (`../.deco/sections.gen` from src/setup.ts; adjust depth per file) OR add the `"deco/*": [".deco/*"]` tsconfig paths alias and use it if vite/vitest resolve tsconfig paths in that site (check for vite-tsconfig-paths or existing paths usage; pick whichever pattern the site already supports, relative is the safe default). +- [ ] Regenerate via the site's own chain (`bun run build` runs generate:*): confirm the generators now write the `.deco/` copies and no stale `src/server/` artifacts reappear (delete any regenerated strays ONLY if the generator wrote them due to explicit flags in the site's package.json scripts — if the site's scripts pass explicit old-path flags, DELETE THE FLAGS to ride the new defaults). +- [ ] Gates: typecheck error count ≤ baseline (32/41/42); dev boot on an isolated port → `/` 200 + `/live/_meta` 200 with real JSON; build completes (lebiscuit + casaevideo have a KNOWN pre-existing `cookiePassthrough.ts` client-bundle build failure — reproduce-on-base rule applies: only require build success where it succeeded at 7.4.0). +- [ ] Commit (fold any pre-existing uncommitted bump changes; message `refactor(deco): gen artifacts in .deco/, bump @decocms/* to 7.5.0`) and push to each site's default branch upstream (check branch + remote state first; if a site's tree has OTHER unrelated uncommitted changes beyond the @decocms bumps, commit only the migration+bump files and report the leftovers). + +### Task 5: fila cleanup + final verification + +- [ ] `~/code/faststore-fila`: bump @decocms/* to ^7.5.0 (`bun update` + `yarn install`); DROP the now-redundant `--out-file .deco/sections.gen.ts` / `--out .deco/meta.gen.json` flags from the two scripts (defaults now match — keep `--registry`, `--namespace site --site fila --skip-apps`). +- [ ] Regenerate (`bun run generate`), confirm byte-stable artifacts (only regenerated-if-changed noise), gates: tsc clean, `bun jest src/sdk/deco/` 6/6, dev boot `/.decofile` + `/live/_meta` 200, `yarn build`. +- [ ] Commit + push (fetch first — this branch gets external pushes). +- [ ] Ledger + update the setup-reference artifact page (scripts section: flags gone). From 36ebf2a511392f0096b8f39792932e4762d16db0 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 21:11:04 -0300 Subject: [PATCH 72/85] feat(blocks-cli): generator outputs default into .deco/ (framework artifacts live in the framework's folder) Flips the default output path of four blocks-cli generators from src/server/{cms,admin}/ to .deco/, so generated framework artifacts live alongside .deco/blocks instead of scattered under app source: - generate-blocks -> .deco/blocks.gen.ts (+ sibling .deco/blocks.gen.json, derived from --out-file by extension swap) - generate-loaders -> .deco/loaders.gen.ts - generate-sections -> .deco/sections.gen.ts - generate-schema -> .deco/meta.gen.json generate-invoke is the one exception and keeps its default at src/server/invoke.gen.ts: unlike the other four (inert data/metadata snapshots the framework loads at runtime), it emits actual app server-function code that TanStack Start's build-time compiler transforms (createServerFn().handler() -> RPC stubs), so it belongs in src/ with the rest of the compiled app code. Documented inline. Legacy guard: when no explicit --out/--out-file flag is passed AND the OLD default file still exists on disk, each generator prints a one-line stderr warning (lib/legacyArtifact.ts) naming both paths and telling the caller to move the file and update its importers, then writes to the NEW default anyway. An explicit flag skips the guard entirely (deliberate choice, no warning). Every flipped generator mkdir -p's the new output directory before writing. Updated in the same commit (ships in the same lockstep release): - packages/tanstack/src/vite/plugin.js dev-mode watcher, which called generateBlocks()/generate-schema.ts relying on the old defaults - packages/blocks-cli/scripts/migrate.ts's scaffolding templates (setup.ts, commerce-loaders.ts, knip-config.ts) that imported from or referenced the old default paths - packages/nextjs README + setup.ts JSDoc: scripts no longer need --out/--out-file, and the recipe now imports generated artifacts through a `deco/*` tsconfig path alias -> .deco/* instead of a relative import, since src/deco/setup.ts isn't adjacent to the site-root .deco/ directory - packages/blocks-admin's "unknown handler" error message and packages/blocks/src/setup.ts JSDoc, which pointed at the old path examples/nextjs-smoke and examples/tanstack-smoke construct their setup inline and don't invoke any of these generators, so they needed no changes. Tests: extended generate-sections.test.ts and generate-schema.test.ts with subprocess fixtures covering the new default path, the legacy warning (both paths + guidance text on stderr, new default still written), and the explicit-flag no-warning case. Co-Authored-By: Claude Sonnet 5 --- packages/blocks-admin/src/admin/invoke.ts | 2 +- .../blocks-cli/scripts/generate-blocks.ts | 18 +++- .../blocks-cli/scripts/generate-invoke.ts | 10 ++ .../blocks-cli/scripts/generate-loaders.ts | 15 ++- .../scripts/generate-schema.test.ts | 98 ++++++++++++++++++- .../blocks-cli/scripts/generate-schema.ts | 15 ++- .../scripts/generate-sections.test.ts | 67 ++++++++++++- .../blocks-cli/scripts/generate-sections.ts | 15 ++- .../blocks-cli/scripts/lib/legacyArtifact.ts | 20 ++++ .../migrate/templates/commerce-loaders.ts | 2 +- .../scripts/migrate/templates/knip-config.ts | 1 - .../scripts/migrate/templates/setup.ts | 6 +- packages/blocks/src/setup.ts | 3 +- packages/nextjs/README.md | 40 ++++++-- packages/nextjs/src/setup.ts | 11 ++- packages/tanstack/src/vite/plugin.js | 8 +- 16 files changed, 298 insertions(+), 33 deletions(-) create mode 100644 packages/blocks-cli/scripts/lib/legacyArtifact.ts diff --git a/packages/blocks-admin/src/admin/invoke.ts b/packages/blocks-admin/src/admin/invoke.ts index 128ebd7..03ca300 100644 --- a/packages/blocks-admin/src/admin/invoke.ts +++ b/packages/blocks-admin/src/admin/invoke.ts @@ -200,7 +200,7 @@ function unknownHandlerMessage(key: string): string { `Unknown handler: ${key}. ` + `Check that the ${kind} exists in ${dir} (file path must match the key — ` + `keys like "site/loaders/foo" resolve to ${dir}foo.ts) and that it is ` + - `included in src/server/cms/loaders.gen.ts. Regenerate with \`${cliHint}\` ` + + `included in .deco/loaders.gen.ts. Regenerate with \`${cliHint}\` ` + `or wire it manually via ${setupHint} in setup.ts. If you previously ran ` + `the generator with --decofile-dir / --prune-by-decofile, drop the flag ` + `to register code-driven loaders as well.` diff --git a/packages/blocks-cli/scripts/generate-blocks.ts b/packages/blocks-cli/scripts/generate-blocks.ts index 12af2fa..8966489 100644 --- a/packages/blocks-cli/scripts/generate-blocks.ts +++ b/packages/blocks-cli/scripts/generate-blocks.ts @@ -14,7 +14,14 @@ * * Env / CLI: * --blocks-dir override input (default: .deco/blocks) - * --out-file override output (default: src/server/cms/blocks.gen.ts) + * --out-file override output (default: .deco/blocks.gen.ts, with a sibling + * .deco/blocks.gen.json — the .json path is always derived from + * --out-file by swapping its extension, so passing --out-file + * moves both artifacts together) + * + * If no `--out-file` is passed and the OLD default (src/server/cms/blocks.gen.ts) + * still exists on disk, a one-line legacy warning is printed to stderr and the + * NEW default is written anyway — see lib/legacyArtifact.ts. * * Programmatic: * import { generateBlocks } from "@decocms/blocks-cli/generate-blocks"; @@ -33,6 +40,7 @@ import { mergeCandidates, singleDecodeBlockName, } from "./lib/blocks-dedupe"; +import { warnLegacyArtifact } from "./lib/legacyArtifact"; const TS_STUB = [ "// Auto-generated — thin wrapper around blocks.gen.json.", @@ -291,7 +299,13 @@ if (isMainModule()) { }; const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks")); - const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/blocks.gen.ts")); + const OUT_FILE_EXPLICIT = args.includes("--out-file"); + const NEW_DEFAULT_OUT_FILE = ".deco/blocks.gen.ts"; + const OLD_DEFAULT_OUT_FILE = "src/server/cms/blocks.gen.ts"; + const outFile = path.resolve(process.cwd(), arg("out-file", NEW_DEFAULT_OUT_FILE)); + if (!OUT_FILE_EXPLICIT && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_FILE))) { + warnLegacyArtifact(OLD_DEFAULT_OUT_FILE, NEW_DEFAULT_OUT_FILE); + } generateBlocks({ blocksDir, outFile }).catch((err) => { console.error(err); diff --git a/packages/blocks-cli/scripts/generate-invoke.ts b/packages/blocks-cli/scripts/generate-invoke.ts index dc07b7e..86fc96c 100644 --- a/packages/blocks-cli/scripts/generate-invoke.ts +++ b/packages/blocks-cli/scripts/generate-invoke.ts @@ -11,6 +11,16 @@ * This script generates an equivalent file where each server function is a * top-level const, which the compiler can correctly transform into RPC stubs. * + * Unlike the other four generators (generate-blocks, generate-loaders, + * generate-sections, generate-schema), this one's default output stays under + * `src/`, not `.deco/`. The other four emit inert framework artifacts (data + * snapshots / metadata) that the framework loads at runtime — those belong + * next to the rest of the framework's own generated state. This file emits + * actual app server-function code (`createServerFn().handler()` calls) that + * TanStack Start's own build-time compiler transforms into RPC stubs; it's + * compiled and shipped as part of the site's application code, so it lives + * in `src/` alongside the code it's compiled with, not in `.deco/`. + * * Usage (from site root): * npx tsx node_modules/@decocms/blocks-cli/scripts/generate-invoke.ts * diff --git a/packages/blocks-cli/scripts/generate-loaders.ts b/packages/blocks-cli/scripts/generate-loaders.ts index 6cafe3f..5ca5698 100644 --- a/packages/blocks-cli/scripts/generate-loaders.ts +++ b/packages/blocks-cli/scripts/generate-loaders.ts @@ -34,13 +34,18 @@ * CLI: * --loaders-dir override loaders input (default: src/loaders) * --actions-dir override actions input (default: src/actions) - * --out-file override output (default: src/server/cms/loaders.gen.ts) + * --out-file override output (default: .deco/loaders.gen.ts) * --exclude comma-separated list of loader keys to skip (they have custom wiring) * --prune-by-decofile only emit entries whose key appears as `__resolveType` in any JSON * --decofile-dir @deprecated alias for --prune-by-decofile + * + * If no `--out-file` is passed and the OLD default (src/server/cms/loaders.gen.ts) + * still exists on disk, a one-line legacy warning is printed to stderr and the + * NEW default is written anyway — see lib/legacyArtifact.ts. */ import fs from "node:fs"; import path from "node:path"; +import { warnLegacyArtifact } from "./lib/legacyArtifact"; const args = process.argv.slice(2); function arg(name: string, fallback: string): string { @@ -50,7 +55,13 @@ function arg(name: string, fallback: string): string { const loadersDir = path.resolve(process.cwd(), arg("loaders-dir", "src/loaders")); const actionsDir = path.resolve(process.cwd(), arg("actions-dir", "src/actions")); -const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/loaders.gen.ts")); +const OUT_FILE_EXPLICIT = args.includes("--out-file"); +const NEW_DEFAULT_OUT_FILE = ".deco/loaders.gen.ts"; +const OLD_DEFAULT_OUT_FILE = "src/server/cms/loaders.gen.ts"; +const outFile = path.resolve(process.cwd(), arg("out-file", NEW_DEFAULT_OUT_FILE)); +if (!OUT_FILE_EXPLICIT && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_FILE))) { + warnLegacyArtifact(OLD_DEFAULT_OUT_FILE, NEW_DEFAULT_OUT_FILE); +} const excludeRaw = arg("exclude", ""); const excludeSet = new Set(excludeRaw.split(",").map((s) => s.trim()).filter(Boolean)); diff --git a/packages/blocks-cli/scripts/generate-schema.test.ts b/packages/blocks-cli/scripts/generate-schema.test.ts index 849204b..e0ac7c4 100644 --- a/packages/blocks-cli/scripts/generate-schema.test.ts +++ b/packages/blocks-cli/scripts/generate-schema.test.ts @@ -1,5 +1,9 @@ +import * as cp from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { Project } from "ts-morph"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { WIDGET_TYPE_FORMATS, applyWidgetFormat, @@ -95,3 +99,95 @@ describe("typeToJsonSchema with an unresolvable widget alias import", () => { }); }); }); + +// --------------------------------------------------------------------------- +// Default output path (.deco/) + legacy warning — subprocess, mirrors the +// pattern in generate-sections.test.ts. generate-schema.ts IS guarded by +// isMainModule(), but it's still driven as a subprocess here so the CLI's +// argv-parsed OUT_REL/legacy-check top-level code runs against a real cwd. +// --------------------------------------------------------------------------- + +const SCRIPT = path.resolve(__dirname, "generate-schema.ts"); + +function runGenerator( + args: string[], + opts: { cwd?: string } = {}, +): { stdout: string; stderr: string; code: number } { + const r = cp.spawnSync("npx", ["tsx", SCRIPT, ...args], { encoding: "utf8", cwd: opts.cwd }); + return { stdout: r.stdout || "", stderr: r.stderr || "", code: r.status ?? 0 }; +} + +describe("generate-schema default output path (.deco/)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-schema-defaults-")); + fs.writeFileSync( + path.join(tmpDir, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + target: "ES2020", + module: "ESNext", + moduleResolution: "Bundler", + jsx: "react-jsx", + skipLibCheck: true, + strict: true, + }, + }), + ); + const sectionsDir = path.join(tmpDir, "src", "sections"); + fs.mkdirSync(sectionsDir, { recursive: true }); + fs.writeFileSync( + path.join(sectionsDir, "Hero.tsx"), + [ + "export interface Props {", + " title: string;", + "}", + "export default function Hero(props: Props) { return null; }", + ].join("\n"), + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("writes to .deco/meta.gen.json when no --out flag is passed", () => { + const { code } = runGenerator(["--skip-apps"], { cwd: tmpDir }); + expect(code).toBe(0); + + const newDefault = path.join(tmpDir, ".deco", "meta.gen.json"); + expect(fs.existsSync(newDefault)).toBe(true); + const meta = JSON.parse(fs.readFileSync(newDefault, "utf-8")); + expect(meta.manifest.blocks.sections).toHaveProperty("site/sections/Hero.tsx"); + }); + + it("warns once to stderr naming both paths when the OLD default file exists and no --out is passed, but still writes the NEW default", () => { + const oldDefaultDir = path.join(tmpDir, "src", "server", "admin"); + fs.mkdirSync(oldDefaultDir, { recursive: true }); + fs.writeFileSync(path.join(oldDefaultDir, "meta.gen.json"), "{}"); + + const { code, stderr } = runGenerator(["--skip-apps"], { cwd: tmpDir }); + expect(code).toBe(0); + + expect(stderr).toContain("src/server/admin/meta.gen.json"); + expect(stderr).toContain(".deco/meta.gen.json"); + expect(stderr).toContain("Move the file and update its importers"); + + const newDefault = path.join(tmpDir, ".deco", "meta.gen.json"); + expect(fs.existsSync(newDefault)).toBe(true); + }); + + it("does not warn when an explicit --out is passed, even if the OLD default file exists", () => { + const oldDefaultDir = path.join(tmpDir, "src", "server", "admin"); + fs.mkdirSync(oldDefaultDir, { recursive: true }); + fs.writeFileSync(path.join(oldDefaultDir, "meta.gen.json"), "{}"); + + const explicitOut = path.join(tmpDir, "custom", "meta.gen.json"); + const { code, stderr } = runGenerator(["--skip-apps", "--out", explicitOut], { cwd: tmpDir }); + expect(code).toBe(0); + + expect(stderr).not.toContain("Generator default output moved"); + expect(fs.existsSync(explicitOut)).toBe(true); + }); +}); diff --git a/packages/blocks-cli/scripts/generate-schema.ts b/packages/blocks-cli/scripts/generate-schema.ts index 950742f..9b9c371 100644 --- a/packages/blocks-cli/scripts/generate-schema.ts +++ b/packages/blocks-cli/scripts/generate-schema.ts @@ -20,8 +20,12 @@ import { fileURLToPath } from "node:url"; * --loaders Loaders directory (default: "src/loaders") * --apps Apps directory (default: "src/apps") * --skip-apps Skip app schema generation - * --out Output file (default: "src/server/admin/meta.gen.json") + * --out Output file (default: ".deco/meta.gen.json") * --platform Platform name (default: "cloudflare") + * + * If no `--out` is passed and the OLD default (src/server/admin/meta.gen.json) + * still exists on disk, a one-line legacy warning is printed to stderr and the + * NEW default is written anyway — see lib/legacyArtifact.ts. */ import { type Symbol as MorphSymbol, @@ -32,6 +36,7 @@ import { type Type, } from "ts-morph"; import { isExcludedCodegenFile } from "./lib/codegenExclusions"; +import { warnLegacyArtifact } from "./lib/legacyArtifact"; // --------------------------------------------------------------------------- // CLI arg parsing @@ -49,8 +54,14 @@ const SECTIONS_REL = arg("sections", "src/sections"); const LOADERS_REL = arg("loaders", "src/loaders"); const APPS_REL = arg("apps", "src/apps"); const SKIP_APPS = argv.includes("--skip-apps"); -const OUT_REL = arg("out", "src/server/admin/meta.gen.json"); +const OUT_FILE_EXPLICIT = argv.includes("--out"); +const NEW_DEFAULT_OUT_REL = ".deco/meta.gen.json"; +const OLD_DEFAULT_OUT_REL = "src/server/admin/meta.gen.json"; +const OUT_REL = arg("out", NEW_DEFAULT_OUT_REL); const PLATFORM = arg("platform", "cloudflare"); +if (!OUT_FILE_EXPLICIT && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_REL))) { + warnLegacyArtifact(OLD_DEFAULT_OUT_REL, NEW_DEFAULT_OUT_REL); +} // --------------------------------------------------------------------------- // Interfaces diff --git a/packages/blocks-cli/scripts/generate-sections.test.ts b/packages/blocks-cli/scripts/generate-sections.test.ts index 2536c0a..0b1bf9d 100644 --- a/packages/blocks-cli/scripts/generate-sections.test.ts +++ b/packages/blocks-cli/scripts/generate-sections.test.ts @@ -21,8 +21,11 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; const SCRIPT = path.resolve(__dirname, "generate-sections.ts"); -function runGenerator(args: string[]): { stdout: string; stderr: string; code: number } { - const r = cp.spawnSync("npx", ["tsx", SCRIPT, ...args], { encoding: "utf8" }); +function runGenerator( + args: string[], + opts: { cwd?: string } = {}, +): { stdout: string; stderr: string; code: number } { + const r = cp.spawnSync("npx", ["tsx", SCRIPT, ...args], { encoding: "utf8", cwd: opts.cwd }); return { stdout: r.stdout || "", stderr: r.stderr || "", code: r.status ?? 0 }; } @@ -71,6 +74,66 @@ describe("generate-sections walkDir exclusions", () => { }); }); +describe("generate-sections default output path (.deco/)", () => { + let tmpDir: string; + let sectionsDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-defaults-")); + sectionsDir = path.join(tmpDir, "src", "sections"); + fs.mkdirSync(sectionsDir, { recursive: true }); + fs.writeFileSync( + path.join(sectionsDir, "Hero.tsx"), + "export const eager = true;\nexport default function Hero() { return null; }\n", + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("writes to .deco/sections.gen.ts when no --out-file flag is passed", () => { + const { code, stderr } = runGenerator([], { cwd: tmpDir }); + expect(code).toBe(0); + + const newDefault = path.join(tmpDir, ".deco", "sections.gen.ts"); + expect(fs.existsSync(newDefault)).toBe(true); + expect(fs.readFileSync(newDefault, "utf-8")).toContain("site/sections/Hero.tsx"); + // No legacy file present, so no warning is expected. + expect(stderr).not.toContain("Generator default output moved"); + }); + + it("warns once to stderr naming both paths when the OLD default file exists and no --out-file is passed, but still writes the NEW default", () => { + const oldDefaultDir = path.join(tmpDir, "src", "server", "cms"); + fs.mkdirSync(oldDefaultDir, { recursive: true }); + fs.writeFileSync(path.join(oldDefaultDir, "sections.gen.ts"), "// stale\n"); + + const { code, stderr } = runGenerator([], { cwd: tmpDir }); + expect(code).toBe(0); + + expect(stderr).toContain("src/server/cms/sections.gen.ts"); + expect(stderr).toContain(".deco/sections.gen.ts"); + expect(stderr).toContain("Move the file and update its importers"); + + const newDefault = path.join(tmpDir, ".deco", "sections.gen.ts"); + expect(fs.existsSync(newDefault)).toBe(true); + expect(fs.readFileSync(newDefault, "utf-8")).toContain("site/sections/Hero.tsx"); + }); + + it("does not warn when an explicit --out-file is passed, even if the OLD default file exists", () => { + const oldDefaultDir = path.join(tmpDir, "src", "server", "cms"); + fs.mkdirSync(oldDefaultDir, { recursive: true }); + fs.writeFileSync(path.join(oldDefaultDir, "sections.gen.ts"), "// stale\n"); + + const explicitOut = path.join(tmpDir, "custom", "sections.gen.ts"); + const { code, stderr } = runGenerator(["--out-file", explicitOut], { cwd: tmpDir }); + expect(code).toBe(0); + + expect(stderr).not.toContain("Generator default output moved"); + expect(fs.existsSync(explicitOut)).toBe(true); + }); +}); + describe("generate-sections --registry", () => { let tmpDir: string; let sectionsDir: string; diff --git a/packages/blocks-cli/scripts/generate-sections.ts b/packages/blocks-cli/scripts/generate-sections.ts index 4c77a88..f7c7e47 100644 --- a/packages/blocks-cli/scripts/generate-sections.ts +++ b/packages/blocks-cli/scripts/generate-sections.ts @@ -16,17 +16,22 @@ * * CLI: * --sections-dir override input (default: src/sections) - * --out-file override output (default: src/server/cms/sections.gen.ts) + * --out-file override output (default: .deco/sections.gen.ts) * --registry also emit `sectionImports` — a lazy section-import map * keyed glob-style (`./sections/...`), the Next.js/webpack * equivalent of Vite's `import.meta.glob("./sections/**\/*.tsx")`. * Built from every scanned section file, not just the ones * carrying convention exports. Off by default so existing * Vite sites regenerating sections.gen.ts in CI see zero diff. + * + * If no `--out-file` is passed and the OLD default (src/server/cms/sections.gen.ts) + * still exists on disk, a one-line legacy warning is printed to stderr and the + * NEW default is written anyway — see lib/legacyArtifact.ts. */ import fs from "node:fs"; import path from "node:path"; import { isExcludedCodegenFile } from "./lib/codegenExclusions"; +import { warnLegacyArtifact } from "./lib/legacyArtifact"; const args = process.argv.slice(2); function arg(name: string, fallback: string): string { @@ -35,7 +40,13 @@ function arg(name: string, fallback: string): string { } const sectionsDir = path.resolve(process.cwd(), arg("sections-dir", "src/sections")); -const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/sections.gen.ts")); +const OUT_FILE_EXPLICIT = args.includes("--out-file"); +const NEW_DEFAULT_OUT_FILE = ".deco/sections.gen.ts"; +const OLD_DEFAULT_OUT_FILE = "src/server/cms/sections.gen.ts"; +const outFile = path.resolve(process.cwd(), arg("out-file", NEW_DEFAULT_OUT_FILE)); +if (!OUT_FILE_EXPLICIT && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_FILE))) { + warnLegacyArtifact(OLD_DEFAULT_OUT_FILE, NEW_DEFAULT_OUT_FILE); +} const EMIT_REGISTRY = args.includes("--registry"); interface SectionMeta { diff --git a/packages/blocks-cli/scripts/lib/legacyArtifact.ts b/packages/blocks-cli/scripts/lib/legacyArtifact.ts new file mode 100644 index 0000000..42f3ac9 --- /dev/null +++ b/packages/blocks-cli/scripts/lib/legacyArtifact.ts @@ -0,0 +1,20 @@ +/** + * Generator default output paths flipped from `src/server/{cms,admin}/` to + * `.deco/` (framework artifacts live in the framework's folder, not mixed + * into app source). Sites that never pass an explicit `--out`/`--out-file` + * flag pick up the new default silently — except when the OLD default file + * is still sitting on disk, which almost always means something (an + * importer, a `.gitignore` entry, a stale CI cache check) still points at + * it. In that case we warn once, to stderr, and then write to the NEW + * default anyway: the artifact is regenerated code, so there's no reason to + * block the run — the warning is just a nudge to go clean up the stale file + * and its importers. + * + * An explicit flag means the caller made a deliberate choice about where + * output goes; it gets no warning and no guard. + */ +export function warnLegacyArtifact(oldPath: string, newPath: string): void { + console.warn( + `[deco] Generator default output moved: ${oldPath} -> ${newPath}. Move the file and update its importers.`, + ); +} diff --git a/packages/blocks-cli/scripts/migrate/templates/commerce-loaders.ts b/packages/blocks-cli/scripts/migrate/templates/commerce-loaders.ts index 4b7275d..b0565fc 100644 --- a/packages/blocks-cli/scripts/migrate/templates/commerce-loaders.ts +++ b/packages/blocks-cli/scripts/migrate/templates/commerce-loaders.ts @@ -64,7 +64,7 @@ export function generateCommerceLoaders(ctx: MigrationContext): string { } // Always import siteLoaders — the generate:loaders script creates this file - lines.push(`import { siteLoaders } from "../server/cms/loaders.gen";`); + lines.push(`import { siteLoaders } from "../../.deco/loaders.gen";`); lines.push(``); lines.push(`const DOMAIN_RE = /;\\s*domain=[^;]*/gi;`); diff --git a/packages/blocks-cli/scripts/migrate/templates/knip-config.ts b/packages/blocks-cli/scripts/migrate/templates/knip-config.ts index 3629c64..a741a5f 100644 --- a/packages/blocks-cli/scripts/migrate/templates/knip-config.ts +++ b/packages/blocks-cli/scripts/migrate/templates/knip-config.ts @@ -12,7 +12,6 @@ const config: KnipConfig = { project: ["src/**/*.{ts,tsx}"], ignore: [ "src/server/invoke.gen.ts", - "src/server/cms/blocks.gen.ts", "src/routeTree.gen.ts", ], ignoreDependencies: [ diff --git a/packages/blocks-cli/scripts/migrate/templates/setup.ts b/packages/blocks-cli/scripts/migrate/templates/setup.ts index 6827647..acf259e 100644 --- a/packages/blocks-cli/scripts/migrate/templates/setup.ts +++ b/packages/blocks-cli/scripts/migrate/templates/setup.ts @@ -99,8 +99,8 @@ import { setInvokeLoaders } from "@decocms/start/admin";${isVtex ? ` import { createInstrumentedFetch } from "@decocms/start/sdk/instrumentedFetch"; import { initVtexFromBlocks, setVtexFetch } from "@decocms/apps/vtex";` : ""}${hasLocationMatcher ? ` import { registerLocationMatcher } from "./matchers/location";` : ""} -import { blocks as generatedBlocks } from "./server/cms/blocks.gen"; -import { sectionMeta, syncComponents, loadingFallbacks } from "./server/cms/sections.gen"; +import { blocks as generatedBlocks } from "../.deco/blocks.gen"; +import { sectionMeta, syncComponents, loadingFallbacks } from "../.deco/sections.gen"; import { PreviewProviders } from "@decocms/start/hooks"; // @ts-ignore Vite ?url import import appCss from "./styles/app.css?url"; @@ -112,7 +112,7 @@ import "./setup/section-loaders"; createSiteSetup({ sections: import.meta.glob("./sections/**/*.tsx") as Record Promise>, blocks: generatedBlocks, - meta: () => import("./server/admin/meta.gen.json").then((m) => m.default), + meta: () => import("../.deco/meta.gen.json").then((m) => m.default), css: appCss, fonts: [${fontEntries}], productionOrigins: [ diff --git a/packages/blocks/src/setup.ts b/packages/blocks/src/setup.ts index f6233c4..5187105 100644 --- a/packages/blocks/src/setup.ts +++ b/packages/blocks/src/setup.ts @@ -35,7 +35,8 @@ export interface SiteSetupOptions { /** * Generated blocks object — import and pass directly: - * `import { blocks } from "./server/cms/blocks.gen";` + * `import { blocks } from "../.deco/blocks.gen";` (`.deco/blocks.gen.ts` + * is `generate-blocks.ts`'s default output location). */ blocks: Record; diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index a1693dc..e68f666 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -110,15 +110,33 @@ they're safe to import from anywhere, including a route file. ## 3. `src/deco/setup.ts` — `createNextSetup` +The codegen artifacts (`sections.gen.ts`, `meta.gen.json`) are generated into +`.deco/` at the site root — the same default `@decocms/blocks-cli`'s +generators use everywhere else (framework artifacts live in the framework's +folder, not scattered across `src/`). `src/deco/setup.ts` isn't adjacent to +`.deco/`, so import it through a `deco/*` tsconfig path alias instead of a +relative path: + +```json +// tsconfig.json +{ + "compilerOptions": { + "paths": { + "deco/*": [".deco/*"] + } + } +} +``` + ```ts // src/deco/setup.ts import { createNextSetup } from "@decocms/nextjs/setup"; -import { sectionImports, sectionMeta, syncComponents, loadingFallbacks } from "./sections.gen"; +import { sectionImports, sectionMeta, syncComponents, loadingFallbacks } from "deco/sections.gen"; export const ensureSetup = createNextSetup({ sections: sectionImports, conventions: { meta: sectionMeta, syncComponents, loadingFallbacks }, - meta: () => import("./meta.gen.json").then((m) => m.default), + meta: () => import("deco/meta.gen.json").then((m) => m.default), }); ``` @@ -158,7 +176,7 @@ Two call sites need `ensureSetup()`: | `blocks` | Extra/override blocks, merged **over** the directory's blocks. | | `sections` | The lazy section registry — `sectionImports` from `generate-sections --registry` (see below). | | `conventions` | `{ meta, syncComponents, loadingFallbacks }` from `sections.gen.ts` — wires the `export const sync/layout/seo/cache/eager/clientOnly` conventions (see below). | -| `meta` | Lazy admin meta schema loader: `() => import("./meta.gen.json").then(m => m.default)`. Wire this even with a trivial schema — without it, `/deco/meta` (and its `/live/_meta` alias) 503s with `"Schema not initialized"`. | +| `meta` | Lazy admin meta schema loader: `() => import("deco/meta.gen.json").then(m => m.default)`. Wire this even with a trivial schema — without it, `/deco/meta` (and its `/live/_meta` alias) 503s with `"Schema not initialized"`. | | `renderShell` | Admin preview shell config (`{ css, fonts }`). | | `previewWrapper` | Admin preview wrapper component. | | `productionOrigins`, `customMatchers`, `onResolveError`, `onDanglingReference` | Passed through to `createSiteSetup`. | @@ -174,23 +192,29 @@ depending on script-merge order. Use these names instead: ```json { "scripts": { - "generate:deco-meta": "tsx node_modules/@decocms/blocks-cli/scripts/generate-schema.ts --out src/deco/meta.gen.json", - "generate:deco-sections": "tsx node_modules/@decocms/blocks-cli/scripts/generate-sections.ts --registry --out-file src/deco/sections.gen.ts" + "generate:deco-meta": "tsx node_modules/@decocms/blocks-cli/scripts/generate-schema.ts", + "generate:deco-sections": "tsx node_modules/@decocms/blocks-cli/scripts/generate-sections.ts --registry" } } ``` +Neither script needs an `--out`/`--out-file` flag — both generators default +to `.deco/`, which is exactly where `src/deco/setup.ts` reads them from via +the `deco/*` path alias (see above). Only pass `--out`/`--out-file` if your +site has a reason to put the artifact somewhere else. + - `generate:deco-meta` runs `blocks-cli`'s `generate-schema.ts`, which scans `src/sections/`, `src/loaders/`, and `src/apps/` for `Props` interfaces - and emits the JSON Schema the admin's `/deco/meta` endpoint serves - (`meta.gen.json`). + and emits the JSON Schema the admin's `/deco/meta` endpoint serves, to + `.deco/meta.gen.json` by default. - `generate:deco-sections` runs `generate-sections.ts` **with `--registry`** — the flag that additionally emits `sectionImports`, the Next.js/webpack equivalent of Vite's `import.meta.glob("./sections/**/*.tsx")` (Next has no `import.meta.glob` or Vite plugin, so this is generated instead of computed at build time). Without `--registry` you only get `sectionMeta`/`syncComponents`/`loadingFallbacks`, not the lazy loader map - `setup.ts` needs for its `sections` option. + `setup.ts` needs for its `sections` option. Defaults to + `.deco/sections.gen.ts`. Run both any time `src/sections/` changes (wire into a `predev`/`prebuild` step, or a watch script, as your site prefers). diff --git a/packages/nextjs/src/setup.ts b/packages/nextjs/src/setup.ts index 152dcca..f1d0725 100644 --- a/packages/nextjs/src/setup.ts +++ b/packages/nextjs/src/setup.ts @@ -15,12 +15,15 @@ * @example site's `src/deco/setup.ts` * ```ts * import { createNextSetup } from "@decocms/nextjs/setup"; - * import { sectionImports, sectionMeta, syncComponents } from "./sections.gen"; + * // Generators default to `.deco/`; `deco/*` is a tsconfig path alias for + * // `.deco/*` (see the package README) since `src/deco/setup.ts` isn't + * // adjacent to the site-root `.deco/` directory. + * import { sectionImports, sectionMeta, syncComponents } from "deco/sections.gen"; * * export const ensureSetup = createNextSetup({ * sections: sectionImports, * conventions: { meta: sectionMeta, syncComponents }, - * meta: () => import("./meta.gen.json").then((m) => m.default), + * meta: () => import("deco/meta.gen.json").then((m) => m.default), * }); * ``` */ @@ -46,10 +49,10 @@ export interface NextSetupOptions { */ sections: Record Promise>; - /** `{ meta: sectionMeta, syncComponents, loadingFallbacks }` from sections.gen.ts. */ + /** `{ meta: sectionMeta, syncComponents, loadingFallbacks }` from sections.gen.ts (`.deco/sections.gen.ts` by default). */ conventions?: Omit; - /** Lazy admin meta schema: `() => import("./meta.gen.json").then(m => m.default)`. */ + /** Lazy admin meta schema: `() => import("deco/meta.gen.json").then(m => m.default)` (`.deco/meta.gen.json` by default). */ meta?: () => Promise; /** Admin preview shell (CSS/font URLs) — see blocks-admin setRenderShell. */ diff --git a/packages/tanstack/src/vite/plugin.js b/packages/tanstack/src/vite/plugin.js index ca43435..1fa7f8c 100644 --- a/packages/tanstack/src/vite/plugin.js +++ b/packages/tanstack/src/vite/plugin.js @@ -14,7 +14,7 @@ * — V8's JSON parser is 2-10x faster than the JS parser for large data. * * meta.gen handling: - * The admin schema bundle (`server/admin/meta.gen.json`) is server-only; + * The admin schema bundle (`.deco/meta.gen.json`) is server-only; * the client receives pre-resolved blocks via the SSR payload. Stubbing * it on the client cuts a typically-large module out of the browser bundle. * Match is done by substring on the import id, so any path style works. @@ -209,7 +209,7 @@ export function decoVitePlugin() { // below) so we don't depend on the consumer's TS loader. const cwd = process.cwd(); const blocksDir = path.resolve(cwd, ".deco/blocks"); - const outFile = path.resolve(cwd, "src/server/cms/blocks.gen.ts"); + const outFile = path.resolve(cwd, ".deco/blocks.gen.ts"); const jsonFile = outFile.replace(/\.ts$/, ".json"); // Lazily load the block generator module (generateBlocks for the cold-start @@ -376,8 +376,10 @@ export function decoVitePlugin() { // --- meta.gen.json auto-regeneration --- // When section/loader/app source files change (types, JSDoc, Props), // re-run generate-schema.ts so meta.gen.json stays in sync during dev. + // No --out is passed to the generator below, so it writes to its own + // default (.deco/meta.gen.json) — this constant must track that default. const schemaWatchDirs = ["src"]; - const schemaOutFile = path.resolve(cwd, "src/server/admin/meta.gen.json"); + const schemaOutFile = path.resolve(cwd, ".deco/meta.gen.json"); // Resolve the site name once from vite define or env. const definedSite = server.config.define?.["process.env.DECO_SITE_NAME"]; From 90d641b182d7bf02b0c970fc6d588d19307b1f1a Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 21:19:54 -0300 Subject: [PATCH 73/85] docs(skills): generated-artifact paths moved to .deco/ generate-blocks/loaders/sections/schema now default their output to .deco/ instead of src/server/{cms,admin}/ (commit 36ebf2a). Update the two migration-skill docs that hardcoded the old paths in troubleshooting commands and the setup.ts template's generated-artifact imports. generate-invoke's src/server/invoke.gen.ts default is unchanged and was left as-is everywhere it's referenced. Co-Authored-By: Claude Sonnet 5 --- .../skills/deco-to-tanstack-migration/references/search.md | 4 ++-- .../skills/deco-to-tanstack-migration/templates/setup-ts.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/skills/deco-to-tanstack-migration/references/search.md b/.agents/skills/deco-to-tanstack-migration/references/search.md index 34b3125..3885731 100644 --- a/.agents/skills/deco-to-tanstack-migration/references/search.md +++ b/.agents/skills/deco-to-tanstack-migration/references/search.md @@ -307,7 +307,7 @@ loader: async (ctx) => { ### 3. Does the CMS page exist? ```bash # Check blocks.gen.ts has the search page -grep '"path": "/s"' src/server/cms/blocks.gen.ts +grep '"path": "/s"' .deco/blocks.gen.ts # If missing, regenerate: npm run generate:blocks ``` @@ -396,4 +396,4 @@ TanStack Router's `search` is a plain `Record` — it **cannot r | `deco-start/src/cms/resolve.ts` | Framework | `__pageUrl`/`__pagePath` injection into loaders | | `apps-start/vtex/inline-loaders/productListingPage.ts` | Commerce | VTEX IS API call, URL param reading | | `.deco/blocks/pages-search-*.json` | CMS | Page definition for `/s` route | -| `src/server/cms/blocks.gen.ts` | Build | Compiled CMS blocks (must include search page) | +| `.deco/blocks.gen.ts` | Build | Compiled CMS blocks (must include search page) | diff --git a/.agents/skills/deco-to-tanstack-migration/templates/setup-ts.md b/.agents/skills/deco-to-tanstack-migration/templates/setup-ts.md index 4242b20..3100f51 100644 --- a/.agents/skills/deco-to-tanstack-migration/templates/setup-ts.md +++ b/.agents/skills/deco-to-tanstack-migration/templates/setup-ts.md @@ -7,8 +7,8 @@ Annotated template based on espacosmart-storefront (100+ sections, VTEX, async r // 1. CMS BLOCKS & META // ========================================================================== -import blocksJson from "./server/cms/blocks.gen.ts"; -import metaData from "./server/admin/meta.gen.json"; +import blocksJson from "../.deco/blocks.gen.ts"; +import metaData from "../.deco/meta.gen.json"; import { setBlocks } from "@decocms/start/cms/loader"; import { setMetaData, setInvokeLoaders, setRenderShell } from "@decocms/start/admin"; import { registerSections, registerSectionsSync, setResolvedComponent } from "@decocms/start/cms/registry"; From ca6f92666bb28c37bdfaf932648d493795b52102 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 21:24:21 -0300 Subject: [PATCH 74/85] test(blocks-cli): 30s timeouts on subprocess-driven generator tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spawnSync-tsx fixture tests take ~6s under full-suite parallel load, tripping vitest's 5s default — 2 flaky failures on whole-monorepo runs that never reproduced in isolated package runs. test: type on purpose — no release. Co-Authored-By: Claude Fable 5 --- packages/blocks-cli/scripts/generate-schema.test.ts | 8 ++++---- .../blocks-cli/scripts/generate-sections.test.ts | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/blocks-cli/scripts/generate-schema.test.ts b/packages/blocks-cli/scripts/generate-schema.test.ts index e0ac7c4..f1b590f 100644 --- a/packages/blocks-cli/scripts/generate-schema.test.ts +++ b/packages/blocks-cli/scripts/generate-schema.test.ts @@ -97,7 +97,7 @@ describe("typeToJsonSchema with an unresolvable widget alias import", () => { type: "string", format: "color", }); - }); + }, 30_000); }); // --------------------------------------------------------------------------- @@ -160,7 +160,7 @@ describe("generate-schema default output path (.deco/)", () => { expect(fs.existsSync(newDefault)).toBe(true); const meta = JSON.parse(fs.readFileSync(newDefault, "utf-8")); expect(meta.manifest.blocks.sections).toHaveProperty("site/sections/Hero.tsx"); - }); + }, 30_000); it("warns once to stderr naming both paths when the OLD default file exists and no --out is passed, but still writes the NEW default", () => { const oldDefaultDir = path.join(tmpDir, "src", "server", "admin"); @@ -176,7 +176,7 @@ describe("generate-schema default output path (.deco/)", () => { const newDefault = path.join(tmpDir, ".deco", "meta.gen.json"); expect(fs.existsSync(newDefault)).toBe(true); - }); + }, 30_000); it("does not warn when an explicit --out is passed, even if the OLD default file exists", () => { const oldDefaultDir = path.join(tmpDir, "src", "server", "admin"); @@ -189,5 +189,5 @@ describe("generate-schema default output path (.deco/)", () => { expect(stderr).not.toContain("Generator default output moved"); expect(fs.existsSync(explicitOut)).toBe(true); - }); + }, 30_000); }); diff --git a/packages/blocks-cli/scripts/generate-sections.test.ts b/packages/blocks-cli/scripts/generate-sections.test.ts index 0b1bf9d..3074cf5 100644 --- a/packages/blocks-cli/scripts/generate-sections.test.ts +++ b/packages/blocks-cli/scripts/generate-sections.test.ts @@ -71,7 +71,7 @@ describe("generate-sections walkDir exclusions", () => { expect(generated).not.toContain("Hero.test.tsx"); expect(generated).not.toContain("Hero.stories.tsx"); expect(generated).not.toContain("sections.gen.ts"); - }); + }, 30_000); }); describe("generate-sections default output path (.deco/)", () => { @@ -101,7 +101,7 @@ describe("generate-sections default output path (.deco/)", () => { expect(fs.readFileSync(newDefault, "utf-8")).toContain("site/sections/Hero.tsx"); // No legacy file present, so no warning is expected. expect(stderr).not.toContain("Generator default output moved"); - }); + }, 30_000); it("warns once to stderr naming both paths when the OLD default file exists and no --out-file is passed, but still writes the NEW default", () => { const oldDefaultDir = path.join(tmpDir, "src", "server", "cms"); @@ -118,7 +118,7 @@ describe("generate-sections default output path (.deco/)", () => { const newDefault = path.join(tmpDir, ".deco", "sections.gen.ts"); expect(fs.existsSync(newDefault)).toBe(true); expect(fs.readFileSync(newDefault, "utf-8")).toContain("site/sections/Hero.tsx"); - }); + }, 30_000); it("does not warn when an explicit --out-file is passed, even if the OLD default file exists", () => { const oldDefaultDir = path.join(tmpDir, "src", "server", "cms"); @@ -131,7 +131,7 @@ describe("generate-sections default output path (.deco/)", () => { expect(stderr).not.toContain("Generator default output moved"); expect(fs.existsSync(explicitOut)).toBe(true); - }); + }, 30_000); }); describe("generate-sections --registry", () => { @@ -186,7 +186,7 @@ describe("generate-sections --registry", () => { expect(generated).toContain( `"./sections/Nested/Promo.tsx": () => import("${expectedImportPath(promoPath)}")`, ); - }); + }, 30_000); it("does not emit sectionImports without the --registry flag", () => { fs.writeFileSync( @@ -203,7 +203,7 @@ describe("generate-sections --registry", () => { const generated = fs.readFileSync(outFile, "utf-8"); expect(generated).not.toContain("sectionImports"); - }); + }, 30_000); it("emits a doc comment that does not self-terminate early, and the resulting file is importable (regression: a literal `**/` inside the emitted /** */ comment used to close it prematurely, leaving prose as bare statements and making every --registry output invalid TypeScript)", () => { const heroPath = path.join(sectionsDir, "Hero.tsx"); From 827128df098093c358b7fab1986a4a1f63f07242 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 21:43:03 -0300 Subject: [PATCH 75/85] =?UTF-8?q?fix(blocks-cli):=20floor=20tsx=20at=20^4.?= =?UTF-8?q?22.5=20=E2=80=94=204.22.0-4.22.4=20breaks=20tsImport=20inside?= =?UTF-8?q?=20Vite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the long-standing non-fatal "[deco] blocks bootstrap failed: TypeError: generateBlocks is not a function" cold-start warning on lebiscuit-tanstack: tsx 4.22.0-4.22.4 has a loader-hook state bug (fixed upstream in 4.22.5, "isolate hook state per async module.register() registration"). Inside a Vite dev-server process, tsImport(@decocms/blocks-cli/generate-blocks) resolves the module correctly but returns an EMPTY namespace — no rejection, which is why every out-of-band reproduction (plain node, same parent URL, same tsx copy) passed while the in-process call failed. Sites whose lockfiles froze 4.21.0 (baggagio, casaevideo, fila) never saw it; lebiscuit's lockfile froze 4.22.0. Verified in both directions on lebiscuit: tsx 4.21.0 -> bootstrap succeeds; 4.22.0 -> empty namespace; 4.23.0 -> succeeds. blocks-cli's tsx range was ^4.19.0, so any fresh install could land in the broken window — floor it at ^4.22.5. The tanstack vite plugin's loadGenModule also now fails with an actionable message (naming the tsx window and the upgrade command) when it sees an empty namespace, instead of the bare "generateBlocks is not a function" that a site's own hoisted-broken-tsx lockfile pin would still surface. Co-Authored-By: Claude Fable 5 --- bun.lock | 62 +++++++++++++++++++++++++--- packages/blocks-cli/package.json | 2 +- packages/tanstack/src/vite/plugin.js | 16 +++++++ 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index d8e6e10..f06410a 100644 --- a/bun.lock +++ b/bun.lock @@ -278,7 +278,7 @@ "dependencies": { "@decocms/blocks": "workspace:*", "ts-morph": "^27.0.0", - "tsx": "^4.19.0", + "tsx": "^4.22.5", }, "devDependencies": { "knip": "^5.86.0", @@ -1089,8 +1089,6 @@ "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, ""], - "git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1373,8 +1371,6 @@ "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, ""], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": "bin/cli.mjs" }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="], @@ -1505,7 +1501,7 @@ "tslib": ["tslib@2.8.1", "", {}, ""], - "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, ""], + "tsx": ["tsx@4.23.0", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w=="], "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], @@ -1961,6 +1957,8 @@ "tempy/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "unplugin/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "vite/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, ""], @@ -2035,6 +2033,58 @@ "tanstack-smoke-fixture/vite/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, ""], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], diff --git a/packages/blocks-cli/package.json b/packages/blocks-cli/package.json index 91b66ee..47253d1 100644 --- a/packages/blocks-cli/package.json +++ b/packages/blocks-cli/package.json @@ -36,7 +36,7 @@ "dependencies": { "@decocms/blocks": "workspace:*", "ts-morph": "^27.0.0", - "tsx": "^4.19.0" + "tsx": "^4.22.5" }, "devDependencies": { "knip": "^5.86.0", diff --git a/packages/tanstack/src/vite/plugin.js b/packages/tanstack/src/vite/plugin.js index 1fa7f8c..8982e4c 100644 --- a/packages/tanstack/src/vite/plugin.js +++ b/packages/tanstack/src/vite/plugin.js @@ -224,6 +224,22 @@ export function decoVitePlugin() { tsImport("@decocms/blocks-cli/generate-blocks", import.meta.url), ) .then((mod) => { + if (typeof mod.generateBlocks !== "function") { + // tsx 4.22.0–4.22.4 has a loader-hook state bug (fixed upstream + // in 4.22.5, "isolate hook state per async module.register() + // registration"): inside a Vite dev-server process, tsImport + // resolves correctly but returns an EMPTY module namespace — + // no rejection, no missing-module error. blocks-cli floors its + // tsx dependency at ^4.22.5, but a site's own lockfile can pin + // a broken copy that hoists above it. Fail with an actionable + // message instead of the bare "generateBlocks is not a + // function" this used to surface as. + throw new Error( + "tsImport(@decocms/blocks-cli/generate-blocks) returned an empty module namespace. " + + "This is the tsx 4.22.0–4.22.4 loader-hook bug — check `node -e \"console.log(require('tsx/package.json').version)\"` " + + "and upgrade tsx to >=4.22.5 (e.g. `bun update tsx` or pin a newer tsx in devDependencies).", + ); + } genModule = mod; return mod; }); From 336b91e3c7dcd424a72b9b76cb3686df046cf9b9 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 22:09:49 -0300 Subject: [PATCH 76/85] docs(blocks-cli): record the empirical reason invoke.gen.ts stays in src/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke-tested moving it to .deco/ on a real site: TanStack Start's client stub transform handles the new path fine, but the server half can't resolve the split module back to an executable handler — every /_serverFn call 500s (unhandled, no stack) while the storefront and typecheck stay green. Reverted; same probe back under src/ returns 200 with a real VTEX orderForm. docs-only commit, no release. Co-Authored-By: Claude Fable 5 --- packages/blocks-cli/scripts/generate-invoke.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/blocks-cli/scripts/generate-invoke.ts b/packages/blocks-cli/scripts/generate-invoke.ts index 86fc96c..e51021f 100644 --- a/packages/blocks-cli/scripts/generate-invoke.ts +++ b/packages/blocks-cli/scripts/generate-invoke.ts @@ -21,6 +21,16 @@ * compiled and shipped as part of the site's application code, so it lives * in `src/` alongside the code it's compiled with, not in `.deco/`. * + * This is empirically load-bearing, not taste (tested 2026-07-08 on a real + * site): with the file moved to `.deco/invoke.gen.ts`, the CLIENT half of + * the Start compiler works fine (stubs generated, IDs re-derived from the + * new path), but the SERVER half cannot resolve the split module back to an + * executable handler — every `/_serverFn/...` call returns an unhandled 500 + * with no logged stack, i.e. every cart/session/MasterData action dies. + * Same site, file back under `src/`: identical RPC probe returns 200 with a + * real VTEX orderForm. Do not move this default without re-running that + * round-trip test end to end. + * * Usage (from site root): * npx tsx node_modules/@decocms/blocks-cli/scripts/generate-invoke.ts * From c4a2b62386b997d52ba8b6f9fa5a8c0cd52ffa13 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 22:46:57 -0300 Subject: [PATCH 77/85] test(blocks-admin): pin the decofile-reload auth gate's fail-closed side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reload endpoint is destructive (posted body fully replaces the in-memory registry) and its dev-bypass predicate already changed once (import.meta.env.DEV -> NODE_ENV). Five cases: no header, no token configured (fail-closed not fail-open), wrong token, NODE_ENV unset — all 401 with registry untouched — plus the correct-token 200 path. Co-Authored-By: Claude Fable 5 --- .../blocks-admin/src/admin/decofile.test.ts | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/blocks-admin/src/admin/decofile.test.ts b/packages/blocks-admin/src/admin/decofile.test.ts index 64ef825..b9d8957 100644 --- a/packages/blocks-admin/src/admin/decofile.test.ts +++ b/packages/blocks-admin/src/admin/decofile.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { KV_KEYS, type KVNamespace, getRevision, loadBlocks, setBlocks } from "@decocms/blocks/cms"; import { handleDecofileReload, setFastDeployKVGetter } from "./decofile"; @@ -154,3 +154,67 @@ describe("handleDecofileReload — validation", () => { expect(res.status).toBe(400); }); }); + +describe("handleDecofileReload — auth gate outside dev (fail-closed)", () => { + // These tests deliberately leave the file-level NODE_ENV=development + // override and simulate the production posture per-test: the reload + // endpoint is DESTRUCTIVE (a posted body fully replaces the in-memory + // registry), and the dev bypass predicate changed once already + // (import.meta.env.DEV -> NODE_ENV) — this suite pins the fail-closed + // side so the next predicate change can't silently open it. + const DEV_VALUE = process.env.NODE_ENV; + + function reloadWithAuth(authHeader: string | null, env?: Record) { + const headers: Record = { "Content-Type": "application/json" }; + if (authHeader !== null) headers.Authorization = authHeader; + const req = new Request("http://x/.decofile", { + method: "POST", + body: JSON.stringify({ Site: { name: "attacker" } }), + headers, + }); + return handleDecofileReload(req, env); + } + + beforeEach(() => { + process.env.NODE_ENV = "production"; + }); + afterEach(() => { + process.env.NODE_ENV = DEV_VALUE; + delete process.env.DECO_RELEASE_RELOAD_TOKEN; + }); + + it("401s with no Authorization header and leaves the registry untouched", async () => { + const res = await reloadWithAuth(null, {}); + expect(res.status).toBe(401); + expect((loadBlocks().Site as { name: string }).name).toBe("base"); + }); + + it("401s when NO reload token is configured at all (fail-closed, not fail-open)", async () => { + const res = await reloadWithAuth("anything", {}); + expect(res.status).toBe(401); + expect((loadBlocks().Site as { name: string }).name).toBe("base"); + }); + + it("401s on a wrong token and leaves the registry untouched", async () => { + const res = await reloadWithAuth("wrong-token", { + DECO_RELEASE_RELOAD_TOKEN: "right-token", + }); + expect(res.status).toBe(401); + expect((loadBlocks().Site as { name: string }).name).toBe("base"); + }); + + it("401s when NODE_ENV is unset (fail-closed by default)", async () => { + delete process.env.NODE_ENV; + const res = await reloadWithAuth(null, {}); + expect(res.status).toBe(401); + expect((loadBlocks().Site as { name: string }).name).toBe("base"); + }); + + it("accepts the correct token and performs the replacement", async () => { + const res = await reloadWithAuth("right-token", { + DECO_RELEASE_RELOAD_TOKEN: "right-token", + }); + expect(res.status).toBe(200); + expect((loadBlocks().Site as { name: string }).name).toBe("attacker"); + }); +}); From 8add5ed07899834114c3795b6e3b7c593078f781 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 23:15:10 -0300 Subject: [PATCH 78/85] fix(blocks-cli): scaffolder emitted pre-split @decocms/start and @decocms/apps imports The migrate/scaffold/post-cleanup pipeline still emitted (or recommended) @decocms/start and the @decocms/apps monolith, neither of which exist for 7.x consumers -- every newly scaffolded site and every deco-post-cleanup --fix run against an already-migrated site was writing code that doesn't resolve. Repoints every emitted import/dependency/npx-command at the current split packages (@decocms/blocks, @decocms/blocks-admin, @decocms/tanstack, @decocms/blocks-cli, @decocms/apps-vtex/-shopify/ -commerce/-magento), verified against each package's actual exports map. Also adds the missing @decocms/tanstack "./sdk/cookiePassthrough" export (the file existed but was never exported) and broadens two post-cleanup DETECT regexes to recognize both the old and new package names so audits of freshly-fixed sites don't false-negative. Co-Authored-By: Claude Sonnet 5 --- .../scripts/migrate-post-cleanup.ts | 16 +- packages/blocks-cli/scripts/migrate.ts | 22 +-- .../migrate/analyzers/loader-inventory.ts | 2 +- .../scripts/migrate/phase-analyze.ts | 6 +- .../scripts/migrate/phase-cleanup.ts | 52 ++++--- .../scripts/migrate/phase-report.ts | 14 +- .../scripts/migrate/phase-scaffold.ts | 8 +- .../scripts/migrate/phase-transform.ts | 2 +- .../scripts/migrate/phase-verify.ts | 11 +- .../scripts/migrate/post-cleanup/rules.ts | 99 ++++++------ .../migrate/post-cleanup/runner.test.ts | 92 +++++++---- .../scripts/migrate/templates/cache-config.ts | 8 +- .../migrate/templates/commerce-loaders.ts | 20 +-- .../migrate/templates/cursor-rules.test.ts | 6 + .../scripts/migrate/templates/cursor-rules.ts | 23 +-- .../scripts/migrate/templates/hooks.test.ts | 14 +- .../scripts/migrate/templates/hooks.ts | 20 +-- .../migrate/templates/lib-utils.test.ts | 4 +- .../scripts/migrate/templates/lib-utils.ts | 8 +- .../templates/no-legacy-packages.test.ts | 147 ++++++++++++++++++ .../scripts/migrate/templates/package-json.ts | 34 ++-- .../scripts/migrate/templates/routes.ts | 18 +-- .../scripts/migrate/templates/sdk-gen.ts | 2 +- .../migrate/templates/section-loaders.ts | 14 +- .../scripts/migrate/templates/server-entry.ts | 74 ++++----- .../scripts/migrate/templates/setup.ts | 12 +- .../scripts/migrate/templates/types-gen.ts | 4 +- .../migrate/templates/ui-components.ts | 4 +- .../scripts/migrate/templates/vite-config.ts | 15 +- .../scripts/migrate/transforms/fresh-apis.ts | 10 +- .../scripts/migrate/transforms/imports.ts | 92 ++++++----- .../migrate/transforms/section-conventions.ts | 2 +- packages/blocks-cli/scripts/migrate/types.ts | 2 +- packages/tanstack/package.json | 3 +- 34 files changed, 542 insertions(+), 318 deletions(-) create mode 100644 packages/blocks-cli/scripts/migrate/templates/no-legacy-packages.test.ts diff --git a/packages/blocks-cli/scripts/migrate-post-cleanup.ts b/packages/blocks-cli/scripts/migrate-post-cleanup.ts index 02d35c2..e5b65ab 100755 --- a/packages/blocks-cli/scripts/migrate-post-cleanup.ts +++ b/packages/blocks-cli/scripts/migrate-post-cleanup.ts @@ -8,8 +8,8 @@ * but turns it into something CI can actually run. * * Usage (from a migrated site directory): - * npx -p @decocms/start deco-post-cleanup - * npx -p @decocms/start deco-post-cleanup --json + * npx -p @decocms/blocks-cli deco-post-cleanup + * npx -p @decocms/blocks-cli deco-post-cleanup --json * * Options: * --source Site directory to audit (default: current directory) @@ -65,13 +65,13 @@ function parseArgs(args: string[]): CliOpts { function showHelp() { console.log(` - @decocms/start — Post-Migration Cleanup Audit + @decocms/blocks-cli — Post-Migration Cleanup Audit Scans a migrated site for dead code and obsolete boilerplate that the framework now owns. Read-only — prints findings, does not modify files. Usage: - npx -p @decocms/start deco-post-cleanup [options] + npx -p @decocms/blocks-cli deco-post-cleanup [options] Options: --source Site directory to audit (default: .) @@ -88,10 +88,10 @@ function showHelp() { --help, -h Show this help Examples: - npx -p @decocms/start deco-post-cleanup - npx -p @decocms/start deco-post-cleanup --source ./my-site --json - npx -p @decocms/start deco-post-cleanup --fix - npx -p @decocms/start deco-post-cleanup --fix --strict # fail CI if anything left + npx -p @decocms/blocks-cli deco-post-cleanup + npx -p @decocms/blocks-cli deco-post-cleanup --source ./my-site --json + npx -p @decocms/blocks-cli deco-post-cleanup --fix + npx -p @decocms/blocks-cli deco-post-cleanup --fix --strict # fail CI if anything left See: .agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md `); diff --git a/packages/blocks-cli/scripts/migrate.ts b/packages/blocks-cli/scripts/migrate.ts index 5a1709a..3b7aa3a 100755 --- a/packages/blocks-cli/scripts/migrate.ts +++ b/packages/blocks-cli/scripts/migrate.ts @@ -4,10 +4,10 @@ * Migration Script: Fresh/Deno/Preact → TanStack Start/React/Cloudflare Workers * * Converts a Deco storefront from the old Fresh/Deno stack to the new TanStack Start stack. - * Part of the @decocms/start framework — run from a site's root directory. + * Ships as part of @decocms/blocks-cli — run from a site's root directory. * * Usage (from your Fresh site directory): - * npx -p @decocms/start deco-migrate [options] + * npx -p @decocms/blocks-cli deco-migrate [options] * * Options: * --source Source directory (default: current directory) @@ -102,10 +102,10 @@ function parseArgs(args: string[]): { function showHelp() { console.log(` - @decocms/start — Migration Script: Fresh/Deno → TanStack Start + @decocms/blocks-cli — Migration Script: Fresh/Deno → TanStack Start Usage: - npx -p @decocms/start deco-migrate [options] + npx -p @decocms/blocks-cli deco-migrate [options] Options: --source Source directory (default: .) @@ -119,10 +119,10 @@ function showHelp() { --help, -h Show this help message Examples: - npx -p @decocms/start deco-migrate --dry-run --verbose - npx -p @decocms/start deco-migrate --source ./my-site - npx -p @decocms/start deco-migrate --strict --with-build - npx -p @decocms/start deco-migrate + npx -p @decocms/blocks-cli deco-migrate --dry-run --verbose + npx -p @decocms/blocks-cli deco-migrate --source ./my-site + npx -p @decocms/blocks-cli deco-migrate --strict --with-build + npx -p @decocms/blocks-cli deco-migrate `); } @@ -136,7 +136,7 @@ async function main() { const sourceDir = path.resolve(opts.source); - banner("@decocms/start — Migrate: Fresh/Deno → TanStack Start"); + banner("@decocms/blocks-cli — Migrate: Fresh/Deno → TanStack Start"); stat("Source", sourceDir); stat("Mode", opts.dryRun ? yellow("DRY RUN") : green("EXECUTE")); stat("Verbose", opts.verbose ? "yes" : "no"); @@ -254,7 +254,7 @@ function bootstrap(ctx: { sourceDir: string }) { // matching `packageManager` field that pins the version. const pm = "bun"; if (!run(`${pm} install`, "Install dependencies", true)) return; - run("bunx tsx node_modules/@decocms/start/scripts/generate-blocks.ts", "Generate CMS blocks"); + run("bunx tsx node_modules/@decocms/blocks-cli/scripts/generate-blocks.ts", "Generate CMS blocks"); // generate-invoke emits src/server/invoke.gen.ts with top-level // createServerFn declarations + the forwardResponseCookies bridge that // propagates VTEX Set-Cookie headers (orderFormId, segment, sc…) to the @@ -265,7 +265,7 @@ function bootstrap(ctx: { sourceDir: string }) { // running the generator gives every freshly-migrated site the canonical // RPC path so VTEX hooks (useCart, useUser, useWishlist) work end-to-end. run( - "bunx tsx node_modules/@decocms/start/scripts/generate-invoke.ts", + "bunx tsx node_modules/@decocms/blocks-cli/scripts/generate-invoke.ts", "Generate VTEX invoke server functions", ); run("bunx tsr generate", "Generate TanStack routes"); diff --git a/packages/blocks-cli/scripts/migrate/analyzers/loader-inventory.ts b/packages/blocks-cli/scripts/migrate/analyzers/loader-inventory.ts index 5a72fe1..8fbb3fc 100644 --- a/packages/blocks-cli/scripts/migrate/analyzers/loader-inventory.ts +++ b/packages/blocks-cli/scripts/migrate/analyzers/loader-inventory.ts @@ -3,7 +3,7 @@ import * as path from "node:path"; import type { MigrationContext, LoaderInfo, Platform } from "../types"; import { log } from "../types"; -/** Well-known loaders that map directly to @decocms/apps equivalents */ +/** Well-known loaders that map directly to @decocms/apps-* equivalents */ const APPS_EQUIVALENTS: Record = { "loaders/availableIcons.ts": "", // deleted "loaders/icons.ts": "", // deleted diff --git a/packages/blocks-cli/scripts/migrate/phase-analyze.ts b/packages/blocks-cli/scripts/migrate/phase-analyze.ts index 6b85517..c2b6a21 100644 --- a/packages/blocks-cli/scripts/migrate/phase-analyze.ts +++ b/packages/blocks-cli/scripts/migrate/phase-analyze.ts @@ -255,7 +255,7 @@ function decideAction( if (SDK_DELETE.has(relPath)) { return { action: "delete", - notes: "Use framework equivalent from @decocms/start or @decocms/apps", + notes: "Use framework equivalent from @decocms/blocks or @decocms/apps-vtex", }; } @@ -263,13 +263,13 @@ function decideAction( if (COMPONENT_DELETE.has(relPath)) { return { action: "delete", - notes: "Scaffolded fresh from @decocms/apps re-exports", + notes: "Scaffolded fresh from @decocms/blocks re-exports", }; } // cart/ directory → delete if (relPath.startsWith("sdk/cart/")) { - return { action: "delete", notes: "Use @decocms/apps cart hooks" }; + return { action: "delete", notes: "Use @decocms/apps-vtex cart hooks" }; } // Non-platform cleanup is done in analyze() post-processing (needs ctx.platform) diff --git a/packages/blocks-cli/scripts/migrate/phase-cleanup.ts b/packages/blocks-cli/scripts/migrate/phase-cleanup.ts index 6a1145d..c750c5f 100644 --- a/packages/blocks-cli/scripts/migrate/phase-cleanup.ts +++ b/packages/blocks-cli/scripts/migrate/phase-cleanup.ts @@ -971,12 +971,15 @@ function upgradeSectionRenderer(ctx: MigrationContext) { let changed = false; // 1. Replace `import { SectionRenderer } from "@decocms/start/hooks"` - // with `import { RenderSection } from "@decocms/start/hooks"` + // with `import { RenderSection } from "@decocms/blocks/hooks"` + // (RenderSection is a distinct component from tanstack's + // SectionRenderer — it lives in @decocms/blocks/hooks, not + // @decocms/tanstack.) const sectionRendererImport = /import\s*\{([^}]*)\bSectionRenderer\b([^}]*)\}\s*from\s*["']@decocms\/start\/hooks["']/g; const newContent = result.replace(sectionRendererImport, (_m, before, after) => { changed = true; - return `import {${before}RenderSection${after}} from "@decocms/start/hooks"`; + return `import {${before}RenderSection${after}} from "@decocms/blocks/hooks"`; }); if (newContent !== result) { result = newContent; @@ -1000,7 +1003,7 @@ function upgradeSectionRenderer(ctx: MigrationContext) { // 4. Add RenderSection import if we introduced usages but no import exists if (changed && result.includes("(...)` → `createHttpClient(...)` (Proxy handles types) @@ -1034,12 +1039,13 @@ function upgradeSectionRenderer(ctx: MigrationContext) { * What was removed: * The four `@decocms/apps/vtex/utils/* → ~/lib/vtex-*` rewrites that the * first-pass `transforms/imports.ts` (lines 50-52) already produces in the - * correct, direct form. See discovery notes in MIGRATION_TOOLING_PLAN.md. + * correct, direct `@decocms/apps-vtex/utils/*` form. See discovery notes + * in MIGRATION_TOOLING_PLAN.md. */ function rewriteVtexUtilImports(ctx: MigrationContext) { // Intentionally empty — see docstring. First-pass `transforms/imports.ts` // already maps `apps/vtex/utils/*` and `apps/vtex/client` directly to the - // `@decocms/apps` equivalents; rewriting them again here would dead-shim them. + // `@decocms/apps-vtex` equivalents; rewriting them again here would dead-shim them. const importRewrites: Array<{ pattern: RegExp; replacement: string; desc: string }> = []; rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => { @@ -1117,12 +1123,12 @@ function rewriteVtexUtilImports(ctx: MigrationContext) { log(ctx, ` Replaced inline getISCookiesFromBag stub → ~/lib/vtex-intelligent-search: src/${relPath}`); } - // Rewrite ~/utils/retry → @decocms/start/sdk/retry + // Rewrite ~/utils/retry → @decocms/blocks/sdk/retry const retryImport = /from\s+["']~\/utils\/retry["']/g; if (retryImport.test(result)) { - result = result.replace(retryImport, 'from "@decocms/start/sdk/retry"'); + result = result.replace(retryImport, 'from "@decocms/blocks/sdk/retry"'); changed = true; - log(ctx, ` Rewrote retry import → @decocms/start/sdk/retry: src/${relPath}`); + log(ctx, ` Rewrote retry import → @decocms/blocks/sdk/retry: src/${relPath}`); } // Rewrite type-only imports from productListingPage (Props type) @@ -1301,7 +1307,7 @@ function moveRootConstantsToSrc(ctx: MigrationContext) { * with broken imports. * * New stack: the canonical Minicart contract + VTEX transform live in - * `@decocms/apps/vtex/inline-loaders/minicart`. We replace the loader with a + * `@decocms/apps-vtex/inline-loaders/minicart`. We replace the loader with a * thin VTEX-only re-export. Sites on Shopify/VNDA/Wake/Linx/Nuvemshop are * not currently in production on the new stack — when one is, swap this for * a runtime dispatcher again or add a platform-flagged rewrite. @@ -1325,19 +1331,19 @@ function rewriteMinicartLoader(ctx: MigrationContext) { // // The legacy site shipped per-platform loaders behind a \`usePlatform()\` // switch (vnda, wake, linx, shopify, nuvemshop). The canonical minicart -// contract now lives in \`@decocms/apps\`. Until a non-VTEX customer comes +// contract now lives in \`@decocms/apps-vtex\`. Until a non-VTEX customer comes // online on the new stack, we re-export the framework loader directly. // TODO: when adding another platform, replace this with a runtime // dispatcher and import the matching framework loader. -export { default } from "@decocms/apps/vtex/inline-loaders/minicart"; -export type { MinicartProps } from "@decocms/apps/vtex/inline-loaders/minicart"; +export { default } from "@decocms/apps-vtex/inline-loaders/minicart"; +export type { MinicartProps } from "@decocms/apps-vtex/inline-loaders/minicart"; `; if (ctx.dryRun) { log(ctx, `[DRY] Would rewrite: ${path.relative(ctx.sourceDir, filePath)} (VTEX-only re-export)`); } else { fs.writeFileSync(filePath, newContent); - log(ctx, `Rewrote: ${path.relative(ctx.sourceDir, filePath)} (VTEX-only re-export of @decocms/apps/vtex/inline-loaders/minicart)`); + log(ctx, `Rewrote: ${path.relative(ctx.sourceDir, filePath)} (VTEX-only re-export of @decocms/apps-vtex/inline-loaders/minicart)`); } } } @@ -1422,7 +1428,7 @@ export function cleanup(ctx: MigrationContext): void { // 5. Override contexts/device.tsx with SSR-safe useSyncExternalStore version. // The transform phase copies and transforms the source file (createContext-based), - // but @decocms/start shell-renders sections without a Device.Provider, so we + // but @decocms/tanstack shell-renders sections without a Device.Provider, so we // must replace it with a standalone implementation. console.log(" Overriding contexts/device.tsx..."); overrideDeviceContext(ctx); @@ -1452,7 +1458,7 @@ export function cleanup(ctx: MigrationContext): void { // 11. Rewrite VTEX utility imports to use ~/lib/ wrappers // The old stack imports from apps/vtex/utils/* which get rewritten to - // @decocms/apps/vtex/utils/* — but the signatures are incompatible + // @decocms/apps-vtex/utils/* — but the signatures are incompatible // and some types (VTEXCommerceStable) don't exist. Replace with // simplified ~/lib/ wrappers generated during scaffold. console.log(" Rewriting VTEX utility imports → ~/lib/ wrappers..."); @@ -1467,7 +1473,7 @@ export function cleanup(ctx: MigrationContext): void { // 11b. Rewrite legacy multi-platform minicart loader → VTEX-only re-export. // `sdk/cart/` is deleted by DIRS_TO_DELETE, leaving loaders/minicart.ts // with broken imports. Replace it with a thin re-export of the - // framework's @decocms/apps/vtex/inline-loaders/minicart loader. + // framework's @decocms/apps-vtex/inline-loaders/minicart loader. console.log(" Rewriting loaders/minicart.ts → VTEX-only re-export..."); rewriteMinicartLoader(ctx); diff --git a/packages/blocks-cli/scripts/migrate/phase-report.ts b/packages/blocks-cli/scripts/migrate/phase-report.ts index 16b2a64..562859b 100644 --- a/packages/blocks-cli/scripts/migrate/phase-report.ts +++ b/packages/blocks-cli/scripts/migrate/phase-report.ts @@ -5,11 +5,11 @@ import { logPhase } from "./types"; const FRAMEWORK_FINDINGS = [ "Session/analytics SDK is boilerplate duplicated across all sites — should be a single framework function", - "GTM event system (useGTMEvent, data-gtm-* listeners) is universal pattern — should be in @decocms/start", + "GTM event system (useGTMEvent, data-gtm-* listeners) is universal pattern — should be in @decocms/blocks", "Route files (__root.tsx, index.tsx, $.tsx, deco/*) are near-identical across sites — should be generated by framework", "server.ts, worker-entry.ts, router.tsx are pure boilerplate — should be a single createSite() call", "setup.ts section registration via import.meta.glob is 100% boilerplate — framework should handle this", - "runtime.ts invoke proxy is identical across sites — already in @decocms/start but sites still have local copies", + "runtime.ts invoke proxy is identical across sites — already in @decocms/blocks/sdk/invoke but sites still have local copies", "apps/site.ts is mostly empty after migration — platform config should be in a config file, not code", ]; @@ -124,7 +124,7 @@ export function report(ctx: MigrationContext): void { lines.push("## Loader Inventory"); lines.push(""); lines.push(`- **${ctx.loaderInventory.length}** loaders inventoried`); - lines.push(`- **${mapped}** mapped to \`@decocms/apps\` equivalents`); + lines.push(`- **${mapped}** mapped to \`@decocms/apps-*\` equivalents`); lines.push(`- **${custom}** custom (included in \`setup/commerce-loaders.ts\`)`); lines.push(""); } @@ -175,7 +175,7 @@ export function report(ctx: MigrationContext): void { lines.push("## Framework Findings"); lines.push(""); lines.push( - "> These are patterns found during migration that should eventually be handled by `@decocms/start` instead of being duplicated in every site.", + "> These are patterns found during migration that should eventually be handled by `@decocms/blocks` instead of being duplicated in every site.", ); lines.push(""); for (const finding of ctx.frameworkFindings) { @@ -215,11 +215,11 @@ export function report(ctx: MigrationContext): void { lines.push("#"); lines.push("# Forwarding to a future ClickHouse-backed OTel collector is on the"); lines.push("# roadmap (placeholder lives at"); - lines.push("# @decocms/start/sdk/otelAdapters/clickhouseCollector); when it"); + lines.push("# @decocms/blocks/sdk/otelAdapters/clickhouseCollector); when it"); lines.push("# ships, the codemod gains a `--destination-logs` /"); lines.push("# `--destination-traces` flag to point at it."); - lines.push("npx -p @decocms/start deco-cf-observability # dry-run"); - lines.push("npx -p @decocms/start deco-cf-observability --write # apply"); + lines.push("npx -p @decocms/blocks-cli deco-cf-observability # dry-run"); + lines.push("npx -p @decocms/blocks-cli deco-cf-observability --write # apply"); lines.push("```"); lines.push(""); diff --git a/packages/blocks-cli/scripts/migrate/phase-scaffold.ts b/packages/blocks-cli/scripts/migrate/phase-scaffold.ts index ad18ecf..0826b25 100644 --- a/packages/blocks-cli/scripts/migrate/phase-scaffold.ts +++ b/packages/blocks-cli/scripts/migrate/phase-scaffold.ts @@ -135,7 +135,7 @@ export function scaffold(ctx: MigrationContext): void { // some file actually imports. See `writeImportedLibShims` in phase-cleanup. // Replace Context-based useDevice with SSR-safe useSyncExternalStore version. - // @decocms/start shell-renders sections in a separate React root without + // @decocms/tanstack shell-renders sections in a separate React root without // Device.Provider, so the old createContext pattern throws during SSR. writeFile(ctx, "src/contexts/device.tsx", generateDeviceContext()); @@ -312,7 +312,7 @@ export default debounce; function generateSignalShim(): string { return `import { useState, useRef, useEffect, useMemo, useCallback } from "react"; -export { signal, type ReactiveSignal } from "@decocms/start/sdk/signal"; +export { signal, type ReactiveSignal } from "@decocms/blocks/sdk/signal"; /** Run a function immediately. Kept for legacy module-level side effects. */ export function effect(fn: () => void | (() => void)): () => void { @@ -535,8 +535,8 @@ function generateLocationMatcher(): string { * rules server-side. */ -import { registerMatcher } from "@decocms/start/cms"; -import type { MatcherContext } from "@decocms/start/cms"; +import { registerMatcher } from "@decocms/blocks/cms"; +import type { MatcherContext } from "@decocms/blocks/cms"; const COUNTRY_NAME_TO_CODE: Record = { Brasil: "BR", diff --git a/packages/blocks-cli/scripts/migrate/phase-transform.ts b/packages/blocks-cli/scripts/migrate/phase-transform.ts index d70419e..cf59ac4 100644 --- a/packages/blocks-cli/scripts/migrate/phase-transform.ts +++ b/packages/blocks-cli/scripts/migrate/phase-transform.ts @@ -155,7 +155,7 @@ export function transform(ctx: MigrationContext): void { // This file uses Deno-specific APIs (toFileUrl, import.meta.resolve) // and the HTMX-driven `useComponent(component, props)` pattern, which // do not run on Cloudflare Workers and have no equivalent in - // @decocms/start. The whole file must be deleted. + // @decocms/blocks. The whole file must be deleted. if ( /sections\/Component\.tsx?$/.test(record.path) || /sections\/Component\.tsx?$/.test(targetPath) diff --git a/packages/blocks-cli/scripts/migrate/phase-verify.ts b/packages/blocks-cli/scripts/migrate/phase-verify.ts index 8b3986d..3055420 100644 --- a/packages/blocks-cli/scripts/migrate/phase-verify.ts +++ b/packages/blocks-cli/scripts/migrate/phase-verify.ts @@ -35,7 +35,7 @@ const REQUIRED_FILES = [ "src/hooks/useUser.ts", "src/hooks/useWishlist.ts", // src/types/widgets.ts intentionally omitted — provided by the - // framework at `@decocms/start/types/widgets`; sites no longer + // framework at `@decocms/blocks/types/widgets`; sites no longer // shadow the file locally. "src/types/deco.ts", "src/types/commerce-app.ts", @@ -173,8 +173,9 @@ export const checks: Check[] = [ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); const deps = { ...pkg.dependencies, ...pkg.devDependencies }; const required = [ - "@decocms/start", - "@decocms/apps", + "@decocms/blocks", + "@decocms/tanstack", + "@decocms/apps-commerce", "react", "react-dom", "@tanstack/react-start", @@ -382,7 +383,7 @@ export const checks: Check[] = [ }, }, { - name: "No apps/ imports in src/ (should be @decocms/apps or ~/)", + name: "No apps/ imports in src/ (should be @decocms/apps-* or ~/)", severity: "error", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); @@ -457,7 +458,7 @@ export const checks: Check[] = [ severity: "warning", fn: (ctx) => { const typeFiles = [ - // widgets.ts is provided by @decocms/start/types/widgets, not + // widgets.ts is provided by @decocms/blocks/types/widgets, not // scaffolded locally. "src/types/deco.ts", "src/types/commerce-app.ts", diff --git a/packages/blocks-cli/scripts/migrate/post-cleanup/rules.ts b/packages/blocks-cli/scripts/migrate/post-cleanup/rules.ts index fdceb08..0c27662 100644 --- a/packages/blocks-cli/scripts/migrate/post-cleanup/rules.ts +++ b/packages/blocks-cli/scripts/migrate/post-cleanup/rules.ts @@ -467,23 +467,30 @@ function findAttachedLeadingComments(content: string, openIdx: number): number { * * 1. Legacy inline proxy (pre-Wave 15-A migration template) — defines * `createNestedInvokeProxy` plus `invoke` and `Runtime` constants. - * The whole 40-50 LOC body duplicates `@decocms/start/sdk`'s `invoke`. + * The whole 40-50 LOC body duplicates `@decocms/blocks/sdk/invoke`'s + * `invoke`. * * 2. Simple re-export shim — the file only re-exports `invoke` / * `createNestedInvokeProxy` (no inline proxy body, but also not yet - * pointing at `@decocms/start/sdk`). + * pointing at `@decocms/blocks/sdk/invoke`). * - * Both should be replaced with `import { invoke } from "@decocms/start/sdk"` - * at every callsite, and the file deleted. The Wave 15-A migration template + * Both should be replaced with `import { invoke } from "@decocms/blocks/sdk/invoke"` + * at every callsite, and the file deleted. The current migration template * scaffolds a thin re-export form that's also acceptable (re-exports - * `invoke` from `@decocms/start/sdk` and rebuilds `Runtime = { invoke }`); - * we explicitly skip it via the "imports invoke from @decocms/start/sdk + * `invoke` from `@decocms/blocks/sdk/invoke` and rebuilds `Runtime = { invoke }`); + * we explicitly skip it via the "imports invoke from @decocms/blocks/sdk * AND no inline proxy" check below. + * + * Sites migrated before the 7.x package split may still import from the + * retired `@decocms/start/sdk` — that package no longer exists for 7.x + * consumers, so this is intentionally NOT treated as "already fixed"; it + * falls through to the flag-and-rewrite path below like any other stale + * shim. */ const INLINE_PROXY_RE = /(?:function|const)\s+createNestedInvokeProxy\b|new\s+Proxy\s*\(\s*Object\.assign\s*\(\s*async\s*\(\s*props/; const FRAMEWORK_INVOKE_IMPORT_RE = - /import\s+\{[^}]*\binvoke\b[^}]*\}\s+from\s+['"]@decocms\/start(?:\/sdk)?['"]/; + /import\s+\{[^}]*\binvoke\b[^}]*\}\s+from\s+['"]@decocms\/blocks\/sdk(?:\/invoke)?['"]/; const ALLOWED_RUNTIME_EXPORTS = new Set(["invoke", "createNestedInvokeProxy", "Runtime"]); @@ -501,7 +508,7 @@ const ruleDeadRuntimeShim: Rule = { const onlyKnownInvokeExports = exports.length > 0 && exports.every((e) => ALLOWED_RUNTIME_EXPORTS.has(e)); - // Wave 15-A canonical template: re-exports invoke from @decocms/start/sdk + // Current canonical template: re-exports invoke from @decocms/blocks/sdk/invoke // and exposes `Runtime = { invoke }` for legacy callers. No inline proxy // body. This is the desired shape — skip. if (reExportsFromFramework && !hasInlineProxy) return []; @@ -523,11 +530,11 @@ const ruleDeadRuntimeShim: Rule = { severity: "info", file: "src/runtime.ts", message: safeToAutoFix - ? `${flavor} [${exportSummary}] — replace with @decocms/start/sdk` + ? `${flavor} [${exportSummary}] — replace with @decocms/blocks/sdk/invoke` : `${flavor} [${exportSummary}] — manual review: file mixes the runtime proxy with site-specific exports`, fix: safeToAutoFix - ? 'rg -l "from \\"~/runtime\\"" src/ | xargs sed -i \'\' \'s|from "~/runtime"|from "@decocms/start/sdk"|g\' && rm src/runtime.ts' - : "Move the inline `createNestedInvokeProxy` body to call @decocms/start/sdk's `invoke`; relocate site-specific helpers to a dedicated module before deleting src/runtime.ts", + ? 'rg -l "from \\"~/runtime\\"" src/ | xargs sed -i \'\' \'s|from "~/runtime"|from "@decocms/blocks/sdk/invoke"|g\' && rm src/runtime.ts' + : "Move the inline `createNestedInvokeProxy` body to call @decocms/blocks/sdk/invoke's `invoke`; relocate site-specific helpers to a dedicated module before deleting src/runtime.ts", meta: { hasInlineProxy, exports, @@ -542,13 +549,13 @@ const ruleDeadRuntimeShim: Rule = { // a runtime.ts that mixes the proxy with site-specific helpers. const safe = findings.every((f) => f.meta?.safeToAutoFix !== false); if (!safe) return []; - const updated = rewriteImportSpec(ctx, writer, "~/runtime", "@decocms/start/sdk"); + const updated = rewriteImportSpec(ctx, writer, "~/runtime", "@decocms/blocks/sdk/invoke"); writer.deleteFile(`${ctx.siteDir}/src/runtime.ts`); return [ { file: "src/runtime.ts", kind: "rewrite-imports+delete", - detail: `rewrote ${updated.length} import(s) → @decocms/start/sdk and deleted src/runtime.ts`, + detail: `rewrote ${updated.length} import(s) → @decocms/blocks/sdk/invoke and deleted src/runtime.ts`, }, ]; }, @@ -573,7 +580,7 @@ const ruleSiteLocalGlobals: Rule = { /(?:export\s+)?(?:function|const)\s+(?:withSiteGlobals|cmsRouteWithGlobals)\b/.test( content, ); - const reExportsFromFramework = /from\s+['"]@decocms\/start\/routes['"]/.test(content); + const reExportsFromFramework = /from\s+['"]@decocms\/tanstack['"]/.test(content); if (!definesWrapper || reExportsFromFramework) continue; const rel = abs.slice(siteDir.length + 1); const lineCount = content.split("\n").length; @@ -581,8 +588,8 @@ const ruleSiteLocalGlobals: Rule = { rule: "site-local-with-globals", severity: "warning", file: rel, - message: `Local wrapper (~${lineCount} LOC) — framework now exports withSiteGlobals from @decocms/start/routes`, - fix: "delete the local wrapper and import { withSiteGlobals } from '@decocms/start/routes'", + message: `Local wrapper (~${lineCount} LOC) — framework now exports withSiteGlobals from @decocms/tanstack`, + fix: "delete the local wrapper and import { withSiteGlobals } from '@decocms/tanstack'", meta: { lineCount }, }); } @@ -623,7 +630,7 @@ export const STUB_FIX_HINTS: Record = { // src/lib/vtex-transform toProduct: { kind: "swap", - canonical: "@decocms/apps/vtex/utils/transform", + canonical: "@decocms/apps-vtex/utils/transform", note: "canonical signature is `toProduct(product, sku, level, options)`; " + "1-arg call sites need to expand args first — see skill § 5", @@ -633,12 +640,12 @@ export const STUB_FIX_HINTS: Record = { kind: "refactor", note: "read cookies via `request.headers.get('cookie')` then call " + - "`buildSegmentFromCookies()` from '@decocms/apps/vtex/utils/segment'. " + + "`buildSegmentFromCookies()` from '@decocms/apps-vtex/utils/segment'. " + "The bag-based lookup mechanism does not exist on TanStack Start.", }, withSegmentCookie: { kind: "swap", - canonical: "@decocms/apps/vtex/utils/segment", + canonical: "@decocms/apps-vtex/utils/segment", note: "canonical signature is `withSegmentCookie(segment, headers?)`; " + "if you currently pass only headers, also pass a segment object", @@ -686,12 +693,12 @@ export function buildVtexShimFixMessage(stubsBySim: Map): stri const parts: string[] = [...known]; if (unknown.length > 0) { parts.push( - `${unknown.join(", ")} → repoint to '@decocms/apps/vtex/...' or 'apps/commerce/utils/...'`, + `${unknown.join(", ")} → repoint to '@decocms/apps-vtex/...' or 'apps/commerce/utils/...'`, ); } return parts.length > 0 ? parts.join(" | ") - : "Repoint imports to '@decocms/apps/vtex/...' or 'apps/commerce/utils/...'"; + : "Repoint imports to '@decocms/apps-vtex/...' or 'apps/commerce/utils/...'"; } /** @@ -885,8 +892,8 @@ const ruleLocalWidgetsTypes: Rule = { rule: "local-widgets-types", severity: "info", file: "src/types/widgets.ts", - message: `Local file shadows @decocms/start/types/widgets (used in ${importCount} place(s))`, - fix: 'rewrite imports to "@decocms/start/types/widgets" and rm src/types/widgets.ts', + message: `Local file shadows @decocms/blocks/types/widgets (used in ${importCount} place(s))`, + fix: 'rewrite imports to "@decocms/blocks/types/widgets" and rm src/types/widgets.ts', meta: { importCount }, }, ]; @@ -897,14 +904,14 @@ const ruleLocalWidgetsTypes: Rule = { ctx, writer, "~/types/widgets", - "@decocms/start/types/widgets", + "@decocms/blocks/types/widgets", ); writer.deleteFile(`${ctx.siteDir}/src/types/widgets.ts`); return [ { file: "src/types/widgets.ts", kind: "rewrite-imports+delete", - detail: `rewrote ${updated.length} import(s) → @decocms/start/types/widgets and deleted src/types/widgets.ts`, + detail: `rewrote ${updated.length} import(s) → @decocms/blocks/types/widgets and deleted src/types/widgets.ts`, }, ]; }, @@ -949,7 +956,7 @@ const ruleFrameworkTodos: Rule = { /** * Registry of files we expect sites to NOT carry locally because the - * canonical implementation already lives in `@decocms/start` (or a + * canonical implementation already lives in `@decocms/blocks` (or a * sibling apps package). * * Two flavours: @@ -1000,7 +1007,7 @@ interface FrameworkDuplicate { /** * Add an entry here when: * - 1+ migrated sites carry their own copy of code that already - * exists in `@decocms/start` (or a sibling apps package), AND + * exists in `@decocms/blocks` (or a sibling apps package), AND * - the canonical version is at least feature-equivalent. * * Per D4 in the migration tooling policy, the framework promotion @@ -1011,18 +1018,18 @@ export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [ { id: "clx", sitePath: "src/sdk/clx.ts", - canonicalImport: "@decocms/start/sdk/clx", + canonicalImport: "@decocms/blocks/sdk/clx", contentSignature: [ /export\s+const\s+clx\s*=/, /args\.filter\(Boolean\)\.join/, ], safeToAutoFix: true, - description: "src/sdk/clx.ts duplicates @decocms/start/sdk/clx", + description: "src/sdk/clx.ts duplicates @decocms/blocks/sdk/clx", }, { id: "use-send-event", sitePath: "src/sdk/useSendEvent.ts", - canonicalImport: "@decocms/start/sdk/analytics", + canonicalImport: "@decocms/blocks/sdk/analytics", contentSignature: [ /export\s+(?:const|function)\s+useSendEvent/, /data-event/, @@ -1034,12 +1041,12 @@ export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [ "Replacing 1:1 weakens type-safety. Either widen the framework export (preferred), or " + "rewrite call sites to drop the generic. Manual review required.", description: - "src/sdk/useSendEvent.ts overlaps with @decocms/start/sdk/analytics → useSendEvent", + "src/sdk/useSendEvent.ts overlaps with @decocms/blocks/sdk/analytics → useSendEvent", }, { id: "location-matcher", sitePath: "src/matchers/location.ts", - canonicalImport: "@decocms/start/matchers/builtins", + canonicalImport: "@decocms/blocks/matchers/builtins", contentSignature: [ /registerMatcher\(\s*['"]website\/matchers\/location\.ts['"]/, /__cf_geo/, @@ -1051,12 +1058,12 @@ export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [ "verify country-name lookup parity (resolveCountryCode vs site's inline table) and " + "swap setup.ts's customMatchers entry to call registerBuiltinMatchers().", description: - "src/matchers/location.ts overlaps with @decocms/start/matchers/builtins → registerBuiltinMatchers()", + "src/matchers/location.ts overlaps with @decocms/blocks/matchers/builtins → registerBuiltinMatchers()", }, { id: "url-relative", sitePath: "src/sdk/url.ts", - canonicalImport: "@decocms/apps/commerce/sdk/url", + canonicalImport: "@decocms/apps-commerce/sdk/url", // Fingerprint: site fork carries a positional `removeIdSku?: boolean` // flag + hardcoded VTEX-specific keys (`idsku`, `skuId`). Canonical // apps export uses an options object — `{ stripSearchParams: string[] }` @@ -1068,7 +1075,7 @@ export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [ ], safeToAutoFix: false, reason: - "rewrite imports to '@decocms/apps/commerce/sdk/url'. " + + "rewrite imports to '@decocms/apps-commerce/sdk/url'. " + "Each call site using the boolean form `relative(url, true)` becomes " + "`relative(url, { stripSearchParams: [\"idsku\", \"skuId\"] })`. " + "1-arg calls are unchanged. Then delete src/sdk/url.ts. " + @@ -1076,12 +1083,12 @@ export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [ "transformation (positional bool → options object), not pure import " + "rewrite.", description: - "src/sdk/url.ts overlaps with @decocms/apps/commerce/sdk/url → relative() (extended in @decocms/apps@1.9+)", + "src/sdk/url.ts overlaps with @decocms/apps-commerce/sdk/url → relative()", }, { id: "use-suggestions", sitePath: "src/sdk/useSuggestions.ts", - canonicalImport: "@decocms/start/sdk/useSuggestions", + canonicalImport: "@decocms/blocks/sdk/useSuggestions", // Fingerprint: hand-rolled hook with the module-level signal + // serial-queue + latestQuery cancel pattern. Both casaevideo and // baggagio independently invented this exact shape. Sites that @@ -1102,7 +1109,7 @@ export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [ "the per-site type parameter and onError wiring need site-specific " + "decisions. See references/platform-hooks-factories.md § useSuggestions.", description: - "src/sdk/useSuggestions.ts duplicates @decocms/start/sdk/useSuggestions → createUseSuggestions() (added in @decocms/start@2.25+)", + "src/sdk/useSuggestions.ts duplicates @decocms/blocks/sdk/useSuggestions → createUseSuggestions()", }, ]; @@ -1175,7 +1182,7 @@ function stripExt(path: string): string { /** * Per D2 in the migration tooling policy, every `hx-*` attribute is - * rewritten on migration; nothing in `@decocms/start` ships an htmx + * rewritten on migration; nothing in `@decocms/blocks` ships an htmx * runtime. This rule is the verification gate: a migrated site is * "rewrite-complete" when there are zero `hx-*` attributes left in * `src/`. @@ -1427,7 +1434,7 @@ function satisfiesRange(version: string, range: string): boolean | null { * package name. Bun's lockfile format embeds the version inside the * package descriptor array, e.g.: * - * "@decocms/start": ["@decocms/start@2.1.1", ...] + * "@decocms/blocks": ["@decocms/blocks@7.5.1", ...] * * We scan all such occurrences (a single package may appear multiple * times if the dep tree pulled different versions). @@ -1583,7 +1590,7 @@ const rulePackageManagerMissing: Rule = { /* ------------------------------------------------------------------ */ /** - * Every VTEX storefront on @decocms/start needs a reverse proxy for + * Every VTEX storefront on @decocms/tanstack needs a reverse proxy for * `/checkout/*`, `/account/*`, `/api/*`, `/files/*`, etc. — those paths * must hit the VTEX origin (not TanStack Start) so the user lands on * the real checkout UI carrying their VTEX session cookies. @@ -1614,11 +1621,14 @@ const ruleVtexProxyHandlerMissing: Rule = { title: "VTEX worker-entry missing the checkout/API proxy handler", run({ siteDir, fs }: RuleContext): Finding[] { // Only run when the site is actually VTEX. The cheapest signal is - // any import from `@decocms/apps/vtex/...` in src/ — every VTEX + // any import from `@decocms/apps-vtex/...` in src/ — every VTEX // site has at least one (commerceLoaders, hooks, types, etc.). + // Also matches the pre-7.x `@decocms/apps/vtex/...` monolith path, + // since sites migrated before the package split still carry it. + const VTEX_IMPORT_RE = /@decocms\/apps-vtex|@decocms\/apps\/vtex/; const srcFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES); const isVtex = srcFiles.some((abs) => - fs.readText(abs).includes("@decocms/apps/vtex"), + VTEX_IMPORT_RE.test(fs.readText(abs)), ); if (!isVtex) return []; @@ -1642,6 +1652,7 @@ const ruleVtexProxyHandlerMissing: Rule = { // either is a strong signal the proxy block exists; absence of // both means it was dropped. const hasProxyImport = + /from\s+["']@decocms\/apps-vtex\/utils\/proxy["']/.test(content) || /from\s+["']@decocms\/apps\/vtex\/utils\/proxy["']/.test(content); // Match both long form (`proxyHandler: async (...) => ...`) and // object-shorthand wiring (`{ proxyHandler }`, `{ proxyHandler, admin }`). @@ -1659,7 +1670,7 @@ const ruleVtexProxyHandlerMissing: Rule = { file: "src/worker-entry.ts", message: hasProxyImport ? "imports proxy helpers but no `proxyHandler:` is wired into createDecoWorkerEntry — /checkout requests will fall through to TanStack and render the SPA shell" - : "no `@decocms/apps/vtex/utils/proxy` import — VTEX /checkout, /account, /api won't be proxied to the origin", + : "no `@decocms/apps-vtex/utils/proxy` import — VTEX /checkout, /account, /api won't be proxied to the origin", fix: "Add `proxyHandler` to createDecoWorkerEntry; see scripts/migrate/templates/server-entry.ts (generateVtexWorkerEntry) for the canonical block", }, ]; diff --git a/packages/blocks-cli/scripts/migrate/post-cleanup/runner.test.ts b/packages/blocks-cli/scripts/migrate/post-cleanup/runner.test.ts index 97583c5..c52e779 100644 --- a/packages/blocks-cli/scripts/migrate/post-cleanup/runner.test.ts +++ b/packages/blocks-cli/scripts/migrate/post-cleanup/runner.test.ts @@ -242,10 +242,10 @@ describe("rule: dead-runtime-shim", () => { it("does NOT flag the Wave 15-A canonical re-export shape", () => { // The migration template now scaffolds a thin re-export from - // @decocms/start/sdk plus a Runtime alias. No inline proxy body. + // @decocms/blocks/sdk/invoke plus a Runtime alias. No inline proxy body. const fs = makeFs({ "/site/src/runtime.ts": - 'import { invoke } from "@decocms/start/sdk";\nexport { invoke };\nexport const Runtime = { invoke };\n', + 'import { invoke } from "@decocms/blocks/sdk/invoke";\nexport { invoke };\nexport const Runtime = { invoke };\n', }); const report = runAudit(SITE, fs); const r = report.rules.find((r) => r.rule === "dead-runtime-shim")!; @@ -330,7 +330,7 @@ describe("rule: site-local-with-globals", () => { it("does not flag a re-export from the framework", () => { const fs = makeFs({ "/site/src/server/routes/withSiteGlobals.ts": - 'export { withSiteGlobals } from "@decocms/start/routes";\n', + 'export { withSiteGlobals } from "@decocms/tanstack";\n', }); const report = runAudit(SITE, fs); const r = report.rules.find((r) => r.rule === "site-local-with-globals")!; @@ -492,12 +492,12 @@ describe("rule: vtex-shim-regression — per-symbol fix hints", () => { const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!; expect(r.findings).toHaveLength(1); const f = r.findings[0]; - expect(f.fix).toContain("toProduct → @decocms/apps/vtex/utils/transform"); + expect(f.fix).toContain("toProduct → @decocms/apps-vtex/utils/transform"); expect(f.fix).toContain("1:1 import swap"); expect(f.meta?.fixHints).toEqual({ toProduct: { kind: "swap", - canonical: "@decocms/apps/vtex/utils/transform", + canonical: "@decocms/apps-vtex/utils/transform", note: expect.stringContaining("canonical signature"), }, }); @@ -537,7 +537,7 @@ describe("rule: vtex-shim-regression — per-symbol fix hints", () => { const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!; const f = r.findings[0]; expect(f.fix).toContain("getSegmentFromBag → call-site refactor"); - expect(f.fix).toContain("toProduct → @decocms/apps/vtex/utils/transform"); + expect(f.fix).toContain("toProduct → @decocms/apps-vtex/utils/transform"); // Joined with " | " for visual separation. expect(f.fix).toContain(" | "); expect(Object.keys(f.meta?.fixHints as object)).toEqual( @@ -555,7 +555,7 @@ describe("rule: vtex-shim-regression — per-symbol fix hints", () => { const report = runAudit(SITE, fs); const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!; const f = r.findings[0]; - expect(f.fix).toContain("unknownStub → repoint to '@decocms/apps/vtex/..."); + expect(f.fix).toContain("unknownStub → repoint to '@decocms/apps-vtex/..."); // No fixHints in meta when no symbols match the table. expect(f.meta?.fixHints).toBeUndefined(); }); @@ -573,7 +573,7 @@ describe("rule: vtex-shim-regression — per-symbol fix hints", () => { const report = runAudit(SITE, fs); const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!; const f = r.findings[0]; - expect(f.fix).toContain("toProduct → @decocms/apps/vtex/utils/transform"); + expect(f.fix).toContain("toProduct → @decocms/apps-vtex/utils/transform"); expect(f.fix).toContain("unknownStub → repoint"); // Only the known symbol shows up in fixHints. expect(f.meta?.fixHints).toEqual({ @@ -665,6 +665,30 @@ export default createDecoWorkerEntry(serverEntry, { expect(r.findings).toEqual([]); }); + it("recognizes the current @decocms/apps-vtex package name (post-7.x split)", () => { + // Sites migrated with the current scaffolder emit `@decocms/apps-vtex`, + // not the retired `@decocms/apps/vtex` monolith path. Both the isVtex + // signal and the proxy-import detection must recognize the new form. + const currentWorkerEntry = ` +import { createDecoWorkerEntry } from "@decocms/tanstack"; +import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps-vtex/utils/proxy"; +const proxy = createVtexCheckoutProxy({ account: "x", checkoutOrigin: "x.vtexcommercestable.com.br" }); +export default createDecoWorkerEntry(serverEntry, { + proxyHandler: async (req, url) => { + if (!shouldProxyToVtex(url.pathname)) return null; + return proxy(req, url); + }, +}); +`; + const fs = makeFs({ + "/site/src/commerceLoaders.ts": 'import {} from "@decocms/apps-vtex/mod";', + "/site/src/worker-entry.ts": currentWorkerEntry, + }); + const report = runAudit(SITE, fs); + const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!; + expect(r.findings).toEqual([]); + }); + it("flags VTEX site missing src/worker-entry.ts entirely", () => { const fs = makeFs({ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";", @@ -687,7 +711,7 @@ export default createDecoWorkerEntry(serverEntry, { admin: {} }); const report = runAudit(SITE, fs); const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!; expect(r.findings).toHaveLength(1); - expect(r.findings[0].message).toContain("no `@decocms/apps/vtex/utils/proxy` import"); + expect(r.findings[0].message).toContain("no `@decocms/apps-vtex/utils/proxy` import"); }); it("does not flag VTEX site using object-shorthand proxyHandler wiring", () => { @@ -821,8 +845,8 @@ describe("runAudit — fix mode", () => { expect(r.fixes).toHaveLength(1); expect(r.fixes![0].detail).toMatch(/rewrote 2 import/); expect("/site/src/runtime.ts" in store).toBe(false); - expect(store["/site/src/sections/A.tsx"]).toContain('"@decocms/start/sdk"'); - expect(store["/site/src/sections/B.tsx"]).toContain("'@decocms/start/sdk'"); + expect(store["/site/src/sections/A.tsx"]).toContain('"@decocms/blocks/sdk/invoke"'); + expect(store["/site/src/sections/B.tsx"]).toContain("'@decocms/blocks/sdk/invoke'"); expect(store["/site/src/sections/C.tsx"]).toContain('"~/something-else"'); expect(log.filter((e) => e.kind === "delete")).toHaveLength(1); expect(log.filter((e) => e.kind === "write")).toHaveLength(2); @@ -841,8 +865,8 @@ describe("runAudit — fix mode", () => { expect(r.fixes).toHaveLength(1); expect(r.fixes![0].detail).toMatch(/rewrote 2 import/); expect("/site/src/types/widgets.ts" in store).toBe(false); - expect(store["/site/src/sections/A.tsx"]).toContain('"@decocms/start/types/widgets"'); - expect(store["/site/src/sections/B.tsx"]).toContain("'@decocms/start/types/widgets'"); + expect(store["/site/src/sections/A.tsx"]).toContain('"@decocms/blocks/types/widgets"'); + expect(store["/site/src/sections/B.tsx"]).toContain("'@decocms/blocks/types/widgets'"); }); it("fix mode is a no-op for rules without applyFix (e.g. framework-todos)", () => { @@ -865,7 +889,7 @@ describe("runAudit — fix mode", () => { 'import type { ImageWidget } from "~/types/widgets";\nimport thing from "~/types/widgets-extra";\n', }); runAudit(SITE, fs, { writer }); - expect(store["/site/src/sections/A.tsx"]).toContain('"@decocms/start/types/widgets"'); + expect(store["/site/src/sections/A.tsx"]).toContain('"@decocms/blocks/types/widgets"'); expect(store["/site/src/sections/A.tsx"]).toContain('"~/types/widgets-extra"'); }); }); @@ -887,10 +911,10 @@ describe("runAudit — fix mode — vtex-shim-regression swap cases", () => { expect(r.fixes).toHaveLength(1); expect(r.fixes![0].file).toBe("src/loaders/search.ts"); expect(r.fixes![0].kind).toBe("rewrite-imports"); - expect(r.fixes![0].detail).toContain("@decocms/apps/vtex/utils/transform"); + expect(r.fixes![0].detail).toContain("@decocms/apps-vtex/utils/transform"); expect(r.fixes![0].detail).toContain("toProduct"); expect(store["/site/src/loaders/search.ts"]).toContain( - '"@decocms/apps/vtex/utils/transform"', + '"@decocms/apps-vtex/utils/transform"', ); expect(store["/site/src/loaders/search.ts"]).not.toContain('"~/lib/vtex-transform"'); }); @@ -907,9 +931,9 @@ describe("runAudit — fix mode — vtex-shim-regression swap cases", () => { const report = runAudit(SITE, fs, { writer }); const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!; expect(r.fixes).toHaveLength(1); - expect(r.fixes![0].detail).toContain("@decocms/apps/vtex/utils/segment"); + expect(r.fixes![0].detail).toContain("@decocms/apps-vtex/utils/segment"); expect(store["/site/src/loaders/x.ts"]).toContain( - '"@decocms/apps/vtex/utils/segment"', + '"@decocms/apps-vtex/utils/segment"', ); }); @@ -964,10 +988,10 @@ describe("runAudit — fix mode — vtex-shim-regression swap cases", () => { const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!; expect(r.fixes!.length).toBe(2); expect(store["/site/src/loaders/A.ts"]).toContain( - '"@decocms/apps/vtex/utils/transform"', + '"@decocms/apps-vtex/utils/transform"', ); expect(store["/site/src/loaders/B.ts"]).toContain( - "'@decocms/apps/vtex/utils/transform'", + "'@decocms/apps-vtex/utils/transform'", ); }); @@ -983,7 +1007,7 @@ describe("runAudit — fix mode — vtex-shim-regression swap cases", () => { }); runAudit(SITE, fs, { writer }); expect(store["/site/src/loaders/x.ts"]).toContain( - 'import { toProduct as toP } from "@decocms/apps/vtex/utils/transform"', + 'import { toProduct as toP } from "@decocms/apps-vtex/utils/transform"', ); }); }); @@ -1296,7 +1320,7 @@ describe("rule: local-framework-duplicate", () => { expect(r.findings[0].message).toContain("pure dup"); expect(r.findings[0].meta?.id).toBe("clx"); expect(r.findings[0].meta?.safeToAutoFix).toBe(true); - expect(r.findings[0].meta?.canonicalImport).toBe("@decocms/start/sdk/clx"); + expect(r.findings[0].meta?.canonicalImport).toBe("@decocms/blocks/sdk/clx"); }); it("flags src/sdk/clx.ts when site adds a clsx alias (signature still matches)", () => { @@ -1328,7 +1352,7 @@ describe("rule: local-framework-duplicate", () => { it("flags src/sdk/useSendEvent.ts as warn-only (typing regression risk)", () => { const fs = makeFs({ "/site/src/sdk/useSendEvent.ts": - 'import { AnalyticsEvent } from "@decocms/apps/commerce/types";\n' + + 'import { AnalyticsEvent } from "@decocms/apps-commerce/types";\n' + "export const useSendEvent = (\n" + " { event, on }: { event: E; on: 'click' | 'view' | 'change' },\n" + ") => ({\n" + @@ -1367,7 +1391,7 @@ describe("rule: local-framework-duplicate", () => { expect(r.findings[0].meta?.id).toBe("url-relative"); expect(r.findings[0].meta?.safeToAutoFix).toBe(false); expect(r.findings[0].meta?.canonicalImport).toBe( - "@decocms/apps/commerce/sdk/url", + "@decocms/apps-commerce/sdk/url", ); expect(r.findings[0].fix).toContain("stripSearchParams"); }); @@ -1393,7 +1417,7 @@ describe("rule: local-framework-duplicate", () => { expect(r.findings[0].meta?.id).toBe("use-suggestions"); expect(r.findings[0].meta?.safeToAutoFix).toBe(false); expect(r.findings[0].meta?.canonicalImport).toBe( - "@decocms/start/sdk/useSuggestions", + "@decocms/blocks/sdk/useSuggestions", ); expect(r.findings[0].fix).toContain("createUseSuggestions"); }); @@ -1404,7 +1428,7 @@ describe("rule: local-framework-duplicate", () => { // strings. The rule's signature must NOT fire on the shim. const fs = makeFs({ "/site/src/sdk/useSuggestions.ts": - 'import { createUseSuggestions } from "@decocms/start/sdk/useSuggestions";\n' + + 'import { createUseSuggestions } from "@decocms/blocks/sdk/useSuggestions";\n' + 'import * as Sentry from "@sentry/react";\n' + "export const { useSuggestions } = createUseSuggestions({\n" + " onError: (err) => Sentry.captureException(err),\n" + @@ -1437,7 +1461,7 @@ describe("rule: local-framework-duplicate", () => { it("flags src/matchers/location.ts as warn-only (behaviour-superset opportunity)", () => { const fs = makeFs({ "/site/src/matchers/location.ts": - 'import { registerMatcher } from "@decocms/start/cms";\n' + + 'import { registerMatcher } from "@decocms/blocks/cms";\n' + "export function registerLocationMatcher(): void {\n" + ' registerMatcher("website/matchers/location.ts", (rule, ctx) => {\n' + " const cookies = ctx.cookies ?? {};\n" + @@ -1457,7 +1481,7 @@ describe("rule: local-framework-duplicate", () => { it("emits zero findings on a clean tree (no duplicates present)", () => { const fs = makeFs({ "/site/src/sections/Hello.tsx": - 'import { clx } from "@decocms/start/sdk/clx";\nexport default () =>
x
;\n', + 'import { clx } from "@decocms/blocks/sdk/clx";\nexport default () =>
x
;\n', }); const report = runAudit(SITE, fs); const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!; @@ -1485,7 +1509,7 @@ describe("rule: local-framework-duplicate", () => { "/site/src/components/B.tsx": 'import { clx } from "~/sdk/clx";\nimport React from "react";\nexport default () => clx("y");\n', "/site/src/components/Unrelated.tsx": - 'import { clx } from "@decocms/start/sdk/clx";\nexport default () => clx("z");\n', + 'import { clx } from "@decocms/blocks/sdk/clx";\nexport default () => clx("z");\n', }); const report = runAudit(SITE, fs, { writer }); const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!; @@ -1497,14 +1521,14 @@ describe("rule: local-framework-duplicate", () => { expect(store["/site/src/sdk/clx.ts"]).toBeUndefined(); // Importers rewritten expect(store["/site/src/components/A.tsx"]).toContain( - 'from "@decocms/start/sdk/clx"', + 'from "@decocms/blocks/sdk/clx"', ); expect(store["/site/src/components/B.tsx"]).toContain( - 'from "@decocms/start/sdk/clx"', + 'from "@decocms/blocks/sdk/clx"', ); // Already-canonical import untouched expect(store["/site/src/components/Unrelated.tsx"]).toContain( - 'from "@decocms/start/sdk/clx"', + 'from "@decocms/blocks/sdk/clx"', ); expect(store["/site/src/components/Unrelated.tsx"]).not.toMatch(/~\/sdk\/clx/); }); @@ -1512,12 +1536,12 @@ describe("rule: local-framework-duplicate", () => { it("auto-fix is a no-op for warn-only entries (does NOT delete partial-overlap files)", () => { const { fs, writer, store } = makeMutableFs({ "/site/src/sdk/useSendEvent.ts": - 'import { AnalyticsEvent } from "@decocms/apps/commerce/types";\n' + + 'import { AnalyticsEvent } from "@decocms/apps-commerce/types";\n' + "export const useSendEvent = () => ({\n" + ' "data-event": encodeURIComponent("x"),\n' + "});\n", "/site/src/matchers/location.ts": - 'import { registerMatcher } from "@decocms/start/cms";\n' + + 'import { registerMatcher } from "@decocms/blocks/cms";\n' + 'registerMatcher("website/matchers/location.ts", () => Boolean(__cf_geo_country));\n', }); const report = runAudit(SITE, fs, { writer }); diff --git a/packages/blocks-cli/scripts/migrate/templates/cache-config.ts b/packages/blocks-cli/scripts/migrate/templates/cache-config.ts index b2c2d64..f8fac48 100644 --- a/packages/blocks-cli/scripts/migrate/templates/cache-config.ts +++ b/packages/blocks-cli/scripts/migrate/templates/cache-config.ts @@ -4,18 +4,18 @@ export function generateCacheConfig(ctx: MigrationContext): string { if (ctx.platform !== "vtex") { return `/** * Cache configuration — customize edge cache profiles per route type. - * See @decocms/start/sdk/cacheHeaders for available profiles. + * See @decocms/blocks/sdk/cacheHeaders for available profiles. */ -// import { setCacheProfile } from "@decocms/start/sdk/cacheHeaders"; +// import { setCacheProfile } from "@decocms/blocks/sdk/cacheHeaders"; // setCacheProfile("product", { sMaxAge: 300, staleWhileRevalidate: 600 }); `; } return `/** * Cache configuration for VTEX storefront. - * Overrides default cache profiles from @decocms/start/sdk/cacheHeaders. + * Overrides default cache profiles from @decocms/blocks/sdk/cacheHeaders. */ -// import { setCacheProfile } from "@decocms/start/sdk/cacheHeaders"; +// import { setCacheProfile } from "@decocms/blocks/sdk/cacheHeaders"; // Uncomment and adjust as needed: // setCacheProfile("product", { sMaxAge: 300, staleWhileRevalidate: 600 }); diff --git a/packages/blocks-cli/scripts/migrate/templates/commerce-loaders.ts b/packages/blocks-cli/scripts/migrate/templates/commerce-loaders.ts index b0565fc..f9e836f 100644 --- a/packages/blocks-cli/scripts/migrate/templates/commerce-loaders.ts +++ b/packages/blocks-cli/scripts/migrate/templates/commerce-loaders.ts @@ -37,15 +37,15 @@ export function generateCommerceLoaders(ctx: MigrationContext): string { lines.push(` */`); if (ctx.platform === "vtex") { - lines.push(`import { getVtexConfig } from "@decocms/apps/vtex";`); - lines.push(`import { createVtexCommerceLoaders, createCachedPDPLoader } from "@decocms/apps/vtex/commerceLoaders";`); + lines.push(`import { getVtexConfig } from "@decocms/apps-vtex";`); + lines.push(`import { createVtexCommerceLoaders, createCachedPDPLoader } from "@decocms/apps-vtex/commerceLoaders";`); if (hasAutocomplete) { - lines.push(`import { autocompleteSearch } from "@decocms/apps/vtex/loaders/autocomplete";`); + lines.push(`import { autocompleteSearch } from "@decocms/apps-vtex/loaders/autocomplete";`); } - lines.push(`import { getAddressByPostalCode } from "@decocms/apps/vtex/loaders/address";`); - lines.push(`import { createAddressFromRequest, updateAddressFromRequest, deleteAddressFromRequest } from "@decocms/apps/vtex/actions/address";`); - lines.push(`import { updateProfileFromRequest, newsletterProfileFromRequest, deletePaymentFromRequest, getPasswordLastUpdate } from "@decocms/apps/vtex/actions/profile";`); - lines.push(`import { createCachedLoader } from "@decocms/start/sdk/cachedLoader";`); + lines.push(`import { getAddressByPostalCode } from "@decocms/apps-vtex/loaders/address";`); + lines.push(`import { createAddressFromRequest, updateAddressFromRequest, deleteAddressFromRequest } from "@decocms/apps-vtex/actions/address";`); + lines.push(`import { updateProfileFromRequest, newsletterProfileFromRequest, deletePaymentFromRequest, getPasswordLastUpdate } from "@decocms/apps-vtex/actions/profile";`); + lines.push(`import { createCachedLoader } from "@decocms/blocks/sdk/cachedLoader";`); if (hasVtexAuth) { lines.push(`import vtexAuthLoader from "../loaders/vtex-auth-loader";`); @@ -92,7 +92,7 @@ export function generateCommerceLoaders(ctx: MigrationContext): string { // Autocomplete aliases if (hasAutocomplete) { - lines.push(` // Autocomplete search — from @decocms/apps`); + lines.push(` // Autocomplete search — from @decocms/apps-vtex`); lines.push(` "site/loaders/search/intelligenseSearch.ts": cachedAutocomplete,`); lines.push(` "site/loaders/search/intelligenseSearch": cachedAutocomplete,`); lines.push(``); @@ -109,7 +109,7 @@ export function generateCommerceLoaders(ctx: MigrationContext): string { } // VTEX address CRUD - lines.push(` // VTEX address CRUD — request-aware wrappers from @decocms/apps`); + lines.push(` // VTEX address CRUD — request-aware wrappers from @decocms/apps-vtex`); lines.push(` "vtex/actions/address/createAddress": createAddressFromRequest as any,`); lines.push(` "vtex/actions/address/updateAddress": updateAddressFromRequest as any,`); lines.push(` "vtex/actions/address/deleteAddress": deleteAddressFromRequest as any,`); @@ -215,7 +215,7 @@ export function generateCommerceLoaders(ctx: MigrationContext): string { lines.push(``); // Profile actions - lines.push(` // Profile actions — request-aware wrappers from @decocms/apps`); + lines.push(` // Profile actions — request-aware wrappers from @decocms/apps-vtex`); lines.push(` "vtex/actions/profile/updateProfile": updateProfileFromRequest as any,`); lines.push(` "vtex/actions/profile/updateProfile.ts": updateProfileFromRequest as any,`); lines.push(` "vtex/actions/profile/newsletterProfile": newsletterProfileFromRequest as any,`); diff --git a/packages/blocks-cli/scripts/migrate/templates/cursor-rules.test.ts b/packages/blocks-cli/scripts/migrate/templates/cursor-rules.test.ts index 4268042..45d91d4 100644 --- a/packages/blocks-cli/scripts/migrate/templates/cursor-rules.test.ts +++ b/packages/blocks-cli/scripts/migrate/templates/cursor-rules.test.ts @@ -43,6 +43,12 @@ describe("generateMigrationPolicyPointerRule", () => { expect(body.length).toBeLessThan(3000); }); + it("does not emit the pre-split @decocms/start or @decocms/apps package names", () => { + expect(body).not.toContain("@decocms/start"); + expect(body).not.toContain("@decocms/apps\`"); + expect(body).toContain("@decocms/blocks-cli"); + }); + it("is deterministic — same site name, same output", () => { const a = generateMigrationPolicyPointerRule("foo"); const b = generateMigrationPolicyPointerRule("foo"); diff --git a/packages/blocks-cli/scripts/migrate/templates/cursor-rules.ts b/packages/blocks-cli/scripts/migrate/templates/cursor-rules.ts index a3ed931..759db25 100644 --- a/packages/blocks-cli/scripts/migrate/templates/cursor-rules.ts +++ b/packages/blocks-cli/scripts/migrate/templates/cursor-rules.ts @@ -26,10 +26,11 @@ alwaysApply: true # Migration Tooling Policy — Pointer -> This site (\`${siteName}\`) was generated by the \`@decocms/start\` -> migration script. The canonical policy that governs how the migration -> tooling, framework (\`@decocms/start\`), and commerce layer -> (\`@decocms/apps\`) evolve lives **upstream**, not in this repo. +> This site (\`${siteName}\`) was generated by the \`@decocms/blocks-cli\` +> migration script (\`deco-migrate\`). The canonical policy that governs how +> the migration tooling, framework (\`@decocms/blocks\` + \`@decocms/tanstack\`), +> and commerce layer (\`@decocms/apps-*\`) evolve lives **upstream**, not in +> this repo. ## Where to read @@ -38,24 +39,24 @@ alwaysApply: true - **Plan (living tracker, decisions + waves):** https://github.com/decocms/deco-start/blob/main/MIGRATION_TOOLING_PLAN.md - **Migration skill (phase playbook):** - \`@decocms/start/.agents/skills/deco-to-tanstack-migration\` + https://github.com/decocms/deco-start/blob/main/.agents/skills/deco-to-tanstack-migration/SKILL.md ## What you need to know in this site | ID | Decision | What it means here | |----|----------|--------------------| -| **D1** | Force convergence — no fork runtime support | Site customisations live in \`src/apps/local/\` or open a PR to \`@decocms/apps\`. Don't wrap framework/commerce code in soft adapters. | +| **D1** | Force convergence — no fork runtime support | Site customisations live in \`src/apps/local/\` or open a PR to \`@decocms/apps-*\`. Don't wrap framework/commerce code in soft adapters. | | **D2** | Rewrite HTMX on migration | If you find HTMX residue, rewrite to React. Don't bring back \`hx-*\` runtime. | -| **D3** | Generated stubs throw at runtime | If a \`~/lib/vtex-*\` import comes from a stub that returns \`null\` / \`{}\` / identity-cast, replace it. \`npx -p @decocms/start deco-post-cleanup --fix\` does the safe swaps automatically. | -| **D4** | Site-local apps by default, promote at 3+ sites | Don't try to upstream a pattern that has only shipped here. Build it twice in different sites first, then PR it to \`@decocms/apps\`. | +| **D3** | Generated stubs throw at runtime | If a \`~/lib/vtex-*\` import comes from a stub that returns \`null\` / \`{}\` / identity-cast, replace it. \`npx -p @decocms/blocks-cli deco-post-cleanup --fix\` does the safe swaps automatically. | +| **D4** | Site-local apps by default, promote at 3+ sites | Don't try to upstream a pattern that has only shipped here. Build it twice in different sites first, then PR it to \`@decocms/apps-*\`. | | **D5** | Failed migrations: \`rm -rf\` and re-run | No restart-mode magic. If the migration goes sideways, blow away the working tree and run again. | ## How to find issues this rule wants you to fix \`\`\`bash -npx -p @decocms/start deco-post-cleanup # audit only -npx -p @decocms/start deco-post-cleanup --fix # auto-fix the safe rules -npx -p @decocms/start deco-post-cleanup --strict # exit 1 on any finding (CI) +npx -p @decocms/blocks-cli deco-post-cleanup # audit only +npx -p @decocms/blocks-cli deco-post-cleanup --fix # auto-fix the safe rules +npx -p @decocms/blocks-cli deco-post-cleanup --strict # exit 1 on any finding (CI) \`\`\` ## Process diff --git a/packages/blocks-cli/scripts/migrate/templates/hooks.test.ts b/packages/blocks-cli/scripts/migrate/templates/hooks.test.ts index dc1401a..20396bf 100644 --- a/packages/blocks-cli/scripts/migrate/templates/hooks.test.ts +++ b/packages/blocks-cli/scripts/migrate/templates/hooks.test.ts @@ -44,12 +44,12 @@ describe("generateHooks (vtex)", () => { const code = files["src/hooks/useCart.ts"]; // Imports from the framework factory. expect(code).toContain( - 'import { createUseCart } from "@decocms/apps/vtex/hooks/createUseCart"', + 'import { createUseCart } from "@decocms/apps-vtex/hooks/createUseCart"', ); expect(code).toContain('import { invoke } from "~/server/invoke"'); - // Re-exports types from @decocms/apps directly. + // Re-exports types from @decocms/apps-vtex directly. expect(code).toContain( - 'export type { OrderForm, OrderFormItem } from "@decocms/apps/vtex/types"', + 'export type { OrderForm, OrderFormItem } from "@decocms/apps-vtex/types"', ); // Calls the factory with invoke and destructures the public API. expect(code).toContain( @@ -70,11 +70,11 @@ describe("generateHooks (vtex)", () => { it("useUser is the createUseUser factory shim (no signal-stub boilerplate)", () => { const code = files["src/hooks/useUser.ts"]; expect(code).toContain( - 'import { createUseUser } from "@decocms/apps/vtex/hooks/createUseUser"', + 'import { createUseUser } from "@decocms/apps-vtex/hooks/createUseUser"', ); expect(code).toContain('import { invoke } from "~/server/invoke"'); expect(code).toContain( - 'export type { Person } from "@decocms/apps/vtex/loaders/user"', + 'export type { Person } from "@decocms/apps-vtex/loaders/user"', ); expect(code).toContain("export const { useUser, resetUser } = createUseUser"); // Must NOT scaffold the legacy signal stub. @@ -85,11 +85,11 @@ describe("generateHooks (vtex)", () => { it("useWishlist is the createUseWishlist factory shim", () => { const code = files["src/hooks/useWishlist.ts"]; expect(code).toContain( - 'import { createUseWishlist } from "@decocms/apps/vtex/hooks/createUseWishlist"', + 'import { createUseWishlist } from "@decocms/apps-vtex/hooks/createUseWishlist"', ); expect(code).toContain('import { invoke } from "~/server/invoke"'); expect(code).toContain( - 'export type { WishlistItem } from "@decocms/apps/vtex/loaders/wishlist"', + 'export type { WishlistItem } from "@decocms/apps-vtex/loaders/wishlist"', ); expect(code).toContain( "export const { useWishlist, resetWishlist } = createUseWishlist", diff --git a/packages/blocks-cli/scripts/migrate/templates/hooks.ts b/packages/blocks-cli/scripts/migrate/templates/hooks.ts index 388517d..6a83055 100644 --- a/packages/blocks-cli/scripts/migrate/templates/hooks.ts +++ b/packages/blocks-cli/scripts/migrate/templates/hooks.ts @@ -19,11 +19,11 @@ export function generateHooks(ctx: MigrationContext): Record { function generateVtexUseCart(): string { // The legacy invoke-based useCart hook is now a 5-line factory call — // the heavy lifting (singleton state, listener pattern, async actions, - // analytics helpers) lives in @decocms/apps/vtex/hooks/createUseCart. - return `import { createUseCart } from "@decocms/apps/vtex/hooks/createUseCart"; + // analytics helpers) lives in @decocms/apps-vtex/hooks/createUseCart. + return `import { createUseCart } from "@decocms/apps-vtex/hooks/createUseCart"; import { invoke } from "~/server/invoke"; -export type { OrderForm, OrderFormItem } from "@decocms/apps/vtex/types"; +export type { OrderForm, OrderFormItem } from "@decocms/apps-vtex/types"; export const { useCart, resetCart, itemToAnalyticsItem } = createUseCart({ invoke, @@ -70,22 +70,22 @@ export default useCart; // VTEX path — these are five-line factory shims. The heavy lifting // (singleton state, listener pattern, async actions, signal-shaped // accessors, legacy arg-swap conventions) lives in -// @decocms/apps/vtex/hooks/createUseUser and createUseWishlist. +// @decocms/apps-vtex/hooks/createUseUser and createUseWishlist. function generateVtexUseUser(): string { - return `import { createUseUser } from "@decocms/apps/vtex/hooks/createUseUser"; + return `import { createUseUser } from "@decocms/apps-vtex/hooks/createUseUser"; import { invoke } from "~/server/invoke"; -export type { Person } from "@decocms/apps/vtex/loaders/user"; +export type { Person } from "@decocms/apps-vtex/loaders/user"; export const { useUser, resetUser } = createUseUser({ invoke }); `; } function generateVtexUseWishlist(): string { - return `import { createUseWishlist } from "@decocms/apps/vtex/hooks/createUseWishlist"; + return `import { createUseWishlist } from "@decocms/apps-vtex/hooks/createUseWishlist"; import { invoke } from "~/server/invoke"; -export type { WishlistItem } from "@decocms/apps/vtex/loaders/wishlist"; +export type { WishlistItem } from "@decocms/apps-vtex/loaders/wishlist"; export const { useWishlist, resetWishlist } = createUseWishlist({ invoke }); `; @@ -98,7 +98,7 @@ function generateGenericUseUser(): string { return `/** * User Hook — wire to invoke.site.loaders for your platform's user API. * - * VTEX sites get a real factory from @decocms/apps/vtex/hooks/createUseUser; + * VTEX sites get a real factory from @decocms/apps-vtex/hooks/createUseUser; * see migration template hooks.ts for the canonical five-line shim. */ import { signal } from "~/sdk/signal"; @@ -125,7 +125,7 @@ function generateGenericUseWishlist(): string { return `/** * Wishlist Hook — wire to invoke.site.loaders/actions for your platform. * - * VTEX sites get a real factory from @decocms/apps/vtex/hooks/createUseWishlist; + * VTEX sites get a real factory from @decocms/apps-vtex/hooks/createUseWishlist; * see migration template hooks.ts for the canonical five-line shim. */ import { signal } from "~/sdk/signal"; diff --git a/packages/blocks-cli/scripts/migrate/templates/lib-utils.test.ts b/packages/blocks-cli/scripts/migrate/templates/lib-utils.test.ts index 8db562d..943e8cc 100644 --- a/packages/blocks-cli/scripts/migrate/templates/lib-utils.test.ts +++ b/packages/blocks-cli/scripts/migrate/templates/lib-utils.test.ts @@ -99,7 +99,7 @@ describe("D3 — generated stubs throw at runtime", () => { it("vtex-transform.toProduct throws and points at the canonical path", () => { const src = LIB_TEMPLATES["src/lib/vtex-transform.ts"]; expect(src).toMatch(/throw new Error/); - expect(src).toMatch(/@decocms\/apps\/vtex\/utils\/transform/); + expect(src).toMatch(/@decocms\/apps-vtex\/utils\/transform/); expect(src).toMatch(/\[deco-migrate\]/); }); @@ -117,7 +117,7 @@ describe("D3 — generated stubs throw at runtime", () => { const src = LIB_TEMPLATES["src/lib/vtex-segment.ts"]; expect(src).toMatch(/getSegmentFromBag[\s\S]*?throw new Error/); expect(src).toMatch(/withSegmentCookie[\s\S]*?throw new Error/); - expect(src).toMatch(/@decocms\/apps\/vtex\/utils\/segment/); + expect(src).toMatch(/@decocms\/apps-vtex\/utils\/segment/); }); it("non-stub helpers stay implemented (negative check — no throw)", () => { diff --git a/packages/blocks-cli/scripts/migrate/templates/lib-utils.ts b/packages/blocks-cli/scripts/migrate/templates/lib-utils.ts index ed04bc2..05dc6a1 100644 --- a/packages/blocks-cli/scripts/migrate/templates/lib-utils.ts +++ b/packages/blocks-cli/scripts/migrate/templates/lib-utils.ts @@ -46,11 +46,11 @@ export function selectImportedLibTemplates( // // Each thrown message points at the canonical replacement so the fix // is mechanical. `deco-post-cleanup --fix` automates the swap. -const LIB_VTEX_TRANSFORM = `import type { Product } from "@decocms/apps/commerce/types"; +const LIB_VTEX_TRANSFORM = `import type { Product } from "@decocms/apps-commerce/types"; const STUB = "[deco-migrate] \`~/lib/vtex-transform.toProduct\` is a generated stub. " + - "Replace with: import { toProduct } from '@decocms/apps/vtex/utils/transform' " + + "Replace with: import { toProduct } from '@decocms/apps-vtex/utils/transform' " + "(canonical signature: \`toProduct(product, sku, level, options)\`). " + "Run \`deco-post-cleanup --fix\` or see the deco-to-tanstack-migration skill " + "(post-migration-cleanup § 5)."; @@ -119,13 +119,13 @@ const LIB_VTEX_SEGMENT = `// Per the migration tooling policy (D3): both these s const STUB_GET_SEGMENT_FROM_BAG = "[deco-migrate] \`~/lib/vtex-segment.getSegmentFromBag\` is a generated " + "stub. Refactor: read cookies via \`request.headers.get('cookie')\` then " + - "call \`buildSegmentFromCookies()\` from '@decocms/apps/vtex/utils/segment'. " + + "call \`buildSegmentFromCookies()\` from '@decocms/apps-vtex/utils/segment'. " + "The bag-based lookup mechanism does not exist on TanStack Start."; const STUB_WITH_SEGMENT_COOKIE = "[deco-migrate] \`~/lib/vtex-segment.withSegmentCookie\` is a generated " + "stub. Replace with: import { withSegmentCookie } from " + - "'@decocms/apps/vtex/utils/segment' (canonical signature: " + + "'@decocms/apps-vtex/utils/segment' (canonical signature: " + "\`withSegmentCookie(segment, headers?)\`). Run \`deco-post-cleanup --fix\` " + "or see the deco-to-tanstack-migration skill."; diff --git a/packages/blocks-cli/scripts/migrate/templates/no-legacy-packages.test.ts b/packages/blocks-cli/scripts/migrate/templates/no-legacy-packages.test.ts new file mode 100644 index 0000000..5a925ec --- /dev/null +++ b/packages/blocks-cli/scripts/migrate/templates/no-legacy-packages.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { createContext } from "../types"; +import type { MigrationContext } from "../types"; +import { generateRoutes } from "./routes"; +import { generateServerEntry } from "./server-entry"; +import { generateSetup } from "./setup"; +import { generateViteConfig } from "./vite-config"; +import { generateCommerceLoaders } from "./commerce-loaders"; +import { generateSectionLoaders } from "./section-loaders"; +import { generateHooks } from "./hooks"; +import { generateUiComponents } from "./ui-components"; +import { generateTypeFiles } from "./types-gen"; +import { generateCacheConfig } from "./cache-config"; +import { generateSdkFiles } from "./sdk-gen"; +import { generatePackageJson } from "./package-json"; +import { generateMigrationPolicyPointerRule } from "./cursor-rules"; + +/** + * Fleet-wide regression guard: none of the scaffolder's templates may emit + * the pre-7.x-split `@decocms/start` package, or a bare `@decocms/apps/` + * monolith subpath import — neither exists for 7.x consumers, so any + * occurrence here means a freshly scaffolded site ships broken code. + * + * `@decocms/apps-` (the current split packages) are fine and + * intentionally NOT flagged by this check. + */ +function assertNoLegacyPackageNames(label: string, output: string) { + expect(output, `${label} must not emit "@decocms/start"`).not.toContain( + "@decocms/start", + ); + expect( + output, + `${label} must not emit the "@decocms/apps/" monolith subpath`, + ).not.toMatch(/@decocms\/apps\//); +} + +function makeCtx(platform: MigrationContext["platform"]): MigrationContext { + const ctx = createContext("/tmp/no-legacy-packages-fixture-site"); + ctx.siteName = "acme-storefront"; + ctx.platform = platform; + ctx.vtexAccount = platform === "vtex" ? "acme" : null; + return ctx; +} + +describe("scaffolder templates never emit @decocms/start or @decocms/apps/*", () => { + for (const platform of ["vtex", "custom"] as const) { + describe(`platform: ${platform}`, () => { + const ctx = makeCtx(platform); + + it("routes.ts", () => { + const files = generateRoutes(ctx); + for (const [path, content] of Object.entries(files)) { + assertNoLegacyPackageNames(`routes.ts (${path})`, content); + } + }); + + it("server-entry.ts", () => { + const files = generateServerEntry(ctx); + for (const [path, content] of Object.entries(files)) { + assertNoLegacyPackageNames(`server-entry.ts (${path})`, content); + } + }); + + it("setup.ts", () => { + assertNoLegacyPackageNames("setup.ts", generateSetup(ctx)); + }); + + it("vite-config.ts", () => { + assertNoLegacyPackageNames("vite-config.ts", generateViteConfig(ctx)); + }); + + it("commerce-loaders.ts", () => { + assertNoLegacyPackageNames( + "commerce-loaders.ts", + generateCommerceLoaders(ctx), + ); + }); + + it("section-loaders.ts", () => { + assertNoLegacyPackageNames( + "section-loaders.ts", + generateSectionLoaders(ctx), + ); + }); + + it("hooks.ts", () => { + const files = generateHooks(ctx); + for (const [path, content] of Object.entries(files)) { + assertNoLegacyPackageNames(`hooks.ts (${path})`, content); + } + }); + + it("types-gen.ts", () => { + const files = generateTypeFiles(ctx); + for (const [path, content] of Object.entries(files)) { + assertNoLegacyPackageNames(`types-gen.ts (${path})`, content); + } + }); + + it("cache-config.ts", () => { + assertNoLegacyPackageNames( + "cache-config.ts", + generateCacheConfig(ctx), + ); + }); + + it("sdk-gen.ts", () => { + const files = generateSdkFiles(ctx); + for (const [path, content] of Object.entries(files)) { + assertNoLegacyPackageNames(`sdk-gen.ts (${path})`, content); + } + }); + }); + } + + // Platform-agnostic templates + it("ui-components.ts", () => { + const ctx = makeCtx("custom"); + const files = generateUiComponents(ctx); + for (const [path, content] of Object.entries(files)) { + assertNoLegacyPackageNames(`ui-components.ts (${path})`, content); + } + }); + + it("cursor-rules.ts", () => { + assertNoLegacyPackageNames( + "cursor-rules.ts", + generateMigrationPolicyPointerRule("acme-storefront"), + ); + }); + + // package-json.ts shells out to `npm view` to resolve the latest published + // version, with a hardcoded fallback if that fails (offline/CI-sandboxed). + // Either way the emitted dependency *names* must never be the retired + // @decocms/start / @decocms/apps monolith. + it("package-json.ts", () => { + const ctx = makeCtx("vtex"); + const pkg = generatePackageJson(ctx); + assertNoLegacyPackageNames("package-json.ts", pkg); + const parsed = JSON.parse(pkg); + expect(parsed.dependencies).not.toHaveProperty("@decocms/start"); + expect(parsed.dependencies).not.toHaveProperty("@decocms/apps"); + expect(parsed.dependencies).toHaveProperty("@decocms/blocks"); + expect(parsed.dependencies).toHaveProperty("@decocms/tanstack"); + expect(parsed.dependencies).toHaveProperty("@decocms/apps-vtex"); + }, 20000); +}); diff --git a/packages/blocks-cli/scripts/migrate/templates/package-json.ts b/packages/blocks-cli/scripts/migrate/templates/package-json.ts index aae4759..4fe1274 100644 --- a/packages/blocks-cli/scripts/migrate/templates/package-json.ts +++ b/packages/blocks-cli/scripts/migrate/templates/package-json.ts @@ -95,9 +95,19 @@ export function generatePackageJson(ctx: MigrationContext): string { const siteDeps = extractedDeps; - // Fetch latest versions from npm registry - const startVersion = getLatestVersion("@decocms/start", "0.34.0"); - const appsVersion = getLatestVersion("@decocms/apps", "0.27.0"); + // @decocms/blocks, @decocms/blocks-admin, @decocms/blocks-cli, + // @decocms/tanstack, and the @decocms/apps-* platform packages are + // published in lockstep from the same monorepo release, so a single + // version lookup covers all of them (mirrors how consumer sites bump + // "@decocms/blocks/blocks-admin/nextjs ranges" together). + const frameworkVersion = getLatestVersion("@decocms/blocks", "7.5.1"); + + const platformPackage: Partial> = { + vtex: "@decocms/apps-vtex", + shopify: "@decocms/apps-shopify", + magento: "@decocms/apps-magento", + }; + const platformDep = platformPackage[ctx.platform]; const pkg = { name: ctx.siteName, @@ -109,14 +119,14 @@ export function generatePackageJson(ctx: MigrationContext): string { "dev:clean": "rm -rf node_modules/.vite .wrangler/state .tanstack && vite dev", "generate:blocks": - "tsx node_modules/@decocms/start/scripts/generate-blocks.ts", + "tsx node_modules/@decocms/blocks-cli/scripts/generate-blocks.ts", "generate:routes": "tsr generate", - "generate:schema": `tsx node_modules/@decocms/start/scripts/generate-schema.ts --site ${ctx.siteName}`, + "generate:schema": `tsx node_modules/@decocms/blocks-cli/scripts/generate-schema.ts --site ${ctx.siteName}`, "generate:invoke": - "tsx node_modules/@decocms/start/scripts/generate-invoke.ts", + "tsx node_modules/@decocms/blocks-cli/scripts/generate-invoke.ts", "generate:sections": - "tsx node_modules/@decocms/start/scripts/generate-sections.ts", - "generate:loaders": `tsx node_modules/@decocms/start/scripts/generate-loaders.ts --exclude vtex/loaders,vtex/actions,loaders/vtex-auth-loader,loaders/reviews/productReviews,loaders/product/buyTogether,loaders/search/productListPageCollection,loaders/search/intelligenseSearch,loaders/Layouts/ProductCard`, + "tsx node_modules/@decocms/blocks-cli/scripts/generate-sections.ts", + "generate:loaders": `tsx node_modules/@decocms/blocks-cli/scripts/generate-loaders.ts --exclude vtex/loaders,vtex/actions,loaders/vtex-auth-loader,loaders/reviews/productReviews,loaders/product/buyTogether,loaders/search/productListPageCollection,loaders/search/intelligenseSearch,loaders/Layouts/ProductCard`, build: "npm run generate:blocks && npm run generate:sections && npm run generate:loaders && npm run generate:schema && npm run generate:invoke && tsr generate && vite build", preview: "vite preview", @@ -141,8 +151,11 @@ export function generatePackageJson(ctx: MigrationContext): string { license: "MIT", packageManager: `bun@${CANONICAL_BUN_VERSION}`, dependencies: { - "@decocms/apps": `^${appsVersion}`, - "@decocms/start": `^${startVersion}`, + "@decocms/blocks": `^${frameworkVersion}`, + "@decocms/blocks-admin": `^${frameworkVersion}`, + "@decocms/tanstack": `^${frameworkVersion}`, + "@decocms/apps-commerce": `^${frameworkVersion}`, + ...(platformDep ? { [platformDep]: `^${frameworkVersion}` } : {}), "@tanstack/react-query": "5.90.21", "@tanstack/react-router": "1.166.7", "@tanstack/react-start": "1.166.8", @@ -155,6 +168,7 @@ export function generatePackageJson(ctx: MigrationContext): string { }, devDependencies: { "@cloudflare/vite-plugin": "^1.27.0", + "@decocms/blocks-cli": `^${frameworkVersion}`, "@tailwindcss/vite": "^4.2.1", "@tanstack/router-cli": "1.166.7", "@types/react": "^19.2.14", diff --git a/packages/blocks-cli/scripts/migrate/templates/routes.ts b/packages/blocks-cli/scripts/migrate/templates/routes.ts index dc6478f..e742fcc 100644 --- a/packages/blocks-cli/scripts/migrate/templates/routes.ts +++ b/packages/blocks-cli/scripts/migrate/templates/routes.ts @@ -63,7 +63,7 @@ function generateRoot(ctx: MigrationContext, siteTitle: string, vtexAccount: str } return `import { createRootRoute } from "@tanstack/react-router"; -import { DecoRootLayout } from "@decocms/start/hooks"; +import { DecoRootLayout } from "@decocms/tanstack"; // @ts-ignore Vite ?url import import appCss from "../styles/app.css?url"; @@ -107,8 +107,7 @@ function RootLayout() { function generateIndex(ctx: MigrationContext, siteTitle: string): string { return `import { createFileRoute } from "@tanstack/react-router"; -import { cmsHomeRouteConfig, deferredSectionLoader } from "@decocms/start/routes"; -import { DecoPageRenderer } from "@decocms/start/hooks"; +import { cmsHomeRouteConfig, DecoPageRenderer, loadDeferredSection } from "@decocms/tanstack"; // MIGRATION TODO: customize defaultTitle / defaultDescription / fallback // copy below for ${siteTitle}. CMS \`Site.seo\` overrides these once block @@ -144,7 +143,7 @@ function HomePage() { deferredPromises={data.deferredPromises} pagePath={data.pagePath} pageUrl={data.pageUrl} - loadDeferredSectionFn={deferredSectionLoader} + loadDeferredSectionFn={loadDeferredSection} /> ); } @@ -153,8 +152,7 @@ function HomePage() { function generateCatchAll(ctx: MigrationContext, siteTitle: string): string { return `import { createFileRoute } from "@tanstack/react-router"; -import { cmsRouteConfig, deferredSectionLoader } from "@decocms/start/routes"; -import { DecoPageRenderer } from "@decocms/start/hooks"; +import { cmsRouteConfig, DecoPageRenderer, loadDeferredSection } from "@decocms/tanstack"; // MIGRATION TODO: customize defaultTitle / defaultDescription for ${siteTitle} // (CMS \`Site.seo\` overrides these per-page once block resolution kicks in). @@ -182,7 +180,7 @@ function CmsPage() { deferredPromises={data.deferredPromises} pagePath={data.pagePath} pageUrl={data.pageUrl} - loadDeferredSectionFn={deferredSectionLoader} + loadDeferredSectionFn={loadDeferredSection} /> ); } @@ -214,7 +212,7 @@ function NotFoundPage() { function generateDecoMeta(): string { return `import { createFileRoute } from "@tanstack/react-router"; -import { decoMetaRoute } from "@decocms/start/routes"; +import { decoMetaRoute } from "@decocms/tanstack"; export const Route = createFileRoute("/deco/meta")(decoMetaRoute); `; @@ -222,7 +220,7 @@ export const Route = createFileRoute("/deco/meta")(decoMetaRoute); function generateDecoInvoke(): string { return `import { createFileRoute } from "@tanstack/react-router"; -import { decoInvokeRoute } from "@decocms/start/routes"; +import { decoInvokeRoute } from "@decocms/tanstack"; export const Route = createFileRoute("/deco/invoke/$")(decoInvokeRoute); `; @@ -230,7 +228,7 @@ export const Route = createFileRoute("/deco/invoke/$")(decoInvokeRoute); function generateDecoRender(): string { return `import { createFileRoute } from "@tanstack/react-router"; -import { decoRenderRoute } from "@decocms/start/routes"; +import { decoRenderRoute } from "@decocms/tanstack"; export const Route = createFileRoute("/deco/render")(decoRenderRoute); `; diff --git a/packages/blocks-cli/scripts/migrate/templates/sdk-gen.ts b/packages/blocks-cli/scripts/migrate/templates/sdk-gen.ts index 8cf50eb..0fe0d5a 100644 --- a/packages/blocks-cli/scripts/migrate/templates/sdk-gen.ts +++ b/packages/blocks-cli/scripts/migrate/templates/sdk-gen.ts @@ -12,7 +12,7 @@ export function generateSdkFiles(ctx: MigrationContext): Record function generateDeviceServer(): string { return `import { createServerFn } from "@tanstack/react-start"; import { getRequestHeader } from "@tanstack/react-start/server"; -import { detectDevice } from "@decocms/start/sdk/useDevice"; +import { detectDevice } from "@decocms/blocks/sdk/useDevice"; export const getDeviceFromServer = createServerFn({ method: "GET" }).handler( async () => { diff --git a/packages/blocks-cli/scripts/migrate/templates/section-loaders.ts b/packages/blocks-cli/scripts/migrate/templates/section-loaders.ts index d7e0585..0375120 100644 --- a/packages/blocks-cli/scripts/migrate/templates/section-loaders.ts +++ b/packages/blocks-cli/scripts/migrate/templates/section-loaders.ts @@ -71,22 +71,22 @@ export function generateSectionLoaders(ctx: MigrationContext): string { lines.push(` withSearchParam,`); lines.push(` withSectionLoader,`); lines.push(` compose,`); - lines.push(`} from "@decocms/start/cms";`); + lines.push(`} from "@decocms/blocks/cms";`); if (hasSearchResult) { - lines.push(`import { detectDevice } from "@decocms/start/sdk/useDevice";`); + lines.push(`import { detectDevice } from "@decocms/blocks/sdk/useDevice";`); } if (isVtex) { - lines.push(`import { getVtexConfig } from "@decocms/apps/vtex";`); + lines.push(`import { getVtexConfig } from "@decocms/apps-vtex";`); if (hasWishlistSection && hasWishlistLoaders) { - lines.push(`import { getUser } from "@decocms/apps/vtex/loaders/user";`); - lines.push(`import { getVtexCookies } from "@decocms/apps/vtex/utils/cookies";`); + lines.push(`import { getUser } from "@decocms/apps-vtex/loaders/user";`); + lines.push(`import { getVtexCookies } from "@decocms/apps-vtex/utils/cookies";`); } } if (hasAccountSections) { - lines.push(`import { vtexAccountLoaders } from "@decocms/apps/vtex/utils/accountLoaders";`); + lines.push(`import { vtexAccountLoaders } from "@decocms/apps-vtex/utils/accountLoaders";`); } if (hasProductReviewsLoader && (hasProductReviews || hasSearchResult)) { @@ -338,7 +338,7 @@ export function generateSectionLoaders(ctx: MigrationContext): string { // ---------- Account sections ---------- if (isVtex && hasAccountSections) { entries.push(``); - entries.push(` // Account sections — via @decocms/apps factory`); + entries.push(` // Account sections — via @decocms/apps-vtex factory`); for (const meta of ctx.sectionMetas) { if (!meta.isAccountSection) continue; diff --git a/packages/blocks-cli/scripts/migrate/templates/server-entry.ts b/packages/blocks-cli/scripts/migrate/templates/server-entry.ts index 75ebd97..2f9d3ba 100644 --- a/packages/blocks-cli/scripts/migrate/templates/server-entry.ts +++ b/packages/blocks-cli/scripts/migrate/templates/server-entry.ts @@ -43,7 +43,7 @@ function generateWorkerEntry(ctx: MigrationContext): string { * Cloudflare Worker entry point. * * Wraps TanStack Start with admin protocol handlers, edge caching, and - * the @decocms/start observability stack (5.0+, Cloudflare-native): + * the @decocms/blocks observability stack (5.0+, Cloudflare-native): * - logs: console.* -> CF Workers Logs (captured by the platform * when wrangler.jsonc has \`observability.enabled: true\`). * View in the CF dashboard -> Workers & Pages -> -> @@ -57,27 +57,27 @@ function generateWorkerEntry(ctx: MigrationContext): string { * * No in-Worker OTLP exporter ships with 5.x — the CF dashboard is the * destination. A ClickHouse-collector adapter is scaffolded at - * @decocms/start/sdk/otelAdapters/clickhouseCollector but throws if + * @decocms/blocks/sdk/otelAdapters/clickhouseCollector but throws if * called; it'll get a real implementation once the OTel collector * gateway lands. * * To wire wrangler.jsonc with the canonical observability block, run: - * npx -p @decocms/start deco-cf-observability --write + * npx -p @decocms/blocks-cli deco-cf-observability --write */ import "./setup"; import handler, { createServerEntry } from "@tanstack/react-start/server-entry"; -import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry"; -import { instrumentWorker } from "@decocms/start/sdk/observability"; +import { createDecoWorkerEntry } from "@decocms/tanstack"; +import { instrumentWorker } from "@decocms/blocks/sdk/observability"; import { handleMeta, handleDecofileRead, handleDecofileReload, handleRender, corsHeaders, -} from "@decocms/start/admin"; +} from "@decocms/blocks-admin"; ${isCommerce ? ` // TODO: Uncomment and wire proxy for ${platformLabel} -// import { shouldProxyTo${capitalize(platformLabel!)}, proxyTo${capitalize(platformLabel!)} } from "@decocms/apps/${platformLabel}/utils/proxy"; +// import { shouldProxyTo${capitalize(platformLabel!)}, proxyTo${capitalize(platformLabel!)} } from "@decocms/apps-${platformLabel}/utils/proxy"; ` : ""} const serverEntry = createServerEntry({ fetch: handler.fetch }); @@ -110,23 +110,23 @@ function generateVtexWorkerEntry(ctx: MigrationContext): string { return `import "./setup"; import handler, { createServerEntry } from "@tanstack/react-start/server-entry"; -import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry"; -import { instrumentWorker } from "@decocms/start/sdk/observability"; +import { createDecoWorkerEntry } from "@decocms/tanstack"; +import { instrumentWorker } from "@decocms/blocks/sdk/observability"; import { handleMeta, handleDecofileRead, handleDecofileReload, handleRender, corsHeaders, -} from "@decocms/start/admin"; -import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps/vtex/utils/proxy"; -import { extractVtexContext } from "@decocms/apps/vtex/middleware"; -import { loadRedirects, matchRedirect } from "@decocms/start/sdk/redirects"; -import { withABTesting } from "@decocms/start/sdk/abTesting"; -import { loadBlocks } from "@decocms/start/cms"; +} from "@decocms/blocks-admin"; +import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps-vtex/utils/proxy"; +import { extractVtexContext } from "@decocms/apps-vtex/middleware"; +import { loadRedirects, matchRedirect } from "@decocms/blocks/sdk/redirects"; +import { withABTesting } from "@decocms/blocks/sdk/abTesting"; +import { loadBlocks } from "@decocms/blocks/cms"; // --------------------------------------------------------------------------- -// VTEX checkout proxy — configured via @decocms/apps factory +// VTEX checkout proxy — configured via @decocms/apps-vtex factory // --------------------------------------------------------------------------- const proxyCheckout = createVtexCheckoutProxy({ @@ -226,7 +226,7 @@ const abTestedWorker = withABTesting(decoWorker, { // --------------------------------------------------------------------------- // Observability wrap (outermost layer) // -// @decocms/start 5.0+ converged on Cloudflare-native observability: +// @decocms/blocks 5.0+ converged on Cloudflare-native observability: // - logs: console.* -> CF Workers Logs (dashboard captures, no // app-side OTLP exporter) // - traces: @opentelemetry/api global tracer (bridged from @@ -236,7 +236,7 @@ const abTestedWorker = withABTesting(decoWorker, { // src/sdk/otelAdapters/clickhouseCollector.ts) // // Wire wrangler.jsonc with the canonical observability block via: -// npx -p @decocms/start deco-cf-observability --write +// npx -p @decocms/blocks-cli deco-cf-observability --write // --------------------------------------------------------------------------- export default instrumentWorker(abTestedWorker, { serviceName: "${ctx.siteName}", @@ -246,7 +246,7 @@ export default instrumentWorker(abTestedWorker, { function generateRouter(): string { return `import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createDecoRouter } from "@decocms/start/sdk/router"; +import { createDecoRouter } from "@decocms/tanstack"; import { routeTree } from "./routeTree.gen"; import "./setup"; @@ -274,16 +274,16 @@ declare module "@tanstack/react-router" { function generateRuntime(): string { return `/** - * Runtime invoke proxy — re-exports the framework canonical from @decocms/start/sdk. + * Runtime invoke proxy — re-exports the framework canonical from @decocms/blocks/sdk/invoke. * * The implementation (typed RPC over /deco/invoke, dotted-path proxy, .ts - * suffix fallback) lives in @decocms/start/sdk/invoke. This file exists so + * suffix fallback) lives in @decocms/blocks/sdk/invoke. This file exists so * existing site code can keep \`import { invoke } from "~/runtime"\` and * \`Runtime.invoke\` shapes without churn. * - * Don't reimplement here — extend @decocms/start/sdk/invoke instead. + * Don't reimplement here — extend @decocms/blocks/sdk/invoke instead. */ -import { invoke } from "@decocms/start/sdk"; +import { invoke } from "@decocms/blocks/sdk/invoke"; export { invoke }; @@ -328,14 +328,14 @@ export const invoke = {} as const; */ import { createServerFn } from "@tanstack/react-start"; import { getRequestHeader } from "@tanstack/react-start/server"; -import { forwardResponseCookies } from "@decocms/start/sdk/cookiePassthrough"; +import { forwardResponseCookies } from "@decocms/tanstack/sdk/cookiePassthrough"; import { vtexActions } from "./invoke.gen"; ${hasVtexAuthLoader ? `import vtexAuthLoader from "../loaders/vtex-auth-loader";\n` : ""}import { extractVtexCookiesFromHeader, stripCookieDomain, performVtexLogout, parseVtexAuthJwt, -} from "@decocms/apps/vtex/utils/authHelpers"; +} from "@decocms/apps-vtex/utils/authHelpers"; export type { OrderForm } from "./invoke.gen"; @@ -398,17 +398,17 @@ export const vtexActions = {} as const; // Each server function is a top-level const so TanStack Start's compiler // can transform createServerFn().handler() into RPC stubs on the client. import { createServerFn } from "@tanstack/react-start"; -import { getOrCreateCart, addItemsToCart, updateCartItems, addCouponToCart, simulateCart, getSellersByRegion, setShippingPostalCode, updateOrderFormAttachment } from "@decocms/apps/vtex/actions/checkout"; -import { createSession, editSession } from "@decocms/apps/vtex/actions/session"; -import { createDocument, getDocument, patchDocument, searchDocuments, uploadAttachment } from "@decocms/apps/vtex/actions/masterData"; -import { subscribe } from "@decocms/apps/vtex/actions/newsletter"; -import { notifyMe } from "@decocms/apps/vtex/actions/misc"; -import type { OrderForm } from "@decocms/apps/vtex/types"; -import type { SimulationItem, RegionResult } from "@decocms/apps/vtex/actions/checkout"; -import type { SessionData } from "@decocms/apps/vtex/actions/session"; -import type { CreateDocumentResult, UploadAttachmentOpts } from "@decocms/apps/vtex/actions/masterData"; -import type { SubscribeProps } from "@decocms/apps/vtex/actions/newsletter"; -import type { NotifyMeProps } from "@decocms/apps/vtex/actions/misc"; +import { getOrCreateCart, addItemsToCart, updateCartItems, addCouponToCart, simulateCart, getSellersByRegion, setShippingPostalCode, updateOrderFormAttachment } from "@decocms/apps-vtex/actions/checkout"; +import { createSession, editSession } from "@decocms/apps-vtex/actions/session"; +import { createDocument, getDocument, patchDocument, searchDocuments, uploadAttachment } from "@decocms/apps-vtex/actions/masterData"; +import { subscribe } from "@decocms/apps-vtex/actions/newsletter"; +import { notifyMe } from "@decocms/apps-vtex/actions/misc"; +import type { OrderForm } from "@decocms/apps-vtex/types"; +import type { SimulationItem, RegionResult } from "@decocms/apps-vtex/actions/checkout"; +import type { SessionData } from "@decocms/apps-vtex/actions/session"; +import type { CreateDocumentResult, UploadAttachmentOpts } from "@decocms/apps-vtex/actions/masterData"; +import type { SubscribeProps } from "@decocms/apps-vtex/actions/newsletter"; +import type { NotifyMeProps } from "@decocms/apps-vtex/actions/misc"; function unwrapResult(result: unknown): T { if (result && typeof result === "object" && "data" in result) { @@ -549,7 +549,7 @@ export const vtexActions = { notifyMe: $notifyMe as unknown as (ctx: { data: NotifyMeProps }) => Promise, } as const; -export type { OrderForm } from "@decocms/apps/vtex/types"; +export type { OrderForm } from "@decocms/apps-vtex/types"; export const invoke = { vtex: { diff --git a/packages/blocks-cli/scripts/migrate/templates/setup.ts b/packages/blocks-cli/scripts/migrate/templates/setup.ts index acf259e..f4d24f5 100644 --- a/packages/blocks-cli/scripts/migrate/templates/setup.ts +++ b/packages/blocks-cli/scripts/migrate/templates/setup.ts @@ -93,15 +93,15 @@ import "./cache-config"; import { registerCommerceLoaders, applySectionConventions, -} from "@decocms/start/cms"; -import { createSiteSetup } from "@decocms/start/setup"; -import { setInvokeLoaders } from "@decocms/start/admin";${isVtex ? ` -import { createInstrumentedFetch } from "@decocms/start/sdk/instrumentedFetch"; -import { initVtexFromBlocks, setVtexFetch } from "@decocms/apps/vtex";` : ""}${hasLocationMatcher ? ` +} from "@decocms/blocks/cms"; +import { createSiteSetup } from "@decocms/blocks/setup"; +import { setInvokeLoaders } from "@decocms/blocks-admin";${isVtex ? ` +import { createInstrumentedFetch } from "@decocms/blocks/sdk/instrumentedFetch"; +import { initVtexFromBlocks, setVtexFetch } from "@decocms/apps-vtex";` : ""}${hasLocationMatcher ? ` import { registerLocationMatcher } from "./matchers/location";` : ""} import { blocks as generatedBlocks } from "../.deco/blocks.gen"; import { sectionMeta, syncComponents, loadingFallbacks } from "../.deco/sections.gen"; -import { PreviewProviders } from "@decocms/start/hooks"; +import { PreviewProviders } from "@decocms/tanstack"; // @ts-ignore Vite ?url import import appCss from "./styles/app.css?url"; diff --git a/packages/blocks-cli/scripts/migrate/templates/types-gen.ts b/packages/blocks-cli/scripts/migrate/templates/types-gen.ts index 3eebf35..1d4855d 100644 --- a/packages/blocks-cli/scripts/migrate/templates/types-gen.ts +++ b/packages/blocks-cli/scripts/migrate/templates/types-gen.ts @@ -5,7 +5,7 @@ export function generateTypeFiles(ctx: MigrationContext): Record // src/types/widgets.ts is no longer generated — the framework owns these // string aliases (`ImageWidget`, `HTMLWidget`, …) at - // `@decocms/start/types/widgets`, and `transforms/imports.ts` rewrites + // `@decocms/blocks/types/widgets`, and `transforms/imports.ts` rewrites // `apps/admin/widgets.ts` directly to that path. Schema generation // works the same way: the generator matches by type *text*, not module // identity (see scripts/generate-schema.ts:WIDGET_TYPE_FORMATS). @@ -99,7 +99,7 @@ export type AppContext = { export type LegacyAppContext = AppContext; `; - files["src/types/vtex-loaders.ts"] = `import type { Product, ProductListingPage } from "@decocms/apps/commerce/types"; + files["src/types/vtex-loaders.ts"] = `import type { Product, ProductListingPage } from "@decocms/apps-commerce/types"; export interface ProductListProps { page: ProductListingPage | null; diff --git a/packages/blocks-cli/scripts/migrate/templates/ui-components.ts b/packages/blocks-cli/scripts/migrate/templates/ui-components.ts index 48811a4..588c15f 100644 --- a/packages/blocks-cli/scripts/migrate/templates/ui-components.ts +++ b/packages/blocks-cli/scripts/migrate/templates/ui-components.ts @@ -13,7 +13,7 @@ export function generateUiComponents(_ctx: MigrationContext): Record> = { + vtex: "@decocms/apps-vtex", + shopify: "@decocms/apps-shopify", + magento: "@decocms/apps-magento", +}; + export function generateViteConfig(ctx: MigrationContext): string { const isVtex = ctx.platform === "vtex"; + const platformDep = PLATFORM_PACKAGE[ctx.platform]; const vtexAccount = ctx.vtexAccount || ctx.siteName.replace(/-migrated$/, "").replace(/-storefront$/, ""); @@ -29,7 +36,7 @@ const VTEX_ORIGIN = \`https://\${VTEX_ACCOUNT}.\${VTEX_ENVIRONMENT}.\${VTEX_DOMA return `import { cloudflare } from "@cloudflare/vite-plugin"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; -import { decoVitePlugin } from "@decocms/start/vite"; +import { decoVitePlugin } from "@decocms/tanstack/vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; @@ -80,8 +87,10 @@ export default defineConfig({ }, resolve: { dedupe: [ - "@decocms/start", - "@decocms/apps", + "@decocms/blocks", + "@decocms/blocks-admin", + "@decocms/tanstack", + "@decocms/apps-commerce",${platformDep ? `\n "${platformDep}",` : ""} "@tanstack/react-start", "@tanstack/react-router", "@tanstack/react-start-server", diff --git a/packages/blocks-cli/scripts/migrate/transforms/fresh-apis.ts b/packages/blocks-cli/scripts/migrate/transforms/fresh-apis.ts index 6917db5..cba9f1a 100644 --- a/packages/blocks-cli/scripts/migrate/transforms/fresh-apis.ts +++ b/packages/blocks-cli/scripts/migrate/transforms/fresh-apis.ts @@ -165,10 +165,10 @@ export function transformFreshApis(content: string): TransformResult { if (result.includes("scriptAsDataURI")) { // Ensure useScript is imported if ( - !result.includes('"@decocms/start/sdk/useScript"') && - !result.includes("'@decocms/start/sdk/useScript'") + !result.includes('"@decocms/blocks/sdk/useScript"') && + !result.includes("'@decocms/blocks/sdk/useScript'") ) { - result = `import { useScript } from "@decocms/start/sdk/useScript";\n${result}`; + result = `import { useScript } from "@decocms/blocks/sdk/useScript";\n${result}`; } // Transform src={scriptAsDataURI(...)} into dangerouslySetInnerHTML={{ __html: useScript(...) }} @@ -190,7 +190,7 @@ export function transformFreshApis(content: string): TransformResult { notes.push("Replaced scriptAsDataURI with useScript + dangerouslySetInnerHTML"); } - // allowCorsFor — not available in @decocms/start, remove usage + // allowCorsFor — not available in @decocms/blocks, remove usage if (result.includes("allowCorsFor")) { result = result.replace( /^import\s+\{[^}]*\ballowCorsFor\b[^}]*\}\s+from\s+["'][^"']+["'];?\s*\n?/gm, @@ -204,7 +204,7 @@ export function transformFreshApis(content: string): TransformResult { // ctx.response.headers → not available, flag if (result.includes("ctx.response")) { - notes.push("MANUAL: ctx.response usage found — FnContext in @decocms/start does not have response object"); + notes.push("MANUAL: ctx.response usage found — FnContext in @decocms/blocks does not have response object"); } // { crypto } from "@std/crypto" → use globalThis.crypto (Web Crypto API) diff --git a/packages/blocks-cli/scripts/migrate/transforms/imports.ts b/packages/blocks-cli/scripts/migrate/transforms/imports.ts index 1097144..f785529 100644 --- a/packages/blocks-cli/scripts/migrate/transforms/imports.ts +++ b/packages/blocks-cli/scripts/migrate/transforms/imports.ts @@ -20,7 +20,7 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [ [/^"@preact\/signals"$/, `"~/sdk/signal"`], // Deco framework — hooks need splitting (useDevice, useScript, useSection) - [/^"@deco\/deco\/hooks"$/, `"@decocms/start/sdk/useScript"`], + [/^"@deco\/deco\/hooks"$/, `"@decocms/blocks/sdk/useScript"`], [/^"@deco\/deco\/blocks"$/, `"~/types/deco"`], [/^"@deco\/deco\/o11y"$/, null], // logger — use console.log/warn/error instead [/^"@deco\/deco\/web"$/, null], // runtime.ts is rewritten @@ -32,66 +32,66 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [ // Widget aliases (ImageWidget, HTMLWidget, ...) are framework-owned — // every site has the same type set, and the schema generator detects // them via type-text matching, not module identity. Re-export from - // @decocms/start/types/widgets so we don't keep a duplicated 8-line + // @decocms/blocks/types/widgets so we don't keep a duplicated 8-line // file in every site. - [/^"apps\/admin\/widgets\.ts"$/, `"@decocms/start/types/widgets"`], + [/^"apps\/admin\/widgets\.ts"$/, `"@decocms/blocks/types/widgets"`], [/^"apps\/website\/components\/Image\.tsx"$/, `"~/components/ui/Image"`], [/^"apps\/website\/components\/Picture\.tsx"$/, `"~/components/ui/Picture"`], [/^"apps\/website\/components\/Video\.tsx"$/, `"~/components/ui/Video"`], [/^"apps\/website\/components\/Theme\.tsx"$/, `"~/components/ui/Theme"`], [/^"apps\/website\/components\/_seo\/[^"]+?"$/, null], // SEO preview — framework-only, remove [/^"apps\/website\/components\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/ui/$1"`], - [/^"apps\/commerce\/types\.ts"$/, `"@decocms/apps/commerce/types"`], + [/^"apps\/commerce\/types\.ts"$/, `"@decocms/apps-commerce/types"`], [/^"apps\/commerce\/mod\.ts"$/, `"~/types/commerce-app"`], - [/^"apps\/commerce\/types"$/, `"@decocms/apps/commerce/types"`], + [/^"apps\/commerce\/types"$/, `"@decocms/apps-commerce/types"`], - // Apps — VTEX hooks: useUser/useCart/useWishlist → local hooks (react-query based @decocms/apps hooks crash Workers SSR) + // Apps — VTEX hooks: useUser/useCart/useWishlist → local hooks (react-query based @decocms/apps-vtex hooks crash Workers SSR) [/^"apps\/vtex\/hooks\/useUser(?:\.ts)?"$/, `"~/hooks/useUser"`], [/^"apps\/vtex\/hooks\/useCart(?:\.ts)?"$/, `"~/hooks/useCart"`], [/^"apps\/vtex\/hooks\/useWishlist(?:\.ts)?"$/, `"~/hooks/useWishlist"`], - [/^"apps\/vtex\/hooks\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/hooks/$1"`], - // Specific VTEX utils that moved to different paths in @decocms/apps + [/^"apps\/vtex\/hooks\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-vtex/hooks/$1"`], + // Specific VTEX utils that moved to different paths in @decocms/apps-vtex // fetchVTEX (generic fetchSafe + QS sanitization) lives at vtex/utils/fetch in apps-start. - [/^"apps\/vtex\/utils\/fetchVTEX(?:\.ts)?"$/, `"@decocms/apps/vtex/utils/fetch"`], - [/^"apps\/vtex\/utils\/client(?:\.ts)?"$/, `"@decocms/apps/vtex/client"`], - [/^"apps\/vtex\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/utils/$1"`], - [/^"apps\/vtex\/actions\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/actions/$1"`], + [/^"apps\/vtex\/utils\/fetchVTEX(?:\.ts)?"$/, `"@decocms/apps-vtex/utils/fetch"`], + [/^"apps\/vtex\/utils\/client(?:\.ts)?"$/, `"@decocms/apps-vtex/client"`], + [/^"apps\/vtex\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-vtex/utils/$1"`], + [/^"apps\/vtex\/actions\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-vtex/actions/$1"`], // Tier B loader path rewrites (apps-start has no `intelligentSearch/`, `legacy/`, or `paths/` subdirs). // Intelligent Search loaders moved to inline-loaders/. [ /^"apps\/vtex\/loaders\/intelligentSearch\/productList(?:\.ts)?"$/, - `"@decocms/apps/vtex/inline-loaders/productList"`, + `"@decocms/apps-vtex/inline-loaders/productList"`, ], [ /^"apps\/vtex\/loaders\/intelligentSearch\/productListingPage(?:\.ts)?"$/, - `"@decocms/apps/vtex/inline-loaders/productListingPage"`, + `"@decocms/apps-vtex/inline-loaders/productListingPage"`, ], [ /^"apps\/vtex\/loaders\/intelligentSearch\/productDetailsPage(?:\.ts)?"$/, - `"@decocms/apps/vtex/inline-loaders/productDetailsPage"`, + `"@decocms/apps-vtex/inline-loaders/productDetailsPage"`, ], [ /^"apps\/vtex\/loaders\/intelligentSearch\/suggestions(?:\.ts)?"$/, - `"@decocms/apps/vtex/inline-loaders/suggestions"`, + `"@decocms/apps-vtex/inline-loaders/suggestions"`, ], // Legacy product loaders are consolidated into a single file (named exports). [ /^"apps\/vtex\/loaders\/legacy\/(?:productList|productListingPage|productDetailsPage|search|category)(?:\.ts)?"$/, - `"@decocms/apps/vtex/loaders/legacy"`, + `"@decocms/apps-vtex/loaders/legacy"`, ], // Path-default loaders (sitemap seeds) don't exist in TanStack Start — paths resolve at request time. [/^"apps\/vtex\/loaders\/paths\/(?:[^"]+)(?:\.ts)?"$/, null], - [/^"apps\/vtex\/loaders\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/loaders/$1"`], - [/^"apps\/vtex\/types(?:\.ts)?"$/, `"@decocms/apps/vtex/types"`], + [/^"apps\/vtex\/loaders\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-vtex/loaders/$1"`], + [/^"apps\/vtex\/types(?:\.ts)?"$/, `"@decocms/apps-vtex/types"`], [/^"apps\/vtex\/mod(?:\.ts)?"$/, `"~/types/vtex-app"`], // Apps — Shopify (hooks, utils, actions, loaders) - [/^"apps\/shopify\/hooks\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/shopify/hooks/$1"`], - [/^"apps\/shopify\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/shopify/utils/$1"`], - [/^"apps\/shopify\/actions\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/shopify/actions/$1"`], - [/^"apps\/shopify\/loaders\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/shopify/loaders/$1"`], + [/^"apps\/shopify\/hooks\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/hooks/$1"`], + [/^"apps\/shopify\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/utils/$1"`], + [/^"apps\/shopify\/actions\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/actions/$1"`], + [/^"apps\/shopify\/loaders\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/loaders/$1"`], // Apps — commerce (types, SDK, utils) - [/^"apps\/commerce\/sdk\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/commerce/sdk/$1"`], - [/^"apps\/commerce\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/commerce/utils/$1"`], + [/^"apps\/commerce\/sdk\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-commerce/sdk/$1"`], + [/^"apps\/commerce\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-commerce/utils/$1"`], // Apps — shared utils (STALE, fetchSafe, createHttpClient, etc.) [/^"apps\/utils\/fetch(?:\.ts)?"$/, `"~/lib/fetch-utils"`], @@ -116,7 +116,7 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [ [/^"@std\/crypto"$/, null], // Use globalThis.crypto instead // site/sdk/* → framework equivalents (before the catch-all site/ → ~/ rule) - [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/start/sdk/clx"`], + [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/blocks/sdk/clx"`], [/^"site\/sdk\/useId(?:\.tsx?)?.*"$/, `"react"`], // useOffer and useVariantPossiblities kept as site files (~/sdk/) [/^"site\/sdk\/usePlatform(?:\.tsx?)?.*"$/, null], @@ -127,10 +127,10 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [ [/^"~\/account\.json"$/, `"~/constants/account"`], // $store/ → ~/ (common Deno import map alias for project root) - [/^"\$store\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/start/sdk/clx"`], + [/^"\$store\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/blocks/sdk/clx"`], [/^"\$store\/sdk\/useId(?:\.tsx?)?.*"$/, `"react"`], // useOffer and useVariantPossiblities kept as site files (~/sdk/) - [/^"\$store\/sdk\/format(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/formatPrice"`], + [/^"\$store\/sdk\/format(?:\.tsx?)?.*"$/, `"@decocms/apps-commerce/sdk/formatPrice"`], [/^"\$store\/sdk\/usePlatform(?:\.tsx?)?.*"$/, null], // islands → components (must be before $store catch-all) [/^"\$store\/islands\/ui\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/ui/$1"`], @@ -141,10 +141,10 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [ [/^"\$home\/(.+)"$/, `"~/$1"`], // site/ → ~/ - [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/start/sdk/clx"`], + [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/blocks/sdk/clx"`], [/^"site\/sdk\/useId(?:\.tsx?)?.*"$/, `"react"`], // useOffer and useVariantPossiblities kept as site files (~/sdk/) - [/^"site\/sdk\/format(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/formatPrice"`], + [/^"site\/sdk\/format(?:\.tsx?)?.*"$/, `"@decocms/apps-commerce/sdk/formatPrice"`], [/^"site\/sdk\/usePlatform(?:\.tsx?)?.*"$/, null], // islands → components (must be before site/ catch-all) [/^"site\/islands\/ui\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/ui/$1"`], @@ -155,10 +155,16 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [ [/^"~\/islands\/ui\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/ui/$1"`], [/^"~\/islands\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/$1"`], - // @decocms/apps hooks → local hooks (react-query hooks crash Workers SSR at module eval) + // @decocms/apps-vtex hooks → local hooks (react-query hooks crash Workers SSR at module eval) + // Pre-7.x monolith path — kept for sites whose Deno import map already + // aliased directly to the npm specifier instead of the "apps/vtex/..." form. [/^"@decocms\/apps\/vtex\/hooks\/useUser"$/, `"~/hooks/useUser"`], [/^"@decocms\/apps\/vtex\/hooks\/useCart"$/, `"~/hooks/useCart"`], [/^"@decocms\/apps\/vtex\/hooks\/useWishlist"$/, `"~/hooks/useWishlist"`], + // Post-7.x split package path — same rationale, current package name. + [/^"@decocms\/apps-vtex\/hooks\/useUser"$/, `"~/hooks/useUser"`], + [/^"@decocms\/apps-vtex\/hooks\/useCart"$/, `"~/hooks/useCart"`], + [/^"@decocms\/apps-vtex\/hooks\/useWishlist"$/, `"~/hooks/useWishlist"`], ]; /** @@ -167,14 +173,14 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [ * The key is the ending of the import path, the value is the replacement specifier. */ const RELATIVE_SDK_REWRITES: Array<[RegExp, string]> = [ - // sdk/clx → @decocms/start/sdk/clx (framework utility) - [/(?:\.\.\/)*sdk\/clx(?:\.tsx?)?$/, "@decocms/start/sdk/clx"], + // sdk/clx → @decocms/blocks/sdk/clx (framework utility) + [/(?:\.\.\/)*sdk\/clx(?:\.tsx?)?$/, "@decocms/blocks/sdk/clx"], // sdk/useId → react (useId is built-in in React 19) [/(?:\.\.\/)*sdk\/useId(?:\.tsx?)?$/, "react"], // sdk/useOffer — kept as-is (sites customize offer logic) // sdk/useVariantPossiblities — kept as-is (sites customize variant logic) - // sdk/format → @decocms/apps/commerce/sdk/formatPrice - [/(?:\.\.\/)*sdk\/format(?:\.tsx?)?$/, "@decocms/apps/commerce/sdk/formatPrice"], + // sdk/format → @decocms/apps-commerce/sdk/formatPrice + [/(?:\.\.\/)*sdk\/format(?:\.tsx?)?$/, "@decocms/apps-commerce/sdk/formatPrice"], // sdk/usePlatform → remove entirely [/(?:\.\.\/)*sdk\/usePlatform(?:\.tsx?)?$/, ""], // static/adminIcons → deleted (icon loaders need rewriting) @@ -234,16 +240,16 @@ export function transformImports( /** * Post-process: split @deco/deco/hooks imports. * In the old stack, @deco/deco/hooks exported useDevice, useScript, useSection, etc. - * In @decocms/start, useDevice is at @decocms/start/sdk/useDevice. + * In @decocms/blocks, useDevice is at @decocms/blocks/sdk/useDevice. * After import rewriting, we need to split lines like: - * import { useDevice, useScript } from "@decocms/start/sdk/useScript" + * import { useDevice, useScript } from "@decocms/blocks/sdk/useScript" * into: - * import { useDevice } from "@decocms/start/sdk/useDevice" - * import { useScript } from "@decocms/start/sdk/useScript" + * import { useDevice } from "@decocms/blocks/sdk/useDevice" + * import { useScript } from "@decocms/blocks/sdk/useScript" */ function splitDecoHooksImports(code: string): string { return code.replace( - /^(import\s+(?:type\s+)?\{)([^}]*\buseDevice\b[^}]*)(\}\s+from\s+["']@decocms\/start\/sdk\/useScript["'];?)$/gm, + /^(import\s+(?:type\s+)?\{)([^}]*\buseDevice\b[^}]*)(\}\s+from\s+["']@decocms\/blocks\/sdk\/useScript["'];?)$/gm, (_match, _prefix, importList, _suffix) => { const items = importList.split(",").map((s: string) => s.trim()).filter(Boolean); const deviceItems = items.filter((s: string) => s.includes("useDevice")); @@ -251,10 +257,10 @@ export function transformImports( const lines: string[] = []; if (deviceItems.length > 0) { - lines.push(`import { ${deviceItems.join(", ")} } from "@decocms/start/sdk/useDevice";`); + lines.push(`import { ${deviceItems.join(", ")} } from "@decocms/blocks/sdk/useDevice";`); } if (otherItems.length > 0) { - lines.push(`import { ${otherItems.join(", ")} } from "@decocms/start/sdk/useScript";`); + lines.push(`import { ${otherItems.join(", ")} } from "@decocms/blocks/sdk/useScript";`); } return lines.join("\n"); }, @@ -356,7 +362,7 @@ export function transformImports( if (afterSplit !== result) { result = afterSplit; changed = true; - notes.push("Split useDevice into separate import from @decocms/start/sdk/useDevice"); + notes.push("Split useDevice into separate import from @decocms/blocks/sdk/useDevice"); } // Rewrite dynamic imports: route through rewriteSpecifier so sdk-specific diff --git a/packages/blocks-cli/scripts/migrate/transforms/section-conventions.ts b/packages/blocks-cli/scripts/migrate/transforms/section-conventions.ts index cfd5cdf..f1ebd52 100644 --- a/packages/blocks-cli/scripts/migrate/transforms/section-conventions.ts +++ b/packages/blocks-cli/scripts/migrate/transforms/section-conventions.ts @@ -8,7 +8,7 @@ import type { SectionMeta, TransformResult } from "../types"; * Adds section convention exports (sync, eager, layout, cache) * to section files based on metadata extracted during analysis. * - * These exports are read by generate-sections.ts in @decocms/start + * These exports are read by generate-sections.ts in @decocms/blocks-cli * to build the sections.gen.ts registry. * * The set of section *names* that get hints applied is configurable diff --git a/packages/blocks-cli/scripts/migrate/types.ts b/packages/blocks-cli/scripts/migrate/types.ts index 9fbca54..a2133e0 100644 --- a/packages/blocks-cli/scripts/migrate/types.ts +++ b/packages/blocks-cli/scripts/migrate/types.ts @@ -122,7 +122,7 @@ export interface LoaderInfo { hasCache: boolean; /** Has export const cacheKey */ hasCacheKey: boolean; - /** Maps to a known @decocms/apps equivalent */ + /** Maps to a known @decocms/apps-* equivalent */ appsEquivalent: string | null; /** Is a custom loader that needs dynamic import in commerce-loaders */ isCustom: boolean; diff --git a/packages/tanstack/package.json b/packages/tanstack/package.json index d01b0bd..68b6a51 100644 --- a/packages/tanstack/package.json +++ b/packages/tanstack/package.json @@ -13,7 +13,8 @@ ".": "./src/index.ts", "./vite": "./src/vite/plugin.js", "./daemon": "./src/daemon/index.ts", - "./sdk/createInvoke": "./src/sdk/createInvoke.ts" + "./sdk/createInvoke": "./src/sdk/createInvoke.ts", + "./sdk/cookiePassthrough": "./src/sdk/cookiePassthrough.ts" }, "scripts": { "build": "tsc", From e6df2c051db68c2a2fc2a47177cd74943feb9184 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 23:45:32 -0300 Subject: [PATCH 79/85] fix(blocks-cli): codegen exclusion predicate misses bare names and applies to directories isExcludedCodegenFile() required a prefix before the marker word, so a file literally named test.ts/spec.tsx/stories.ts (no prefix) was not excluded from section/loader codegen scans. Anchor the marker word to either the start of the filename or a preceding dot so bare names are caught too, while keeping testimonials.tsx/generic.ts (marker word embedded mid-identifier) included. Also: generate-schema.ts's findTsxFiles walk applied the exclusion check to directory entries as well as files, so a directory literally named e.g. foo.gen.ts would be silently skipped instead of walked. generate-sections.ts already applied the predicate to files only; normalize generate-schema.ts to match. Co-Authored-By: Claude Sonnet 5 --- .../blocks-cli/scripts/generate-schema.ts | 13 ++++++++--- .../scripts/lib/codegenExclusions.test.ts | 22 ++++++++++++++----- .../scripts/lib/codegenExclusions.ts | 14 +++++++++++- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/blocks-cli/scripts/generate-schema.ts b/packages/blocks-cli/scripts/generate-schema.ts index 9b9c371..7bac4c1 100644 --- a/packages/blocks-cli/scripts/generate-schema.ts +++ b/packages/blocks-cli/scripts/generate-schema.ts @@ -758,10 +758,17 @@ function findTsxFiles(dir: string): string[] { const results: string[] = []; if (!fs.existsSync(dir)) return results; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (isExcludedCodegenFile(entry.name)) continue; const full = path.join(dir, entry.name); - if (entry.isDirectory()) results.push(...findTsxFiles(full)); - else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) results.push(full); + if (entry.isDirectory()) { + // The exclusion predicate targets generated/test *files* — a directory + // named e.g. `foo.gen.ts` is a real path segment and must still be walked. + results.push(...findTsxFiles(full)); + } else if ( + !isExcludedCodegenFile(entry.name) && + (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) + ) { + results.push(full); + } } return results; } diff --git a/packages/blocks-cli/scripts/lib/codegenExclusions.test.ts b/packages/blocks-cli/scripts/lib/codegenExclusions.test.ts index e132a58..401f808 100644 --- a/packages/blocks-cli/scripts/lib/codegenExclusions.test.ts +++ b/packages/blocks-cli/scripts/lib/codegenExclusions.test.ts @@ -9,14 +9,24 @@ describe("isExcludedCodegenFile", () => { "Hero.stories.tsx", "sections.gen.ts", "meta.gen.json", + // Bare names (no prefix before the dot) must be excluded too. + "test.ts", + "test.tsx", + "spec.tsx", + "stories.ts", + "gen.ts", ])("excludes %s", (name) => { expect(isExcludedCodegenFile(name)).toBe(true); }); - it.each(["Hero.tsx", "Product/SearchResult.tsx", "testimonials.tsx", "generic.ts"])( - "keeps %s", - (name) => { - expect(isExcludedCodegenFile(name)).toBe(false); - }, - ); + it.each([ + "Hero.tsx", + "Product/SearchResult.tsx", + // Marker word embedded mid-identifier (not its own dot-delimited + // segment) must stay INCLUDED, even with the bare-name matching above. + "testimonials.tsx", + "generic.ts", + ])("keeps %s", (name) => { + expect(isExcludedCodegenFile(name)).toBe(false); + }); }); diff --git a/packages/blocks-cli/scripts/lib/codegenExclusions.ts b/packages/blocks-cli/scripts/lib/codegenExclusions.ts index 1c86707..9514ef8 100644 --- a/packages/blocks-cli/scripts/lib/codegenExclusions.ts +++ b/packages/blocks-cli/scripts/lib/codegenExclusions.ts @@ -3,8 +3,20 @@ * `generate-schema.ts` once emitted a site's co-located test file as a * section block (it scans every .ts/.tsx under the sections dir), so both * generators route their directory walks through this predicate. + * + * Matches both the usual `.test.ts` co-located form and a bare + * `test.ts` / `spec.tsx` / `stories.ts` / `gen.ts` file (no prefix before + * the dot) — `(?:^|\.)` anchors the marker word to either the start of the + * filename or a preceding dot, so `testimonials.tsx` / `generic.ts` (marker + * word embedded mid-identifier, not its own dot-delimited segment) are + * correctly left INCLUDED. + * + * IMPORTANT: only ever apply this predicate to file entries, not + * directories, during a directory walk — a directory named e.g. + * `foo.gen.ts` is a real (if unusual) path segment, not a generated file, + * and must still be descended into. */ -const EXCLUDED_SUFFIX_RE = /\.(test|spec|stories|gen)\.(ts|tsx|js|jsx|json)$/; +const EXCLUDED_SUFFIX_RE = /(?:^|\.)(test|spec|stories|gen)\.(ts|tsx|js|jsx|json)$/; export function isExcludedCodegenFile(fileName: string): boolean { return EXCLUDED_SUFFIX_RE.test(fileName); From a122937d7b6e9b831c768429ee08d55201bee936 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 23:45:56 -0300 Subject: [PATCH 80/85] fix(blocks-cli): gate generate-schema.ts's CLI arg parsing behind isMainModule() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI arg-parsing block (argv → SITE_NAMESPACE/SECTIONS_REL/etc.) and the legacy-artifact check ran unconditionally at module scope, so merely importing this file for its pure exports (definitionIdForPath, applyWidgetFormat, typeToJsonSchema — done by generate-schema.test.ts) ran an fs.existsSync check against the importing process's cwd and could print a legacy-artifact warning as an import-time side effect unrelated to what the test wanted to exercise. generateMeta() and the final write are already reached only inside `if (isMainModule())`; move the arg parsing + legacy check into that same guard (declaring the argv-derived vars with real defaults via `let` so generateMeta() still closes over them). The CLI/subprocess behavior is unchanged — verified via the existing generate-schema.test.ts subprocess suite, which drives real `npx tsx generate-schema.ts [args]` invocations. Co-Authored-By: Claude Sonnet 5 --- .../blocks-cli/scripts/generate-schema.ts | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/blocks-cli/scripts/generate-schema.ts b/packages/blocks-cli/scripts/generate-schema.ts index 7bac4c1..8340486 100644 --- a/packages/blocks-cli/scripts/generate-schema.ts +++ b/packages/blocks-cli/scripts/generate-schema.ts @@ -40,6 +40,17 @@ import { warnLegacyArtifact } from "./lib/legacyArtifact"; // --------------------------------------------------------------------------- // CLI arg parsing +// +// Guarded by isMainModule() (defined below — hoisted, so it's callable up +// here) so that importing this module for its pure exports +// (definitionIdForPath, applyWidgetFormat, typeToJsonSchema — see +// generate-schema.test.ts) never reads argv or touches the filesystem. +// Without the guard, every import used to run an `fs.existsSync` check +// against the *importing process's* cwd and could print a legacy-artifact +// warning to stderr as an import-time side effect, unrelated to whatever +// the test actually wanted to exercise. generateMeta() (below) and the +// final write are themselves only reached inside `if (isMainModule())`, so +// these vars only need real values in that same case. // --------------------------------------------------------------------------- const argv = process.argv.slice(2); function arg(name: string, fallback: string): string { @@ -47,20 +58,33 @@ function arg(name: string, fallback: string): string { return idx !== -1 && argv[idx + 1] ? argv[idx + 1] : fallback; } -const SITE_NAMESPACE = arg("namespace", "site"); -const SITE_NAME = arg("site", "storefront"); -const FRAMEWORK_VERSION = arg("version", "1.0.0"); -const SECTIONS_REL = arg("sections", "src/sections"); -const LOADERS_REL = arg("loaders", "src/loaders"); -const APPS_REL = arg("apps", "src/apps"); -const SKIP_APPS = argv.includes("--skip-apps"); -const OUT_FILE_EXPLICIT = argv.includes("--out"); const NEW_DEFAULT_OUT_REL = ".deco/meta.gen.json"; const OLD_DEFAULT_OUT_REL = "src/server/admin/meta.gen.json"; -const OUT_REL = arg("out", NEW_DEFAULT_OUT_REL); -const PLATFORM = arg("platform", "cloudflare"); -if (!OUT_FILE_EXPLICIT && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_REL))) { - warnLegacyArtifact(OLD_DEFAULT_OUT_REL, NEW_DEFAULT_OUT_REL); + +let SITE_NAMESPACE = "site"; +let SITE_NAME = "storefront"; +let FRAMEWORK_VERSION = "1.0.0"; +let SECTIONS_REL = "src/sections"; +let LOADERS_REL = "src/loaders"; +let APPS_REL = "src/apps"; +let SKIP_APPS = false; +let OUT_REL = NEW_DEFAULT_OUT_REL; +let PLATFORM = "cloudflare"; + +if (isMainModule()) { + SITE_NAMESPACE = arg("namespace", SITE_NAMESPACE); + SITE_NAME = arg("site", SITE_NAME); + FRAMEWORK_VERSION = arg("version", FRAMEWORK_VERSION); + SECTIONS_REL = arg("sections", SECTIONS_REL); + LOADERS_REL = arg("loaders", LOADERS_REL); + APPS_REL = arg("apps", APPS_REL); + SKIP_APPS = argv.includes("--skip-apps"); + const outFileExplicit = argv.includes("--out"); + OUT_REL = arg("out", NEW_DEFAULT_OUT_REL); + PLATFORM = arg("platform", PLATFORM); + if (!outFileExplicit && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_REL))) { + warnLegacyArtifact(OLD_DEFAULT_OUT_REL, NEW_DEFAULT_OUT_REL); + } } // --------------------------------------------------------------------------- From 30ef08e3577e230a8acfb8f4eecd78e54fa92f2c Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 23:46:16 -0300 Subject: [PATCH 81/85] fix(blocks-cli): normalize generate-sections.ts output hygiene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate-sections.ts's emitted .deco/sections.gen.ts had no trailing newline in --registry mode (the last pushed line was "};" with no separator after it) and a doubled blank line before the registry doc comment (both the loadingFallbacks block and the registry block unconditionally pushed a "" separator). The same double-blank pattern also showed up between the header comment and the SectionMetaEntry interface whenever a site has no sync/ LoadingFallback sections. Collapse any run of blank lines to a single one and normalize to exactly one trailing newline at the final write, regardless of which branches ran. This changes generated bytes: every consuming site will see a one-time regeneration diff on its next build. No action needed — sites regenerate .deco/sections.gen.ts on every build, so the new formatting self-applies. Co-Authored-By: Claude Sonnet 5 --- .../scripts/generate-sections.test.ts | 67 +++++++++++++++++++ .../blocks-cli/scripts/generate-sections.ts | 18 ++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/blocks-cli/scripts/generate-sections.test.ts b/packages/blocks-cli/scripts/generate-sections.test.ts index 3074cf5..ba54aa3 100644 --- a/packages/blocks-cli/scripts/generate-sections.test.ts +++ b/packages/blocks-cli/scripts/generate-sections.test.ts @@ -258,4 +258,71 @@ describe("generate-sections --registry", () => { const importResult = cp.spawnSync("npx", ["tsx", checkerFile], { encoding: "utf8" }); expect(importResult.status, importResult.stderr).toBe(0); }, 30_000); + + it("ends with exactly one trailing newline and no doubled blank line before the registry block (output hygiene)", () => { + fs.writeFileSync( + path.join(sectionsDir, "Hero.tsx"), + "export const sync = true\nexport default function Hero() { return null }\n", + ); + + const { code } = runGenerator([ + "--sections-dir", sectionsDir, + "--out-file", outFile, + "--registry", + ]); + expect(code).toBe(0); + + const generated = fs.readFileSync(outFile, "utf-8"); + expect(generated.endsWith("\n")).toBe(true); + expect(generated.endsWith("\n\n")).toBe(false); + // No run of 3+ consecutive newlines (i.e. no blank-line pair) anywhere, + // in particular right before the registry doc comment. + expect(generated).not.toMatch(/\n{3,}/); + }, 30_000); +}); + +describe("generate-sections output hygiene (non-registry)", () => { + let tmpDir: string; + let sectionsDir: string; + let outFile: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-hygiene-")); + sectionsDir = path.join(tmpDir, "sections"); + outFile = path.join(tmpDir, "out", "sections.gen.ts"); + fs.mkdirSync(sectionsDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("ends with exactly one trailing newline without --registry", () => { + fs.writeFileSync( + path.join(sectionsDir, "Hero.tsx"), + "export const eager = true;\nexport default function Hero() { return null; }\n", + ); + + const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]); + expect(code).toBe(0); + + const generated = fs.readFileSync(outFile, "utf-8"); + expect(generated.endsWith("\n")).toBe(true); + expect(generated.endsWith("\n\n")).toBe(false); + expect(generated).not.toMatch(/\n{3,}/); + }, 30_000); + + it("ends with exactly one trailing newline when the sections dir is missing (empty-output early-exit path)", () => { + const missingSectionsDir = path.join(tmpDir, "does-not-exist"); + + const { code } = runGenerator([ + "--sections-dir", missingSectionsDir, + "--out-file", outFile, + ]); + expect(code).toBe(0); + + const generated = fs.readFileSync(outFile, "utf-8"); + expect(generated.endsWith("\n")).toBe(true); + expect(generated.endsWith("\n\n")).toBe(false); + }, 30_000); }); diff --git a/packages/blocks-cli/scripts/generate-sections.ts b/packages/blocks-cli/scripts/generate-sections.ts index f7c7e47..d844626 100644 --- a/packages/blocks-cli/scripts/generate-sections.ts +++ b/packages/blocks-cli/scripts/generate-sections.ts @@ -248,7 +248,10 @@ lines.push(""); // Lazy section-import registry — opt-in via --registry, built from every // scanned section file (not just convention-carrying `entries`). if (EMIT_REGISTRY) { - lines.push(""); + // NOTE: no extra `lines.push("")` here — the loadingFallbacks block above + // already pushed a single trailing blank line as its separator. Pushing + // another one here doubled the blank line before this comment block in + // every site's committed .deco/sections.gen.ts. lines.push("/**"); lines.push(" * Lazy section registry — the Next.js/webpack equivalent of Vite's"); lines.push(" * `import.meta.glob` scan over every file under ./sections, recursively."); @@ -265,7 +268,18 @@ if (EMIT_REGISTRY) { } fs.mkdirSync(path.dirname(outFile), { recursive: true }); -fs.writeFileSync(outFile, lines.join("\n")); +// Output hygiene: several sections above push a trailing "" separator +// unconditionally (e.g. after the header comment, after the sync/fallback +// import blocks) — when a following section has nothing to emit (no sync +// imports, --registry off, etc.) those separators stack into a doubled +// blank line. Collapse any run of blank lines down to a single one, then +// normalize to exactly one trailing newline: without --registry, `lines` +// ends with a pushed "" separator (one trailing "\n" once joined); with +// --registry, the last pushed line is "};" (no trailing newline at all). +fs.writeFileSync( + outFile, + lines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n"), +); console.log( `Generated section metadata for ${entries.length} sections → ${path.relative(process.cwd(), outFile)}`, From 080f378e1b055c7fec0b3dc318ed79ba96d6da3d Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 23:47:29 -0300 Subject: [PATCH 82/85] fix(blocks-cli): fix dead condition that skipped the RenderSection import fix-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of upgradeSectionRenderer() checked `!result.includes("RenderSection")` to decide whether to add the missing `import { RenderSection } from "@decocms/blocks/hooks"`. Every `` JSX but had no matching import would silently ship broken. Check for the actual import statement instead of the JSX substring. Export upgradeSectionRenderer for direct testing (mirrors the existing writeImportedLibShims export) and add a regression fixture: a `.Component`/`.props` call converts to `` JSX with no prior import, and the import must now be added. Co-Authored-By: Claude Sonnet 5 --- .../scripts/migrate/phase-cleanup.test.ts | 71 ++++++++++++++++++- .../scripts/migrate/phase-cleanup.ts | 14 +++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/packages/blocks-cli/scripts/migrate/phase-cleanup.test.ts b/packages/blocks-cli/scripts/migrate/phase-cleanup.test.ts index 24cedb4..050e5b0 100644 --- a/packages/blocks-cli/scripts/migrate/phase-cleanup.test.ts +++ b/packages/blocks-cli/scripts/migrate/phase-cleanup.test.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { writeImportedLibShims } from "./phase-cleanup"; +import { upgradeSectionRenderer, writeImportedLibShims } from "./phase-cleanup"; import type { MigrationContext } from "./types"; /** @@ -139,3 +139,72 @@ describe("writeImportedLibShims (integration)", () => { } }); }); + +describe("upgradeSectionRenderer (integration)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "render-section-test-")); + fs.mkdirSync(path.join(tmpDir, "src", "components"), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it( + "adds the missing RenderSection import when a .Component call is " + + "converted to JSX usage and no import exists yet (regression: the " + + "inner check used to always be false)", + () => { + const file = path.join(tmpDir, "src", "components", "Nested.tsx"); + fs.writeFileSync( + file, + `export function Nested({ block }: { block: any }) {\n` + + ` return ;\n` + + `}\n`, + ); + + upgradeSectionRenderer(makeCtx(tmpDir)); + + const result = fs.readFileSync(file, "utf-8"); + expect(result).toContain( + `import { RenderSection } from "@decocms/blocks/hooks";`, + ); + expect(result).toContain(``); + // Import must appear before its first usage. + expect(result.indexOf("import { RenderSection }")).toBeLessThan( + result.indexOf(" { + const file = path.join(tmpDir, "src", "components", "Nested.tsx"); + fs.writeFileSync( + file, + `import { RenderSection } from "@decocms/blocks/hooks";\n\n` + + `export function Nested({ block }: { block: any }) {\n` + + ` return ;\n` + + `}\n`, + ); + + upgradeSectionRenderer(makeCtx(tmpDir)); + + const result = fs.readFileSync(file, "utf-8"); + const importCount = ( + result.match(/import\s*\{\s*RenderSection\s*\}\s*from/g) ?? [] + ).length; + expect(importCount).toBe(1); + }); + + it("does not touch files with no SectionRenderer/.Component usage", () => { + const file = path.join(tmpDir, "src", "components", "Plain.tsx"); + const original = `export const Plain = () =>
hi
;\n`; + fs.writeFileSync(file, original); + + upgradeSectionRenderer(makeCtx(tmpDir)); + + expect(fs.readFileSync(file, "utf-8")).toBe(original); + }); +}); diff --git a/packages/blocks-cli/scripts/migrate/phase-cleanup.ts b/packages/blocks-cli/scripts/migrate/phase-cleanup.ts index c750c5f..7e4b699 100644 --- a/packages/blocks-cli/scripts/migrate/phase-cleanup.ts +++ b/packages/blocks-cli/scripts/migrate/phase-cleanup.ts @@ -963,7 +963,7 @@ function convertDirectComponentCalls(src: string, onFix: (msg: string) => void): * Also converts direct `` patterns to use * RenderSection for robustness. */ -function upgradeSectionRenderer(ctx: MigrationContext) { +export function upgradeSectionRenderer(ctx: MigrationContext) { rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => { if (!relPath.endsWith(".tsx") && !relPath.endsWith(".ts")) return null; @@ -1001,8 +1001,16 @@ function upgradeSectionRenderer(ctx: MigrationContext) { log(ctx, ` ${msg}: src/${relPath}`); }); - // 4. Add RenderSection import if we introduced usages but no import exists - if (changed && result.includes(" Date: Wed, 8 Jul 2026 23:48:05 -0300 Subject: [PATCH 83/85] docs(blocks-cli): document two implicit same-run coordination points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (a) transforms/jsx.ts and phase-cleanup.ts coordinate through the literal "@decocms/start/hooks" import specifier: jsx.ts deliberately writes that pre-split package path as a same-run handoff, and phase-cleanup.ts's upgradeSectionRenderer() matches it later in the same migration run to rewrite it to the real "@decocms/blocks/hooks" target. Neither side named its counterpart, so a future reorder of the transform/cleanup phases could silently desync them. Add a one-line comment on both ends. (b) templates/package-json.ts assumes every @decocms/* package version is lockstep (a single getLatestVersion("@decocms/blocks", ...) call covers blocks/blocks-admin/blocks-cli/tanstack/apps-*). Document what to change if that assumption ever breaks: each `${frameworkVersion}` usage would need its own getLatestVersion() call. No behavior change — comments only. Co-Authored-By: Claude Sonnet 5 --- packages/blocks-cli/scripts/migrate/phase-cleanup.ts | 7 +++++++ .../blocks-cli/scripts/migrate/templates/package-json.ts | 6 ++++++ packages/blocks-cli/scripts/migrate/transforms/jsx.ts | 8 ++++++++ 3 files changed, 21 insertions(+) diff --git a/packages/blocks-cli/scripts/migrate/phase-cleanup.ts b/packages/blocks-cli/scripts/migrate/phase-cleanup.ts index 7e4b699..31ee3f0 100644 --- a/packages/blocks-cli/scripts/migrate/phase-cleanup.ts +++ b/packages/blocks-cli/scripts/migrate/phase-cleanup.ts @@ -975,6 +975,13 @@ export function upgradeSectionRenderer(ctx: MigrationContext) { // (RenderSection is a distinct component from tanstack's // SectionRenderer — it lives in @decocms/blocks/hooks, not // @decocms/tanstack.) + // + // HANDOFF MARKER: the "@decocms/start/hooks" specifier this matches + // is written earlier in the same migration run by + // transforms/jsx.ts (the `.Component`/`.props` → `` + // rewrite), which deliberately emits that pre-split package path as + // a same-run handoff to this function. If the transform/cleanup + // phase order ever changes, this match target must move with it. const sectionRendererImport = /import\s*\{([^}]*)\bSectionRenderer\b([^}]*)\}\s*from\s*["']@decocms\/start\/hooks["']/g; const newContent = result.replace(sectionRendererImport, (_m, before, after) => { diff --git a/packages/blocks-cli/scripts/migrate/templates/package-json.ts b/packages/blocks-cli/scripts/migrate/templates/package-json.ts index 4fe1274..cf0210c 100644 --- a/packages/blocks-cli/scripts/migrate/templates/package-json.ts +++ b/packages/blocks-cli/scripts/migrate/templates/package-json.ts @@ -100,6 +100,12 @@ export function generatePackageJson(ctx: MigrationContext): string { // published in lockstep from the same monorepo release, so a single // version lookup covers all of them (mirrors how consumer sites bump // "@decocms/blocks/blocks-admin/nextjs ranges" together). + // + // If this ever stops being true (e.g. a package starts shipping + // independent version bumps), every `${frameworkVersion}` usage below + // (in `dependencies` and `devDependencies`) needs to become its own + // `getLatestVersion("", "")` call instead of sharing + // this one lookup. const frameworkVersion = getLatestVersion("@decocms/blocks", "7.5.1"); const platformPackage: Partial> = { diff --git a/packages/blocks-cli/scripts/migrate/transforms/jsx.ts b/packages/blocks-cli/scripts/migrate/transforms/jsx.ts index 1516e85..3379a78 100644 --- a/packages/blocks-cli/scripts/migrate/transforms/jsx.ts +++ b/packages/blocks-cli/scripts/migrate/transforms/jsx.ts @@ -257,6 +257,14 @@ export function transformJsx(content: string): TransformResult { // In TanStack Start, nested sections have Component as a string key, not a function. // SectionRenderer from @decocms/start/hooks handles the lazy registry lookup. // + // HANDOFF MARKER: "@decocms/start/hooks" below is intentionally the + // pre-split package path, not the real current one — it's a same-run + // handoff to phase-cleanup.ts's upgradeSectionRenderer(), which runs + // later in the same migration and rewrites this exact import specifier + // to `import { RenderSection } from "@decocms/blocks/hooks"`. If this + // transform's run order relative to the cleanup phase ever changes, that + // rewrite target must move with it. + // // Gate on ANY variant of the .Component/.props pattern (simple, extra props, or multi-line). const sectionPatternGate = /\.\s*Component[\s\n]+\{\.\.\.(\w+)\.props\}/; if (sectionPatternGate.test(result)) { From b67bb9ae649f8e7271e2baadd1db799844d515c2 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 23:48:22 -0300 Subject: [PATCH 84/85] fix(blocks-cli): stop mapping legacy Shopify hooks imports to a nonexistent package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration's import-rewrite rules mapped "apps/shopify/hooks/$1" to "@decocms/apps-shopify/hooks/$1", but that target has never existed: the current @decocms/apps-shopify package ships no src/hooks/ directory and no ./hooks/* export (confirmed via package.json and `git log --diff-filter=A -- '**/shopify/hooks/**'` returning nothing across all history, including the pre-split @decocms/apps monolith it was migrated from wholesale in c7604df). The legacy migration reference (.agents/skills/deco-to-tanstack-migration/references/platform-hooks/README.md) confirms Shopify's useCart/useUser/useWishlist were always meant to become site-local no-op stubs, not package hooks — and templates/hooks.ts's generateHooks() still scaffolds exactly those three at src/hooks/use{Cart,User,Wishlist}.ts for every non-VTEX platform today. Mirror the VTEX rule shape: route the three known hook names to the scaffolded local files (same as "apps/vtex/hooks/useUser" → "~/hooks/useUser" above), and drop the generic fallback entirely rather than pointing it at a package that was never real. No shopify hook name other than these three has ever existed, so an unmatched "apps/shopify/hooks/*" import now falls through to the general "apps/*" catch-all (which removes the import line) — a loud break at the usage site instead of a phantom unresolvable module. Also adds this file's first dedicated test suite (transforms/imports.ts had none), covering the shopify hooks fix above and splitDecoHooksImports (the @deco/deco/hooks → useDevice/useScript split post-process), including the mixed-import and order-of-operations cases. Co-Authored-By: Claude Sonnet 5 --- .../migrate/transforms/imports.test.ts | 138 ++++++++++++++++++ .../scripts/migrate/transforms/imports.ts | 18 ++- 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 packages/blocks-cli/scripts/migrate/transforms/imports.test.ts diff --git a/packages/blocks-cli/scripts/migrate/transforms/imports.test.ts b/packages/blocks-cli/scripts/migrate/transforms/imports.test.ts new file mode 100644 index 0000000..04c3c91 --- /dev/null +++ b/packages/blocks-cli/scripts/migrate/transforms/imports.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { transformImports } from "./imports"; + +describe("transformImports — shopify hooks", () => { + it("rewrites the three known Shopify hooks to the site-local scaffolded files, mirroring VTEX", () => { + const src = + `import { useUser } from "apps/shopify/hooks/useUser.ts";\n` + + `import { useCart } from "apps/shopify/hooks/useCart.ts";\n` + + `import { useWishlist } from "apps/shopify/hooks/useWishlist.ts";\n`; + + const r = transformImports(src); + + expect(r.changed).toBe(true); + expect(r.content).toContain(`import { useUser } from "~/hooks/useUser";`); + expect(r.content).toContain(`import { useCart } from "~/hooks/useCart";`); + expect(r.content).toContain( + `import { useWishlist } from "~/hooks/useWishlist";`, + ); + // Must never point at the nonexistent @decocms/apps-shopify/hooks/* target. + expect(r.content).not.toContain("@decocms/apps-shopify/hooks"); + }); + + it("does not rewrite an unknown shopify hook name to a nonexistent package export", () => { + // No shopify hook other than useCart/useUser/useWishlist has ever existed + // (verified against full git history), so there is no generic fallback + // rule anymore. Unhandled "apps/*" imports fall through to the general + // apps/ catch-all, which removes the import line entirely — surfacing as + // an obvious break at the usage site rather than an unresolvable module. + const src = `import { useSomethingElse } from "apps/shopify/hooks/useSomethingElse.ts";\n`; + + const r = transformImports(src); + + expect(r.content).not.toContain("@decocms/apps-shopify/hooks"); + expect(r.content).not.toContain("apps/shopify/hooks"); + }); + + it("still rewrites shopify utils/actions/loaders to the real @decocms/apps-shopify package", () => { + const src = + `import { formatMoney } from "apps/shopify/utils/formatMoney.ts";\n` + + `import { addItems } from "apps/shopify/actions/cart/addItems.ts";\n` + + `import ProductList from "apps/shopify/loaders/ProductList.ts";\n`; + + const r = transformImports(src); + + expect(r.content).toContain( + `import { formatMoney } from "@decocms/apps-shopify/utils/formatMoney";`, + ); + expect(r.content).toContain( + `import { addItems } from "@decocms/apps-shopify/actions/cart/addItems";`, + ); + expect(r.content).toContain( + `import ProductList from "@decocms/apps-shopify/loaders/ProductList";`, + ); + }); +}); + +describe("transformImports — splitDecoHooksImports", () => { + it("splits a mixed useDevice + non-useDevice named import from the legacy @deco/deco/hooks specifier", () => { + const src = `import { useDevice, useScript, useSection } from "@deco/deco/hooks";\n`; + + const r = transformImports(src); + + expect(r.changed).toBe(true); + expect(r.content).toContain( + `import { useDevice } from "@decocms/blocks/sdk/useDevice";`, + ); + expect(r.content).toContain( + `import { useScript, useSection } from "@decocms/blocks/sdk/useScript";`, + ); + expect(r.notes).toContain( + "Split useDevice into separate import from @decocms/blocks/sdk/useDevice", + ); + }); + + it("emits only the useDevice import when useDevice is the sole named import", () => { + const src = `import { useDevice } from "@deco/deco/hooks";\n`; + + const r = transformImports(src); + + expect(r.content).toBe( + `import { useDevice } from "@decocms/blocks/sdk/useDevice";\n`, + ); + expect(r.content).not.toContain("@decocms/blocks/sdk/useScript"); + }); + + it("leaves a non-useDevice-only import pointed at useScript, unsplit", () => { + const src = `import { useScript, useSection } from "@deco/deco/hooks";\n`; + + const r = transformImports(src); + + expect(r.content).toBe( + `import { useScript, useSection } from "@decocms/blocks/sdk/useScript";\n`, + ); + expect(r.content).not.toContain("@decocms/blocks/sdk/useDevice"); + }); + + it("handles a `type` import with mixed useDevice/non-useDevice specifiers", () => { + const src = `import type { useDevice, useScript } from "@deco/deco/hooks";\n`; + + const r = transformImports(src); + + // The base IMPORT_RULES rewrite fires first (regardless of `type`), then + // splitDecoHooksImports re-splits the resulting line — confirming the + // regex-chain order (rewriteSpecifier → splitDecoHooksImports) still + // matches `import type { ... }` shapes, not just bare `import { ... }`. + expect(r.content).toContain( + `import { useDevice } from "@decocms/blocks/sdk/useDevice";`, + ); + expect(r.content).toContain( + `import { useScript } from "@decocms/blocks/sdk/useScript";`, + ); + }); + + it("preserves aliased specifiers (useDevice as useDeviceHook) on the split line", () => { + const src = `import { useDevice as useDeviceHook, useScript } from "@deco/deco/hooks";\n`; + + const r = transformImports(src); + + expect(r.content).toContain( + `import { useDevice as useDeviceHook } from "@decocms/blocks/sdk/useDevice";`, + ); + expect(r.content).toContain( + `import { useScript } from "@decocms/blocks/sdk/useScript";`, + ); + }); + + it("does not touch @decocms/blocks/sdk/useScript imports that were never routed through @deco/deco/hooks", () => { + // Guards the order-of-operations: splitDecoHooksImports must only ever + // fire as a post-process of the IMPORT_RULES rewrite, not independently + // match any pre-existing import already targeting useScript. + const src = `import { useScript } from "@decocms/blocks/sdk/useScript";\n`; + + const r = transformImports(src); + + expect(r.changed).toBe(false); + expect(r.content).toBe(src); + }); +}); diff --git a/packages/blocks-cli/scripts/migrate/transforms/imports.ts b/packages/blocks-cli/scripts/migrate/transforms/imports.ts index f785529..636e80a 100644 --- a/packages/blocks-cli/scripts/migrate/transforms/imports.ts +++ b/packages/blocks-cli/scripts/migrate/transforms/imports.ts @@ -85,7 +85,23 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [ [/^"apps\/vtex\/types(?:\.ts)?"$/, `"@decocms/apps-vtex/types"`], [/^"apps\/vtex\/mod(?:\.ts)?"$/, `"~/types/vtex-app"`], // Apps — Shopify (hooks, utils, actions, loaders) - [/^"apps\/shopify\/hooks\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/hooks/$1"`], + // Shopify hooks were never a real package export — not in the pre-split + // @decocms/apps monolith, not in @decocms/apps-shopify today (it ships + // no src/hooks/ dir and no ./hooks/* export; verified via `git log + // --diff-filter=A -- '**/shopify/hooks/**'` returning nothing across all + // history). The legacy migration reference + // (.agents/skills/deco-to-tanstack-migration/references/platform-hooks/README.md) + // confirms Shopify's useCart/useUser/useWishlist were always meant to be + // site-local no-op stubs, and templates/hooks.ts's generateHooks() still + // scaffolds them at src/hooks/use{Cart,User,Wishlist}.ts for every + // non-VTEX platform (shopify included). Mirror the VTEX rule shape below: + // route the three known hook names to the scaffolded local files, same as + // "apps/vtex/hooks/useUser" → "~/hooks/useUser" above. Do NOT reintroduce + // a generic "apps/shopify/hooks/$1" → "@decocms/apps-shopify/hooks/$1" + // fallback — that target has never existed. + [/^"apps\/shopify\/hooks\/useUser(?:\.ts)?"$/, `"~/hooks/useUser"`], + [/^"apps\/shopify\/hooks\/useCart(?:\.ts)?"$/, `"~/hooks/useCart"`], + [/^"apps\/shopify\/hooks\/useWishlist(?:\.ts)?"$/, `"~/hooks/useWishlist"`], [/^"apps\/shopify\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/utils/$1"`], [/^"apps\/shopify\/actions\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/actions/$1"`], [/^"apps\/shopify\/loaders\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/loaders/$1"`], From 4d1d1c13e893d7aae24b66d04f4df90f01f261df Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 8 Jul 2026 23:48:30 -0300 Subject: [PATCH 85/85] test(nextjs): cover createNextSetup's untested option paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createNextSetup's option surface (packages/nextjs/src/setup.ts) had a single happy-path test plus a memoization/retry test. Add direct coverage for the remaining paths: blocksDir as a real string path (tmp dir with one JSON decofile), options.blocks winning over blocksDir on an overlapping key, renderShell/previewWrapper/meta reaching @decocms/blocks-admin's setters (mocked, matching the existing routeHandlers.test.ts pattern in this same package — @decocms/blocks-admin is a heavier graph than the CMS core), productionOrigins/customMatchers/onResolveError/onDanglingReference passthrough to createSiteSetup, and extend() receiving the merged loaded blocks. Co-Authored-By: Claude Sonnet 5 --- packages/nextjs/src/setup.test.ts | 160 +++++++++++++++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/setup.test.ts b/packages/nextjs/src/setup.test.ts index 7dca7a6..c61811b 100644 --- a/packages/nextjs/src/setup.test.ts +++ b/packages/nextjs/src/setup.test.ts @@ -7,13 +7,34 @@ // under vitest's "node" environment rather than the package default // (jsdom, used by the component-rendering tests in this same package) to // match that real invocation context. -import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as cms from "@decocms/blocks/cms"; import { listRegisteredSections, loadBlocks, setBlocks } from "@decocms/blocks/cms"; +import { getProductionOrigins } from "@decocms/blocks/sdk/normalizeUrls"; + +// Mirrors routeHandlers.test.ts in this same package: @decocms/blocks-admin +// is mocked (rather than exercised for real) because it's a heavier graph +// than the CMS core — see this file's `@example` JSDoc note in setup.ts. +// The mock applies to every test in this file; existing tests that pass +// `meta` don't assert on setMetaData's *internal* effects (only that the +// `meta` loader itself was invoked), so swapping the real setter for a +// stub doesn't change what they verify. +const adminMocks = vi.hoisted(() => ({ + setMetaData: vi.fn(), + setRenderShell: vi.fn(), + setPreviewWrapper: vi.fn(), +})); +vi.mock("@decocms/blocks-admin", () => adminMocks); + import { createNextSetup } from "./setup"; describe("createNextSetup", () => { beforeEach(() => { setBlocks({}); + vi.clearAllMocks(); }); it("returns a memoized ensureSetup that registers blocks, sections, meta", async () => { @@ -77,4 +98,141 @@ describe("createNextSetup", () => { await expect(ensureSetup()).resolves.toBeUndefined(); expect(meta).toHaveBeenCalledTimes(2); }); + + describe("blocksDir", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "next-setup-blocksdir-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("loads a real string blocksDir path (tmp dir with one JSON decofile)", async () => { + fs.writeFileSync( + path.join(tmpDir, "myBlock.json"), + JSON.stringify({ __resolveType: "site/sections/Hero.tsx" }), + ); + + const ensureSetup = createNextSetup({ + blocksDir: tmpDir, + sections: { "./sections/Hero.tsx": async () => ({ default: () => null }) }, + }); + await ensureSetup(); + + expect(loadBlocks().myBlock).toEqual({ + __resolveType: "site/sections/Hero.tsx", + }); + }); + + it("options.blocks wins over blocksDir on an overlapping key (merge precedence)", async () => { + fs.writeFileSync( + path.join(tmpDir, "shared.json"), + JSON.stringify({ source: "dir" }), + ); + + const ensureSetup = createNextSetup({ + blocksDir: tmpDir, + blocks: { shared: { source: "override" } }, + sections: {}, + }); + await ensureSetup(); + + expect(loadBlocks().shared).toEqual({ source: "override" }); + }); + }); + + it("reaches blocks-admin's setRenderShell, setPreviewWrapper, and setMetaData with the given args", async () => { + const meta = vi.fn().mockResolvedValue({ schema: { definitions: {}, root: {} } }); + const renderShell = { css: "https://cdn.example.com/admin.css", fonts: ["Inter"] }; + const PreviewWrapper = () => null; + + const ensureSetup = createNextSetup({ + blocksDir: false, + sections: {}, + meta, + renderShell, + previewWrapper: PreviewWrapper, + }); + await ensureSetup(); + + expect(adminMocks.setMetaData).toHaveBeenCalledWith( + await meta.mock.results[0]!.value, + ); + expect(adminMocks.setRenderShell).toHaveBeenCalledWith(renderShell); + expect(adminMocks.setPreviewWrapper).toHaveBeenCalledWith(PreviewWrapper); + }); + + it("does not touch blocks-admin's setters when meta/renderShell/previewWrapper are all omitted", async () => { + const ensureSetup = createNextSetup({ + blocksDir: false, + sections: {}, + }); + await ensureSetup(); + + expect(adminMocks.setMetaData).not.toHaveBeenCalled(); + expect(adminMocks.setRenderShell).not.toHaveBeenCalled(); + expect(adminMocks.setPreviewWrapper).not.toHaveBeenCalled(); + }); + + it("passes productionOrigins, customMatchers, onResolveError, and onDanglingReference through to createSiteSetup", async () => { + const matcher = vi.fn(); + const onResolveError = vi.fn(); + const onDanglingReference = vi.fn(); + + // onResolveError / onDanglingReference are installed via module-scope + // setters (setResolveErrorHandler / setDanglingReferenceHandler) with no + // public getter to read the currently-installed handler back — spy on + // the real setters on the @decocms/blocks/cms module namespace. + // createSiteSetup (called by createNextSetup) imports these same two + // names from the identical resolved module ("./cms/index" internally, + // "@decocms/blocks/cms" here — same file per package.json's export + // map), and Vitest's SSR module transform makes named exports mutable + // properties on a shared namespace object, so spying here intercepts + // the call made from inside createSiteSetup too. + const resolveErrorSpy = vi.spyOn(cms, "setResolveErrorHandler"); + const danglingRefSpy = vi.spyOn(cms, "setDanglingReferenceHandler"); + + const ensureSetup = createNextSetup({ + blocksDir: false, + sections: {}, + productionOrigins: ["https://www.example.com"], + customMatchers: [matcher], + onResolveError, + onDanglingReference, + }); + await ensureSetup(); + + // Cheapest direct observation, per the option's own doc comment: each + // customMatchers thunk is called exactly once during setup. + expect(matcher).toHaveBeenCalledTimes(1); + // productionOrigins has a real, cheap getter — assert the registered + // value directly rather than only inferring it went through. + expect(getProductionOrigins()).toEqual(["https://www.example.com"]); + expect(resolveErrorSpy).toHaveBeenCalledWith(onResolveError); + expect(danglingRefSpy).toHaveBeenCalledWith(onDanglingReference); + + resolveErrorSpy.mockRestore(); + danglingRefSpy.mockRestore(); + }); + + it("calls extend with the merged, loaded blocks", async () => { + const extend = vi.fn(); + const ensureSetup = createNextSetup({ + blocksDir: false, + blocks: { myBlock: { __resolveType: "site/sections/Hero.tsx" } }, + sections: { "./sections/Hero.tsx": async () => ({ default: () => null }) }, + extend, + }); + await ensureSetup(); + + expect(extend).toHaveBeenCalledTimes(1); + expect(extend).toHaveBeenCalledWith( + expect.objectContaining({ + myBlock: { __resolveType: "site/sections/Hero.tsx" }, + }), + ); + }); });